Files
Stonks/stockscreener.php
2026-07-11 11:47:12 +02:00

2018 lines
84 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Momentum Screener — Real-time Streaming (Ultra-Optimized)
* Sources:
* 1. TradingView — Most Volatile (US stocks)
* 2. NASDAQ — Market Cap screener (Medium/Small/Micro/Nano)
* 3. NASDAQ Gainers — Top % Gainers list (api.nasdaq.com)
* 4. Yahoo Trending — Trending symbols (query1.finance.yahoo.com)
*/
// ─── Yahoo Proxy (CORS bypass) ──────────────────────────────────────────────
if (isset($_GET['yahoo_proxy']) && $_GET['yahoo_proxy'] == '1') {
header('Content-Type: application/json');
$q = isset($_GET['q']) ? trim($_GET['q']) : '';
if (empty($q)) {
echo json_encode(['error' => 'Missing q parameter']);
exit;
}
$url = 'https://query1.finance.yahoo.com/v1/finance/search?q=' . urlencode($q) . '&quotesCount=1&newsCount=0';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) {
echo json_encode(['error' => 'Yahoo proxy failed', 'http_code' => $httpCode]);
} else {
echo $response;
}
exit;
}
// ─── Get JSON endpoint ──────────────────────────────────────────────────────
if (isset($_GET['get_json']) && $_GET['get_json'] == '1') {
header('Content-Type: application/json');
$jsonFile = __DIR__ . '/stockscreener.json';
if (file_exists($jsonFile)) {
echo file_get_contents($jsonFile);
} else {
echo '[]';
}
exit;
}
// ─── Save to JSON endpoint ──────────────────────────────────────────────────
if (isset($_POST['save_json']) && $_POST['save_json'] === '1') {
header('Content-Type: application/json');
$data = isset($_POST['data']) ? json_decode($_POST['data'], true) : null;
if (!$data || !is_array($data)) {
echo json_encode(['error' => 'Invalid data']);
exit;
}
$jsonFile = __DIR__ . '/stockscreener.json';
$existing = [];
if (file_exists($jsonFile)) {
$existing = json_decode(file_get_contents($jsonFile), true);
if (!is_array($existing)) $existing = [];
}
// Build a lookup of symbols already present, so we can update them
// in place (e.g. a changed comment) instead of only appending new ones.
$existingIndexBySymbol = [];
foreach ($existing as $idx => $e) {
if (!empty($e['symbol'])) {
$existingIndexBySymbol[$e['symbol']] = $idx;
}
}
$added = 0;
$updated = 0;
foreach ($data as $entry) {
if (empty($entry['symbol'])) continue;
if (isset($existingIndexBySymbol[$entry['symbol']])) {
// Symbol already present — merge in any changed fields
// (isin, gettex_status, comment, etc.) rather than skipping it.
$idx = $existingIndexBySymbol[$entry['symbol']];
$existing[$idx] = array_merge($existing[$idx], $entry);
$updated++;
} else {
$existing[] = $entry;
$existingIndexBySymbol[$entry['symbol']] = count($existing) - 1;
$added++;
}
}
if (file_put_contents($jsonFile, json_encode($existing, JSON_PRETTY_PRINT))) {
echo json_encode(['success' => true, 'count' => count($existing), 'added' => $added, 'updated' => $updated]);
} else {
echo json_encode(['error' => 'Failed to write file']);
}
exit;
}
// ─── Config ──────────────────────────────────────────────────────────────────
$MIN_AVG_VOLUME = 70000;
$MIN_PRICE = 0.50;
$MAX_PRICE = 5.00;
$MIN_PCT_CHANGE = 3.0;
$LOOKBACK_DAYS = 20;
$BREAKOUT_THRESHOLD = 1.02;
$VOLUME_SPIKE = 1.2;
$MAX_RSI_BREAKOUT = 80;
$VOL_SURGE_MIN = 2.0;
$VOL_SURGE_PRICE_MIN = 1.02;
$REVERSAL_MAX_RSI = 35;
$REVERSAL_MIN_VOL = 1.5;
$REVERSAL_MIN_BOUNCE = 1.05;
$HIGH_MOMENTUM_THRESHOLD = 15;
$MOM_GAP_THRESHOLD = 10;
$TICKER_BASE_URL = 'https://alceawis.de/other/extra/fetchdata/2026-05-13-Finance/2026-05-13-Stocks/process.php?symbol=';
// ─── NASDAQ Cap Band Definitions ────────────────────────────────────────────
define('CAP_MEDIUM_MIN', 2_000_000_000);
define('CAP_MEDIUM_MAX', 10_000_000_000);
define('CAP_SMALL_MIN', 300_000_000);
define('CAP_SMALL_MAX', 2_000_000_000);
define('CAP_MICRO_MIN', 50_000_000);
define('CAP_MICRO_MAX', 300_000_000);
define('CAP_NANO_MAX', 50_000_000);
function capBand(float $cap): ?string {
if ($cap >= CAP_MEDIUM_MIN && $cap < CAP_MEDIUM_MAX) return 'Medium';
if ($cap >= CAP_SMALL_MIN && $cap < CAP_SMALL_MAX) return 'Small';
if ($cap >= CAP_MICRO_MIN && $cap < CAP_MICRO_MAX) return 'Micro';
if ($cap > 0 && $cap < CAP_NANO_MAX) return 'Nano';
return null;
}
// ─── Check if this is a streaming request ──────────────────────────────────
$isStream = isset($_GET['stream']) && $_GET['stream'] === '1';
if ($isStream) {
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
ob_end_flush();
runStreamingScan();
exit;
}
// ─── Run streaming scan ─────────────────────────────────────────────────────
function runStreamingScan() {
global $MIN_AVG_VOLUME, $MIN_PRICE, $MAX_PRICE, $MIN_PCT_CHANGE, $LOOKBACK_DAYS;
global $BREAKOUT_THRESHOLD, $VOLUME_SPIKE, $MAX_RSI_BREAKOUT;
global $VOL_SURGE_MIN, $VOL_SURGE_PRICE_MIN;
global $REVERSAL_MAX_RSI, $REVERSAL_MIN_VOL, $REVERSAL_MIN_BOUNCE;
global $HIGH_MOMENTUM_THRESHOLD, $MOM_GAP_THRESHOLD;
$seenSymbols = [];
$stats = [
'tv_checked' => 0, 'tv_signals' => 0, 'tv_total' => 0,
'nas_checked' => 0, 'nas_signals' => 0, 'nas_total' => 0,
'nas_skipped_price' => 0, 'nas_skipped_change' => 0, 'nas_skipped_cap' => 0,
'nasg_checked' => 0, 'nasg_signals' => 0, 'nasg_total' => 0,
'ytrend_checked' => 0, 'ytrend_signals' => 0, 'ytrend_total' => 0,
];
// ── Run 1: TradingView ──
sendEvent('status', ['message' => 'Fetching TradingView volatile stocks...', 'type' => 'info']);
$tvCandidates = fetchTradingViewVolatile();
runSimpleSourceScan('TradingView', 'tv', $tvCandidates, $seenSymbols, $stats);
// ── Run 2: NASDAQ ──
sendEvent('status', ['message' => 'Fetching NASDAQ screener data...', 'type' => 'info']);
$allNasdaqStocks = fetchNasdaqScreener();
$filteredCandidates = [];
$skipReasons = [
'price_too_high' => 0,
'price_too_low' => 0,
'cap_too_big' => 0,
'pct_change_too_low' => 0,
'no_data' => 0
];
foreach ($allNasdaqStocks as $cand) {
$price = (float) str_replace(['$', ','], '', $cand['lastsale'] ?? '0');
$pctChangeRaw = str_replace('%', '', $cand['pctchange'] ?? '0');
$pctChange = (float) $pctChangeRaw;
$cap = $cand['marketCap'] ?? 0;
if ($price > $MAX_PRICE) { $skipReasons['price_too_high']++; continue; }
if ($price < $MIN_PRICE) { $skipReasons['price_too_low']++; continue; }
if ($cap > 10_000_000_000) { $skipReasons['cap_too_big']++; continue; }
if (abs($pctChange) < 1.0) { $skipReasons['pct_change_too_low']++; continue; }
if ($price <= 0 || $cap <= 0) { $skipReasons['no_data']++; continue; }
$filteredCandidates[] = $cand;
}
$stats['nas_total'] = count($filteredCandidates);
$stats['nas_original'] = count($allNasdaqStocks);
$stats['nas_skipped_price'] = $skipReasons['price_too_high'] + $skipReasons['price_too_low'];
$stats['nas_skipped_cap'] = $skipReasons['cap_too_big'];
$stats['nas_skipped_change'] = $skipReasons['pct_change_too_low'];
$stats['nas_skipped_total'] = array_sum($skipReasons);
$totalSkipped = $stats['nas_skipped_total'];
$keptPercent = $stats['nas_total'] > 0 ? round(($stats['nas_total'] / max($stats['nas_original'], 1)) * 100, 1) : 0;
sendEvent('status', [
'message' => "NASDAQ: {$stats['nas_original']}{$stats['nas_total']} candidates (price≤$".$MAX_PRICE.", skipped {$totalSkipped}) - Price too high: {$skipReasons['price_too_high']}, Change<1%: {$skipReasons['pct_change_too_low']}, Cap: {$skipReasons['cap_too_big']}",
'type' => 'info'
]);
foreach ($filteredCandidates as $idx => $cand) {
$stats['nas_checked']++;
$sym = $cand['symbol'];
if ($idx % 5 === 0) {
sendEvent('progress', [
'source' => 'NASDAQ',
'checked' => $stats['nas_checked'],
'total' => $stats['nas_total'],
'signals' => $stats['nas_signals'],
'symbol' => $sym,
'skipped' => $stats['nas_skipped_total'],
'kept_pct' => $keptPercent
]);
}
if (isset($seenSymbols[$sym])) continue;
$hist = fetchHistory($sym);
if (!$hist) continue;
if ($sig = screen($hist, $sym)) {
$sig['source'] = 'NASDAQ';
$sig['marketCap'] = $cand['marketCap'];
$sig['sector'] = $cand['sector'];
$sig['band'] = $cand['band'];
$sig['companyName'] = $cand['name'] ?? $sym;
$stats['nas_signals']++;
$seenSymbols[$sym] = true;
sendEvent('signal', array_merge($sig, ['stats' => $stats]));
}
usleep(50000);
}
sendEvent('status', [
'message' => "NASDAQ done: {$stats['nas_signals']} signals found",
'type' => 'info'
]);
// ── Run 3: NASDAQ Top Gainers ──
sendEvent('status', ['message' => 'Fetching NASDAQ top gainers...', 'type' => 'info']);
$nasgCandidates = fetchNasdaqGainers();
runSimpleSourceScan('NasdaqGainers', 'nasg', $nasgCandidates, $seenSymbols, $stats);
// ── Run 4: Yahoo Trending ──
sendEvent('status', ['message' => 'Fetching Yahoo Finance trending symbols...', 'type' => 'info']);
$ytrendCandidates = fetchYahooTrending();
runSimpleSourceScan('YahooTrending', 'ytrend', $ytrendCandidates, $seenSymbols, $stats);
$totalSignals = $stats['tv_signals'] + $stats['nas_signals'] + $stats['nasg_signals']
+ $stats['ytrend_signals'];
sendEvent('done', [
'message' => "✅ Complete! Found {$totalSignals} signals across 4 sources.",
'stats' => $stats
]);
}
// ─── Generic scan loop ──────────────────────────────────────────────────────
function runSimpleSourceScan(string $sourceLabel, string $statPrefix, array $candidates, array &$seenSymbols, array &$stats) {
$totalKey = "{$statPrefix}_total";
$checkedKey = "{$statPrefix}_checked";
$signalsKey = "{$statPrefix}_signals";
$stats[$totalKey] = count($candidates);
sendEvent('status', [
'message' => "Found {$stats[$totalKey]} candidates from {$sourceLabel}, screening...",
'type' => 'info'
]);
foreach ($candidates as $idx => $sym) {
$stats[$checkedKey]++;
if ($idx % 5 === 0) {
sendEvent('progress', [
'source' => $sourceLabel,
'checked' => $stats[$checkedKey],
'total' => $stats[$totalKey],
'signals' => $stats[$signalsKey],
'symbol' => $sym
]);
}
if (isset($seenSymbols[$sym])) continue;
$hist = fetchHistory($sym);
if (!$hist) continue;
if ($sig = screen($hist, $sym)) {
$sig['source'] = $sourceLabel;
$sig['marketCap'] = null;
$sig['sector'] = null;
$sig['band'] = null;
$sig['companyName'] = $sym; // Will be enriched by JS if needed
$stats[$signalsKey]++;
$seenSymbols[$sym] = true;
sendEvent('signal', array_merge($sig, ['stats' => $stats]));
}
usleep(50000);
}
sendEvent('status', [
'message' => "{$sourceLabel} done: {$stats[$signalsKey]} signals found",
'type' => 'info'
]);
}
// ─── Helper to send SSE event ──────────────────────────────────────────────
function sendEvent($event, $data) {
echo "event: {$event}\n";
echo "data: " . json_encode($data) . "\n\n";
flush();
if (connection_aborted()) {
exit;
}
}
// ─── Discovery Functions ────────────────────────────────────────────────────
function fetchTradingViewVolatile(): array {
$url = 'https://www.tradingview.com/markets/stocks-usa/market-movers-most-volatile/';
$opts = [
'http' => [
'header' => "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\r\n",
'timeout' => 20
]
];
$html = @file_get_contents($url, false, stream_context_create($opts));
if (!$html) return [];
preg_match_all('/data-rowkey="([A-Z]+:[A-Z]+)"/', $html, $matches);
$symbols = [];
foreach ($matches[1] as $rowkey) {
$parts = explode(':', $rowkey);
$symbols[] = $parts[1] ?? $rowkey;
}
return array_values(array_unique($symbols));
}
function fetchNasdaqScreener(): array {
$url = 'https://api.nasdaq.com/api/screener/stocks?' . http_build_query([
'tableonly' => 'true',
'limit' => 0,
'offset' => 0,
'download' => 'true',
]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Accept: application/json, text/plain, */*',
'Accept-Language: en-US,en;q=0.9',
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
. '(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
'Origin: https://www.nasdaq.com',
'Referer: https://www.nasdaq.com/market-activity/stocks/screener',
],
CURLOPT_ENCODING => '',
CURLOPT_TIMEOUT => 60,
]);
$response = curl_exec($ch);
if (curl_errno($ch) || curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 200) {
curl_close($ch);
return [];
}
curl_close($ch);
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return [];
}
$allRows = $data['data']['rows']
?? $data['data']['table']['rows']
?? $data['rows']
?? $data['table']['rows']
?? [];
if (empty($allRows)) return [];
$wanted = ['Medium', 'Small', 'Micro', 'Nano'];
$filtered = [];
foreach ($allRows as $row) {
$capRaw = str_replace(',', '', $row['marketCap'] ?? '0');
$cap = is_numeric($capRaw) ? (float) $capRaw : 0.0;
$band = capBand($cap);
if ($band !== null && in_array($band, $wanted, true)) {
$filtered[] = [
'symbol' => $row['symbol'] ?? '',
'name' => $row['name'] ?? '',
'marketCap' => $cap,
'sector' => $row['sector'] ?? '',
'lastsale' => $row['lastsale'] ?? '0',
'pctchange' => $row['pctchange'] ?? '0%',
'band' => $band,
];
}
}
return $filtered;
}
function fetchNasdaqGainers(): array {
$url = 'https://api.nasdaq.com/api/quote/list-type-extended/gainers?' . http_build_query([
'assetclass' => 'stocks',
]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Accept: application/json, text/plain, */*',
'Accept-Language: en-US,en;q=0.9',
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
. '(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
'Origin: https://www.nasdaq.com',
'Referer: https://www.nasdaq.com/market-activity/stocks/gainers',
],
CURLOPT_ENCODING => '',
CURLOPT_TIMEOUT => 30,
]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($resp === false || $httpCode !== 200) {
error_log("NASDAQ Gainers fetch failed: HTTP {$httpCode}, curl_error: {$curlErr}");
return [];
}
$data = json_decode($resp, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log("NASDAQ Gainers JSON decode failed: " . json_last_error_msg());
return [];
}
$rows = $data['data']['gainers']['rows']
?? $data['data']['rows']
?? $data['data']['table']['rows']
?? [];
$symbols = [];
foreach ($rows as $row) {
if (!empty($row['symbol'])) $symbols[] = $row['symbol'];
}
return array_values(array_unique($symbols));
}
function fetchYahooTrending(): array {
$url = 'https://query1.finance.yahoo.com/v1/finance/trending/US?' . http_build_query([
'count' => 50,
]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
. '(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
],
CURLOPT_ENCODING => '',
CURLOPT_TIMEOUT => 15,
]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($resp === false || $httpCode !== 200) {
error_log("Yahoo Trending fetch failed: HTTP {$httpCode}, curl_error: {$curlErr}");
return [];
}
$data = json_decode($resp, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log("Yahoo Trending JSON decode failed: " . json_last_error_msg());
return [];
}
$quotes = $data['finance']['result'][0]['quotes'] ?? [];
$symbols = [];
foreach ($quotes as $q) {
if (!empty($q['symbol'])) $symbols[] = $q['symbol'];
}
return array_values(array_unique($symbols));
}
function fetchHistory(string $symbol): ?array {
$p1 = strtotime("-90 days");
$p2 = time();
$url = "https://query1.finance.yahoo.com/v8/finance/chart/{$symbol}?period1={$p1}&period2={$p2}&interval=1d&events=history&includeAdjustedClose=true";
$opts = ['http' => ['header' => "User-Agent: Mozilla/5.0\r\n", 'timeout' => 12]];
$json = @file_get_contents($url, false, stream_context_create($opts));
if (!$json) return null;
$d = json_decode($json, true);
if (empty($d['chart']['result'][0])) return null;
$r = $d['chart']['result'][0];
$ts = $r['timestamp'] ?? [];
$q = $r['indicators']['quote'][0] ?? [];
$adj = $r['indicators']['adjclose'][0]['adjclose'] ?? [];
$hist = [];
for ($i = 0; $i < count($ts); $i++) {
if (!isset($adj[$i]) || $adj[$i] === null) continue;
$hist[] = [
'date' => date('Y-m-d', $ts[$i]),
'open' => $q['open'][$i] ?? null,
'high' => $q['high'][$i] ?? null,
'low' => $q['low'][$i] ?? null,
'close' => $adj[$i],
'volume' => $q['volume'][$i] ?? 0,
];
}
return $hist;
}
// ─── Technical Helpers ───────────────────────────────────────────────────────
function calcRSI(array $closes, int $period = 14): float {
if (count($closes) < $period + 1) return 50;
$gains = $losses = [];
for ($i = count($closes) - $period; $i < count($closes); $i++) {
$change = $closes[$i] - $closes[$i - 1];
$gains[] = max($change, 0);
$losses[] = max(-$change, 0);
}
$avgGain = array_sum($gains) / $period;
$avgLoss = array_sum($losses) / $period;
if ($avgLoss == 0) return 100;
return 100 - (100 / (1 + ($avgGain / $avgLoss)));
}
function screen(array $h, string $sym): ?array {
global $LOOKBACK_DAYS, $MIN_AVG_VOLUME, $MIN_PRICE, $MAX_PRICE;
global $BREAKOUT_THRESHOLD, $VOLUME_SPIKE, $MAX_RSI_BREAKOUT;
global $VOL_SURGE_MIN, $VOL_SURGE_PRICE_MIN;
global $REVERSAL_MAX_RSI, $REVERSAL_MIN_VOL, $REVERSAL_MIN_BOUNCE;
global $HIGH_MOMENTUM_THRESHOLD, $MOM_GAP_THRESHOLD;
if (count($h) < $LOOKBACK_DAYS + 5) return null;
$today = end($h);
$price = $today['close'];
$vol = $today['volume'];
if ($price < $MIN_PRICE) return null;
if ($price > $MAX_PRICE) return null;
$past = array_slice($h, -($LOOKBACK_DAYS + 1), $LOOKBACK_DAYS);
$high20 = max(array_column($past, 'high'));
$low20 = min(array_column($past, 'low'));
$avgVol = array_sum(array_column($past, 'volume')) / count($past);
if ($avgVol < 5000) return null;
$closes = array_column($h, 'close');
$rsi = calcRSI($closes);
$threeAgo = $h[count($h) - 4]['close'] ?? $price;
$mom3d = (($price - $threeAgo) / $threeAgo) * 100;
if (count($h) >= 6) {
$fiveDaysAgo = $h[count($h) - 6]['close'] ?? $price;
$mom5d = (($price - $fiveDaysAgo) / $fiveDaysAgo) * 100;
if ($mom5d >= $HIGH_MOMENTUM_THRESHOLD && $rsi < 85) {
return [
'symbol' => $sym,
'strategy' => 'HIGH-MOMENTUM',
'price' => $price,
'trigger' => $mom5d.'% in 5d',
'trigger_pct' => round($mom5d, 1),
'rsi' => round($rsi,1),
'spike' => round($vol/max($avgVol,1),1),
'vol' => $vol,
'avg_vol' => round($avgVol),
'mom3d' => round($mom3d, 1),
];
}
}
if ($mom3d >= $MOM_GAP_THRESHOLD && $vol < $avgVol * 0.5 && $rsi < 80) {
return [
'symbol' => $sym,
'strategy' => 'MOM-GAP',
'price' => $price,
'trigger' => $mom3d.'% in 3d (low vol)',
'trigger_pct' => round($mom3d, 1),
'rsi' => round($rsi,1),
'spike' => round($vol/max($avgVol,1),1),
'vol' => $vol,
'avg_vol' => round($avgVol),
'mom3d' => round($mom3d, 1),
];
}
if ($price >= $high20 * $BREAKOUT_THRESHOLD && $vol >= $avgVol * $VOLUME_SPIKE && $rsi <= $MAX_RSI_BREAKOUT) {
return [
'symbol' => $sym, 'strategy' => 'BREAKOUT',
'price' => $price, 'trigger' => '$'.number_format($high20,2).' high',
'trigger_pct' => round((($price - $high20) / $high20) * 100, 1),
'rsi' => round($rsi,1), 'spike' => round($vol/max($avgVol,1),1),
'vol' => $vol, 'avg_vol' => round($avgVol),
'mom3d' => round($mom3d, 1),
];
}
$yesterday = $h[count($h) - 2] ?? $today;
$priceChange = (($price - $yesterday['close']) / $yesterday['close']);
if ($vol >= $avgVol * $VOL_SURGE_MIN && $priceChange >= 0.02 && $rsi < 75) {
return [
'symbol' => $sym, 'strategy' => 'VOL-SURGE',
'price' => $price, 'trigger' => '+'.round($priceChange*100,1).'% today',
'trigger_pct' => round($priceChange*100, 1),
'rsi' => round($rsi,1), 'spike' => round($vol/max($avgVol,1),1),
'vol' => $vol, 'avg_vol' => round($avgVol),
'mom3d' => round($mom3d, 1),
];
}
if ($rsi <= $REVERSAL_MAX_RSI && $vol >= $avgVol * $REVERSAL_MIN_VOL && $price >= $low20 * $REVERSAL_MIN_BOUNCE) {
return [
'symbol' => $sym, 'strategy' => 'REVERSAL',
'price' => $price, 'trigger' => 'RSI '.round($rsi,1).' + bounce',
'trigger_pct' => round((($price - $low20) / $low20) * 100, 1),
'rsi' => round($rsi,1), 'spike' => round($vol/max($avgVol,1),1),
'vol' => $vol, 'avg_vol' => round($avgVol),
'mom3d' => round($mom3d, 1),
];
}
return null;
}
// ─── If not streaming, show the UI ──────────────────────────────────────────
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html>
<head>
<title>Momentum Screener — Real-time</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; margin: 0; }
h2 { color: #58a6ff; margin: 0 0 6px 0; font-size: 22px; }
.meta { color: #8b949e; font-size: 13px; margin-bottom: 24px; }
.stats { display: flex; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.stat-box { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px 18px; min-width: 110px; }
.stat-label { color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-value { color: #fff; font-size: 20px; font-weight: 700; margin-top: 4px; }
.stat-value.loading { color: #d29922; animation: pulse 1s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.source-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-left: 6px; vertical-align: middle; }
.source-tv { background: #1f6feb; color: #fff; }
.source-nas { background: #8957e5; color: #fff; }
.source-nasg { background: #d29922; color: #000; }
.source-ytrend { background: #39c5cf; color: #000; }
.band-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 8px; font-weight: 700; background: #21262d; color: #8b949e; margin-left: 4px; }
.band-medium { background: #238636; color: #fff; }
.band-small { background: #d29922; color: #000; }
.band-micro { background: #da3633; color: #fff; }
.band-nano { background: #8957e5; color: #fff; }
.isin-link {
color: #58a6ff;
text-decoration: none;
font-size: 11px;
cursor: pointer;
padding: 2px 8px;
border: 1px solid #30363d;
border-radius: 4px;
background: #0d1117;
display: inline-block;
transition: all 0.2s;
}
.isin-link:hover {
background: #1c2128;
border-color: #58a6ff;
}
.isin-link.loading {
opacity: 0.5;
cursor: wait;
}
.isin-result {
font-size: 11px;
margin-top: 4px;
padding: 4px 8px;
border-radius: 4px;
background: #161b22;
border: 1px solid #30363d;
display: none;
}
.isin-result.show {
display: block;
}
.isin-result .found {
color: #3fb950;
}
.isin-result .not-found {
color: #f85149;
}
.isin-result .multiple {
color: #d29922;
cursor: pointer;
text-decoration: underline;
}
.comment-input {
width: 140px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 4px;
color: #c9d1d9;
font-size: 11px;
padding: 4px 6px;
text-align: left;
}
.comment-input:focus {
outline: none;
border-color: #58a6ff;
}
.comment-input.saved-flash {
border-color: #3fb950;
}
tr.reversesplit-row td {
text-decoration: line-through;
text-decoration-color: #f85149;
opacity: 0.55;
}
tr.reversesplit-row .comment-input {
text-decoration: line-through;
text-decoration-color: #f85149;
opacity: 1;
}
table { border-collapse: collapse; width: 100%; font-size: 13px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
th {
background: #21262d;
color: #58a6ff;
padding: 12px 14px;
text-align: right;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #30363d;
cursor: pointer;
user-select: none;
transition: background 0.15s;
white-space: nowrap;
position: sticky;
top: 0;
z-index: 10;
}
th:hover { background: #30363d; }
th:first-child, td:first-child { text-align: left; }
th .arrow {
margin-left: 6px;
opacity: 0.3;
font-size: 10px;
display: inline-block;
transition: opacity 0.2s;
}
th.sorted .arrow { opacity: 1; }
th.sorted-asc .arrow::after { content: '▲'; }
th.sorted-desc .arrow::after { content: '▼'; }
td { padding: 10px 14px; border-bottom: 1px solid #21262d; text-align: right; vertical-align: middle; }
tr:hover { background: #1c2128; }
tr:last-child td { border-bottom: none; }
.sym a { font-weight: 700; color: #58a6ff; font-size: 14px; text-decoration: none; }
.sym a:hover { text-decoration: underline; color: #79c0ff; }
.sym .company-name { color: #8b949e; font-size: 11px; font-weight: 400; display: block; }
.strat { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
.strat-breakout { background: #238636; color: #fff; }
.strat-vol { background: #8957e5; color: #fff; }
.strat-rev { background: #d29922; color: #000; }
.strat-high { background: #f0883e; color: #000; }
.strat-gap { background: #da3633; color: #fff; }
.spike { color: #f85149; font-weight: 700; }
.results-container { max-height: 600px; overflow-y: auto; }
.results-container::-webkit-scrollbar { width: 8px; }
.results-container::-webkit-scrollbar-track { background: #0d1117; }
.results-container::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
.results-container::-webkit-scrollbar-thumb:hover { background: #484f58; }
.source-header { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.source-pill { padding: 4px 12px; border-radius: 16px; font-size: 12px; font-weight: 600; cursor: pointer; border: 1px solid #30363d; background: #161b22; color: #8b949e; }
.source-pill.active { background: #21262d; color: #58a6ff; border-color: #58a6ff; }
.source-pill:hover { border-color: #58a6ff; }
.scan-btn { background: #238636; color: #fff; border: none; padding: 8px 20px; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 14px; }
.scan-btn:hover { background: #2ea043; }
.scan-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.scan-btn.scanning { background: #d29922; }
.save-btn { background: #1f6feb; color: #fff; border: none; padding: 8px 20px; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 14px; margin-left: 8px; }
.save-btn:hover { background: #388bfd; }
.save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.status-bar {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
font-size: 13px;
color: #8b949e;
min-height: 44px;
display: flex;
flex-direction: column;
gap: 8px;
}
.status-top {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.status-dot.idle { background: #8b949e; }
.status-dot.scanning { background: #d29922; animation: pulse 1s infinite; }
.status-dot.done { background: #3fb950; }
.status-dot.error { background: #f85149; }
.status-message { flex: 1; }
.log-container {
max-height: 150px;
overflow-y: auto;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 4px;
padding: 6px 8px;
font-family: 'Courier New', monospace;
font-size: 11px;
line-height: 1.5;
color: #c9d1d9;
margin-top: 4px;
scroll-behavior: smooth;
}
.log-container::-webkit-scrollbar { width: 6px; }
.log-container::-webkit-scrollbar-track { background: #0d1117; }
.log-container::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
.log-entry {
border-bottom: 1px solid #21262d;
padding: 2px 0;
white-space: pre-wrap;
word-break: break-all;
}
.log-entry:last-child { border-bottom: none; }
.log-entry .timestamp { color: #484f58; margin-right: 8px; }
.log-entry .level-info { color: #58a6ff; }
.log-entry .level-debug { color: #8b949e; }
.log-entry .level-error { color: #f85149; }
.log-entry .level-success { color: #3fb950; }
.signal-flash { animation: flashGreen 0.5s; }
@keyframes flashGreen { 0% { background: rgba(63, 185, 80, 0.3); } 100% { background: transparent; } }
.price-filter-info {
background: #1c2128;
border: 1px solid #30363d;
border-radius: 6px;
padding: 8px 14px;
font-size: 12px;
color: #8b949e;
margin-bottom: 12px;
display: inline-block;
}
.price-filter-info strong {
color: #58a6ff;
}
.isin-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.7);
}
.isin-modal-content {
background-color: #161b22;
margin: 10% auto;
padding: 20px;
border: 1px solid #30363d;
border-radius: 8px;
width: 80%;
max-width: 600px;
max-height: 500px;
overflow-y: auto;
}
.isin-modal-content h3 {
color: #58a6ff;
margin-top: 0;
}
.isin-modal-item {
padding: 10px;
margin: 8px 0;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.isin-modal-item:hover {
background: #1c2128;
border-color: #58a6ff;
}
.isin-modal-item .isin-code {
font-weight: 700;
color: #58a6ff;
}
.isin-modal-close {
float: right;
color: #8b949e;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.isin-modal-close:hover {
color: #f85149;
}
.market-badge-wrapper {
cursor: pointer;
display: block;
margin-top: 4px;
}
.market-badge-wrapper:hover {
opacity: 0.8;
}
.market-badge-iframe {
width: 100%;
height: 60px;
border: none;
display: block;
pointer-events: none;
}
.clear-log-btn {
background: #21262d;
border: 1px solid #30363d;
color: #8b949e;
padding: 2px 10px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
float: right;
}
.clear-log-btn:hover {
background: #30363d;
color: #c9d1d9;
}
</style>
</head>
<body>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div>
<h2>🎯 Momentum Signals <span style="font-size:12px;color:#8b949e;font-weight:400;">(live streaming)</span></h2>
<div class="meta" id="timestamp">Click "Scan Now" to start</div>
</div>
<div>
<button class="scan-btn" id="scanBtn" onclick="startScan()">🔍 Scan Now</button>
<button class="save-btn" id="saveBtn" onclick="saveToJSON()">💾 Save to JSON</button>
</div>
</div>
<div class="price-filter-info">
⚡ Auto-filter: Only stocks <strong>under $x</strong> — focusing on high-momentum small caps
</div>
<div class="status-bar" id="statusBar">
<div class="status-top">
<span class="status-dot idle" id="statusDot"></span>
<span class="status-message" id="statusMessage">Ready to scan</span>
<button class="clear-log-btn" onclick="clearLog()">Clear Log</button>
</div>
<div class="log-container" id="logContainer"></div>
</div>
<div class="stats">
<div class="stat-box"><div class="stat-label">Total Signals</div><div class="stat-value" id="totalCount">0</div></div>
<div class="stat-box"><div class="stat-label">TradingView</div><div class="stat-value" id="tvCount" style="color: #58a6ff">0</div></div>
<div class="stat-box"><div class="stat-label">NASDAQ</div><div class="stat-value" id="nasCount" style="color: #8957e5">0</div></div>
<div class="stat-box"><div class="stat-label">NASDAQ Gainers</div><div class="stat-value" id="nasgCount" style="color: #d29922">0</div></div>
<div class="stat-box"><div class="stat-label">Yahoo Trending</div><div class="stat-value" id="ytrendCount" style="color: #39c5cf">0</div></div>
<div class="stat-box"><div class="stat-label">Breakouts</div><div class="stat-value" id="boCount">0</div></div>
<div class="stat-box"><div class="stat-label">Vol Surge</div><div class="stat-value" id="vsCount">0</div></div>
<div class="stat-box"><div class="stat-label">Reversals</div><div class="stat-value" id="rvCount">0</div></div>
<div class="stat-box"><div class="stat-label">High Mom</div><div class="stat-value" id="hmCount" style="color: #f0883e">0</div></div>
<div class="stat-box"><div class="stat-label">Mom Gap</div><div class="stat-value" id="mgCount" style="color: #da3633">0</div></div>
<div class="stat-box"><div class="stat-label">Progress</div><div class="stat-value" id="progressCount" style="font-size:14px;">0%</div></div>
</div>
<div class="source-header">
<div class="source-pill active" data-filter="all" onclick="filterSource('all')">All Sources</div>
<div class="source-pill" data-filter="TradingView" onclick="filterSource('TradingView')">TradingView</div>
<div class="source-pill" data-filter="NASDAQ" onclick="filterSource('NASDAQ')">NASDAQ</div>
<div class="source-pill" data-filter="NasdaqGainers" onclick="filterSource('NasdaqGainers')">NASDAQ Gainers</div>
<div class="source-pill" data-filter="YahooTrending" onclick="filterSource('YahooTrending')">Yahoo Trending</div>
</div>
<div class="results-container" id="resultsContainer">
<table id="signalTable">
<thead>
<tr>
<th data-col="symbol" data-type="string" onclick="sortTable('symbol')">Symbol<span class="arrow"></span></th>
<th data-col="source" data-type="string" onclick="sortTable('source')">Source<span class="arrow"></span></th>
<th data-col="strategy" data-type="string" onclick="sortTable('strategy')">Strategy<span class="arrow"></span></th>
<th data-col="trigger" data-type="string" onclick="sortTable('trigger')">Trigger<span class="arrow"></span></th>
<th data-col="trigger_pct" data-type="number" onclick="sortTable('trigger_pct')">Trigger %<span class="arrow"></span></th>
<th data-col="price" data-type="number" onclick="sortTable('price')">Price<span class="arrow"></span></th>
<th data-col="vol" data-type="number" onclick="sortTable('vol')">Volume<span class="arrow"></span></th>
<th data-col="avg_vol" data-type="number" onclick="sortTable('avg_vol')">Avg Vol<span class="arrow"></span></th>
<th data-col="spike" data-type="number" onclick="sortTable('spike')">Spike<span class="arrow"></span></th>
<th data-col="rsi" data-type="number" onclick="sortTable('rsi')">RSI<span class="arrow"></span></th>
<th data-col="mom3d" data-type="number" onclick="sortTable('mom3d')">3d Mom<span class="arrow"></span></th>
<th data-col="band" data-type="string" onclick="sortTable('band')">Band<span class="arrow"></span></th>
<th data-col="isin" data-type="string" onclick="sortTable('isin')">ISIN<span class="arrow"></span></th>
<th data-col="comment" data-type="string" onclick="sortTable('comment')">Comment<span class="arrow"></span></th>
</tr>
</thead>
<tbody id="signalBody">
</tbody>
</table>
</div>
<div id="isinModal" class="isin-modal">
<div class="isin-modal-content">
<span class="isin-modal-close" onclick="closeISINModal()">&times;</span>
<h3>Select ISIN for <span id="isinModalSymbol"></span></h3>
<div id="isinModalResults"></div>
</div>
</div>
<script>
// ─── Global debug/logging ──────────────────────────────────────────────
const DEBUG = true;
function log(level, ...args) {
const container = document.getElementById('logContainer');
if (!container) return;
const timestamp = new Date().toLocaleTimeString();
const message = args.join(' ');
const entry = document.createElement('div');
entry.className = 'log-entry';
const levelClass = 'level-' + (level || 'info');
entry.innerHTML = `<span class="timestamp">[${timestamp}]</span><span class="${levelClass}">${level.toUpperCase()}:</span> ${escapeHtml(message)}`;
container.appendChild(entry);
container.scrollTop = container.scrollHeight;
console.log(`[${timestamp}]`, ...args);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function clearLog() {
const container = document.getElementById('logContainer');
if (container) container.innerHTML = '';
}
// ─── Status bar update ──────────────────────────────────────────────────
let currentFilter = 'all';
let allSignals = [];
let eventSource = null;
let isinCache = {};
let stats = {
tv_checked: 0, tv_signals: 0, tv_total: 0,
nas_checked: 0, nas_signals: 0, nas_total: 0,
nas_skipped_price: 0, nas_skipped_change: 0, nas_skipped_cap: 0,
nas_skipped_total: 0,
nasg_checked: 0, nasg_signals: 0, nasg_total: 0,
ytrend_checked: 0, ytrend_signals: 0, ytrend_total: 0,
};
// ─── Sorting State ────────────────────────────────────────────────────────
let sortColumn = 'trigger_pct';
let sortDirection = 'desc';
let sortType = 'number';
const SOURCE_KEY_MAP = {
'TradingView': 'tv',
'NASDAQ': 'nas',
'NasdaqGainers': 'nasg',
'YahooTrending': 'ytrend',
};
// ─── Fetch company name using local proxy ──────────────────────────────
async function fetchCompanyName(symbol) {
log('debug', 'fetchCompanyName() called for:', symbol);
try {
const url = '?yahoo_proxy=1&q=' + encodeURIComponent(symbol);
log('debug', 'Fetching from local proxy:', url);
const response = await fetch(url);
if (!response.ok) {
log('error', 'Proxy fetch failed with status:', response.status);
return null;
}
const data = await response.json();
log('debug', 'Proxy response:', JSON.stringify(data).substring(0, 300) + '...');
if (data && data.quotes && data.quotes.length > 0) {
const name = data.quotes[0].longname || data.quotes[0].shortname || null;
log('info', 'Found company name via proxy:', name);
return name;
}
log('debug', 'No company name found in proxy response');
return null;
} catch (e) {
log('error', 'Proxy fetch error:', e);
return null;
}
}
// ─── ISIN Lookup Function ──────────────────────────────────────────────
async function lookupISIN(symbol, companyName, rowElement) {
log('info', '======= lookupISIN() called =======');
log('info', 'symbol:', symbol);
log('info', 'companyName provided:', companyName || '(null)');
const isinCell = rowElement.querySelector('td[data-cell="isin"]');
const link = isinCell.querySelector('.isin-link');
if (!link) {
log('error', 'ERROR: link element not found!');
return;
}
link.textContent = '⏳ Searching...';
link.className = 'isin-link loading';
// --- Step 1: Get a valid company name ---
let effectiveName = companyName;
if (!effectiveName || effectiveName === symbol) {
log('info', 'Company name missing or same as symbol, fetching from Yahoo via proxy...');
const fetched = await fetchCompanyName(symbol);
if (fetched) {
effectiveName = fetched;
log('info', 'Fetched effectiveName:', effectiveName);
} else {
log('warn', 'Could not fetch company name, will fallback to symbol');
effectiveName = symbol;
}
} else {
log('info', 'Using provided company name:', effectiveName);
}
// --- Step 2: Build search terms (same as process.php) ---
let searchTerms = [];
if (effectiveName && effectiveName !== symbol) {
// First word of the company name
let firstWord = effectiveName.split(' ')[0];
if (firstWord && firstWord !== symbol) {
searchTerms.push(firstWord);
log('debug', 'Added first word:', firstWord);
}
// Full company name
searchTerms.push(effectiveName);
log('debug', 'Added full name:', effectiveName);
}
// Always fallback to symbol as last resort
searchTerms.push(symbol);
log('debug', 'Added symbol fallback:', symbol);
// Remove duplicates
searchTerms = [...new Set(searchTerms)];
log('info', 'Final search terms:', searchTerms.join(', '));
let allResults = [];
let rawJsonData = null;
let currentTermIndex = 0;
// --- Step 3: Recursive search ────────────────────────────────────
function tryNextTerm() {
log('debug', 'tryNextTerm() index:', currentTermIndex, 'of', searchTerms.length);
if (currentTermIndex >= searchTerms.length) {
log('debug', 'All terms tried, results:', allResults.length);
if (allResults.length > 0) {
displayAllResults(allResults);
return;
}
log('error', 'No results found');
link.textContent = '❌ Not found';
link.className = 'isin-link';
link.style.color = '#f85149';
link.style.borderColor = '#f85149';
isinCache[symbol] = null;
return;
}
const term = searchTerms[currentTermIndex];
log('info', 'Searching term: "' + term + '"');
if (currentTermIndex > 0) {
link.textContent = '⏳ Searching: "' + term + '"...';
}
const url = 'ingdiba.php?symbol=' + encodeURIComponent(term) + '&json';
log('debug', 'Fetching URL:', url);
fetch(url)
.then(response => {
log('debug', 'Response status:', response.status);
return response.json();
})
.then(data => {
log('debug', 'ING DiBa response for term "' + term + '":', JSON.stringify(data).substring(0, 300) + '...');
rawJsonData = data;
if (data.status === 'multiple_results' && data.search && data.search.results) {
const count = data.search.results.length;
log('info', 'Multiple results found, count:', count);
data.search.results.forEach(result => {
if (result.isin && !allResults.some(r => r.isin === result.isin)) {
allResults.push({
isin: result.isin,
name: result.name || result.longName || effectiveName || symbol,
price: result.price || result.regularMarketPrice || result.close,
currency: result.currency || 'USD'
});
log('debug', 'Added result:', result.isin, result.name);
}
});
}
if (data.status === 'success' && data.data && data.data.price && data.data.price.isin) {
const isin = data.data.price.isin;
log('info', 'Single success result, ISIN:', isin);
if (!allResults.some(r => r.isin === isin)) {
allResults.push({
isin: isin,
name: data.data.price.name || effectiveName || symbol,
price: data.data.price.price || data.data.price.regularMarketPrice || data.data.price.close,
currency: data.data.price.currency || 'USD'
});
log('debug', 'Added success result:', isin);
}
}
currentTermIndex++;
tryNextTerm();
})
.catch(error => {
log('error', 'Fetch error for term "' + term + '":', error);
currentTermIndex++;
tryNextTerm();
});
}
function displayAllResults(results) {
log('info', 'displayAllResults() called with', results.length, 'results');
if (!results || results.length === 0) {
log('error', 'No results to display');
link.textContent = '❌ Not found';
link.className = 'isin-link';
link.style.color = '#f85149';
link.style.borderColor = '#f85149';
isinCache[symbol] = null;
return;
}
if (results.length === 1) {
const foundIsin = results[0].isin;
log('success', 'Single result found:', foundIsin);
link.textContent = foundIsin;
link.className = 'isin-link';
link.style.color = '#3fb950';
link.style.borderColor = '#3fb950';
isinCache[symbol] = foundIsin;
appendMarketIframe(isinCell, foundIsin);
allSignals.forEach(sig => {
if (sig.symbol === symbol) {
sig.isin = foundIsin;
}
});
return;
}
// Multiple results
log('info', 'Multiple results found, count:', results.length);
link.textContent = 'Multiple (' + results.length + ') ▼';
link.className = 'isin-link';
link.style.color = '#d29922';
link.style.borderColor = '#d29922';
isinCache[symbol] = results;
link.onclick = function(e) {
e.stopPropagation();
log('info', 'Opening modal for multiple results');
openISINModal(symbol, results);
};
}
// Start the search
log('info', 'Starting recursive search...');
tryNextTerm();
}
// ─── ISIN Modal Functions ──────────────────────────────────────────────
function openISINModal(symbol, isinData) {
log('info', 'openISINModal() called for', symbol);
const modal = document.getElementById('isinModal');
const symbolSpan = document.getElementById('isinModalSymbol');
const resultsDiv = document.getElementById('isinModalResults');
symbolSpan.textContent = symbol;
resultsDiv.innerHTML = '';
if (Array.isArray(isinData)) {
isinData.forEach(result => {
const div = document.createElement('div');
div.className = 'isin-modal-item';
div.innerHTML = `
<div><span class="isin-code">${result.isin}</span></div>
<div style="font-size:12px;color:#8b949e;">${result.name || 'Unknown'}</div>
<div style="font-size:11px;color:#484f58;">Price: ${result.price || 'N/A'} ${result.currency || ''}</div>
`;
div.onclick = function() {
selectISIN(symbol, result.isin);
closeISINModal();
};
resultsDiv.appendChild(div);
});
}
modal.style.display = 'block';
}
function closeISINModal() {
document.getElementById('isinModal').style.display = 'none';
}
function selectISIN(symbol, isin) {
log('info', 'selectISIN() called for', symbol, '->', isin);
isinCache[symbol] = isin;
const rows = document.querySelectorAll(`#signalBody tr[data-symbol="${symbol}"]`);
rows.forEach(row => {
const isinCell = row.querySelector('td[data-cell="isin"]');
const link = isinCell.querySelector('.isin-link');
if (link) {
link.textContent = isin;
link.className = 'isin-link';
link.style.color = '#3fb950';
link.style.borderColor = '#3fb950';
link.onclick = null;
appendMarketIframe(isinCell, isin);
}
});
allSignals.forEach(sig => {
if (sig.symbol === symbol) {
sig.isin = isin;
}
});
}
// ─── Embed market iframe badge (clickable) ──────────────────────────────
function appendMarketIframe(containerElement, isin) {
// Remove existing wrapper if any
const existingWrapper = containerElement.querySelector('.market-badge-wrapper');
if (existingWrapper) {
existingWrapper.remove();
}
const wrapper = document.createElement('div');
wrapper.className = 'market-badge-wrapper';
wrapper.style.cssText = 'cursor:pointer; display:block; margin-top:4px;';
wrapper.title = 'Click to check on xchangemarketcheck_gettex';
wrapper.onclick = function(e) {
window.open('xchangemarketcheck_gettex.php?q=' + encodeURIComponent(isin), '_blank');
e.stopPropagation();
};
const iframe = document.createElement('iframe');
iframe.className = 'market-badge-iframe';
iframe.src = 'xchangemarket_badge.php?q=' + encodeURIComponent(isin);
iframe.style.cssText = 'width:100%; height:60px; border:none; display:block; pointer-events:none;';
iframe.loading = 'lazy';
wrapper.appendChild(iframe);
containerElement.appendChild(wrapper);
}
// ─── Render stored gettex status as plain text (no iframe) ──────────────
// Used when restoring from stockscreener.json - we already know the status,
// so there's no need to load an iframe per row just to re-fetch it.
function appendGettexStatusText(containerElement, isin, statusText) {
const existingWrapper = containerElement.querySelector('.market-badge-wrapper');
if (existingWrapper) {
existingWrapper.remove();
}
const wrapper = document.createElement('div');
wrapper.className = 'market-badge-wrapper';
wrapper.style.cssText = 'cursor:pointer; display:block; margin-top:4px; font-size:11px; padding:2px 6px; border-radius:4px; background:#21262d; color:#8b949e; border:1px solid #30363d;';
wrapper.title = 'Click to check on xchangemarketcheck_gettex';
wrapper.textContent = statusText && statusText.trim() ? statusText.trim() : '—';
wrapper.onclick = function(e) {
window.open('xchangemarketcheck_gettex.php?q=' + encodeURIComponent(isin), '_blank');
e.stopPropagation();
};
containerElement.appendChild(wrapper);
}
// ─── Comment handling ────────────────────────────────────────────────────
function applyReversesplitRowState(row, commentText) {
const isReverseSplit = !!(commentText && commentText.toLowerCase().includes('reversesplit'));
row.classList.toggle('reversesplit-row', isReverseSplit);
return isReverseSplit;
}
function handleCommentInput(symbol, inputEl) {
const value = inputEl.value;
const sig = allSignals.find(s => s.symbol === symbol);
if (sig) sig.comment = value;
const row = inputEl.closest('tr');
if (row) applyReversesplitRowState(row, value);
}
// ─── Apply stored data from JSON (auto-restore) ──────────────────────
function applyStoredData() {
log('info', '🔄 Applying stored data from stockscreener.json...');
fetch('?get_json=1')
.then(res => res.json())
.then(data => {
if (!data || !Array.isArray(data) || data.length === 0) {
log('debug', 'No stored data found or empty JSON');
return;
}
// Build map symbol -> {isin, status, comment}
const map = {};
data.forEach(item => {
if (item.symbol && item.isin) {
map[item.symbol] = {
isin: item.isin,
status: item.gettex_status || '',
comment: item.comment || ''
};
}
});
const rows = document.querySelectorAll('#signalBody tr');
let updatedCount = 0;
rows.forEach(row => {
const symbol = row.dataset.symbol;
if (!symbol) return;
const entry = map[symbol];
if (!entry) return;
// ── Restore comment (independent of ISIN lookup state) ──
const commentInput = row.querySelector('.comment-input');
if (commentInput && entry.comment) {
commentInput.value = entry.comment;
const sig = allSignals.find(s => s.symbol === symbol);
if (sig) sig.comment = entry.comment;
applyReversesplitRowState(row, entry.comment);
}
const isinCell = row.querySelector('td[data-cell="isin"]');
if (!isinCell) return;
// Check if already has an ISIN (manual lookup already done)
const existingLink = isinCell.querySelector('.isin-link');
if (existingLink) {
const text = existingLink.textContent.trim();
if (/^[A-Z]{2}[A-Z0-9]{10}$/.test(text)) {
// Already has ISIN, skip to preserve manual selection
updatedCount++;
return;
}
// It's a lookup link we'll replace it
}
// Remove all children (lookup link, any iframes)
isinCell.innerHTML = '';
// Add ISIN link (green)
const isinSpan = document.createElement('span');
isinSpan.className = 'isin-link';
isinSpan.style.color = '#3fb950';
isinSpan.style.borderColor = '#3fb950';
isinSpan.textContent = entry.isin;
isinCell.appendChild(isinSpan);
// We already know the status from JSON — mark the cell so
// saveToJSON() uses this value directly (no iframe present to scrape)
isinCell.dataset.gettexStatus = entry.status;
isinCell.dataset.fromJson = '1';
// Render the known status as plain text - no iframe needed
appendGettexStatusText(isinCell, entry.isin, entry.status);
// Also update the allSignals object for sorting
const sig = allSignals.find(s => s.symbol === symbol);
if (sig) {
sig.isin = entry.isin;
sig.gettexStatus = entry.status;
sig.gettexKnown = true;
}
updatedCount++;
});
if (updatedCount > 0) {
log('success', `✅ Applied stored data to ${updatedCount} rows`);
// Re-sort to reflect ISIN/comment changes
sortTable(sortColumn);
} else {
log('debug', 'No rows needed updating from stored data');
}
})
.catch(err => {
log('error', 'Failed to load stored data:', err);
});
}
// ─── Save to JSON ────────────────────────────────────────────────────────
function saveToJSON() {
const btn = document.getElementById('saveBtn');
btn.disabled = true;
btn.textContent = '⏳ Saving...';
setStatus('scanning', 'Collecting data from rows with ISIN...');
const rows = document.querySelectorAll('#signalBody tr');
const dataToSave = [];
rows.forEach(row => {
const symbol = row.dataset.symbol;
if (!symbol) return;
// Check if ISIN is already known (valid format)
const isinCell = row.querySelector('td[data-cell="isin"]');
const isinLink = isinCell ? isinCell.querySelector('.isin-link') : null;
if (!isinLink) return;
const linkText = isinLink.textContent.trim();
// ISIN pattern: two letters + 10 alphanumeric
if (!/^[A-Z]{2}[A-Z0-9]{10}$/.test(linkText)) return;
const isin = linkText;
// Find the iframe inside the same cell
const iframe = isinCell.querySelector('.market-badge-iframe');
let gettexStatus = '';
if (iframe) {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
if (iframeDoc) {
// PRIMARY: get the badge element directly
const badgeElement = iframeDoc.querySelector('.gettex-badge');
if (badgeElement) {
gettexStatus = badgeElement.textContent.trim();
} else {
// FALLBACK: remove scripts and get body text
const bodyClone = iframeDoc.body.cloneNode(true);
bodyClone.querySelectorAll('script, style, link').forEach(el => el.remove());
gettexStatus = bodyClone.textContent.trim();
}
}
} catch (e) {
log('error', 'Could not read iframe content for ' + symbol, e);
}
} else {
// No iframe (JSON-restored row) - read the status text wrapper directly
const textWrapper = isinCell.querySelector('.market-badge-wrapper');
if (textWrapper) {
const text = textWrapper.textContent.trim();
gettexStatus = (text && text !== '—') ? text : '';
}
}
// Comment is only saved when the row has a valid ISIN (see above 'if (!isinLink) return;')
const commentInput = row.querySelector('.comment-input');
const comment = commentInput ? commentInput.value.trim() : '';
dataToSave.push({
symbol: symbol,
isin: isin,
gettex_status: gettexStatus || '',
comment: comment
});
});
if (dataToSave.length === 0) {
setStatus('error', 'No rows with valid ISIN found to save.');
btn.disabled = false;
btn.textContent = '💾 Save to JSON';
return;
}
log('info', 'Saving ' + dataToSave.length + ' entries to JSON');
fetch('?save_json=1', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'save_json=1&data=' + encodeURIComponent(JSON.stringify(dataToSave))
})
.then(res => res.json())
.then(result => {
if (result.success) {
const updated = result.updated || 0;
setStatus('done', '✅ Added ' + result.added + ' new, updated ' + updated + ' — ' + result.count + ' total in stockscreener.json');
log('success', 'Saved to JSON: +' + result.added + ' new, ' + updated + ' updated, ' + result.count + ' total');
} else {
setStatus('error', '❌ Save failed: ' + (result.error || 'Unknown error'));
log('error', 'Save failed', result);
}
})
.catch(err => {
setStatus('error', '❌ Network error while saving');
log('error', 'Network error', err);
})
.finally(() => {
btn.disabled = false;
btn.textContent = '💾 Save to JSON';
});
}
// ─── Start Scan ──────────────────────────────────────────────────────────
function startScan() {
const btn = document.getElementById('scanBtn');
btn.disabled = true;
btn.textContent = '⏳ Scanning...';
btn.className = 'scan-btn scanning';
allSignals = [];
document.getElementById('signalBody').innerHTML = '';
document.getElementById('totalCount').textContent = '...';
document.getElementById('totalCount').className = 'stat-value loading';
setStatus('scanning', 'Initializing scan...');
log('info', '=== Scan started ===');
if (eventSource) {
eventSource.close();
eventSource = null;
}
eventSource = new EventSource('?stream=1');
eventSource.addEventListener('status', function(e) {
const data = JSON.parse(e.data);
setStatus('scanning', data.message);
log('info', 'STATUS:', data.message);
});
eventSource.addEventListener('progress', function(e) {
const data = JSON.parse(e.data);
const source = data.source;
const checked = data.checked;
const total = data.total;
const skipped = data.skipped || 0;
const keptPct = data.kept_pct || 0;
const key = SOURCE_KEY_MAP[source];
if (key) {
stats[`${key}_checked`] = checked;
stats[`${key}_total`] = total;
if (skipped) stats[`${key}_skipped_total`] = skipped;
}
const totalItems = Object.keys(SOURCE_KEY_MAP).reduce((sum, s) => sum + (stats[`${SOURCE_KEY_MAP[s]}_total`] || 0), 0);
const checkedItems = Object.keys(SOURCE_KEY_MAP).reduce((sum, s) => sum + (stats[`${SOURCE_KEY_MAP[s]}_checked`] || 0), 0);
const progress = totalItems > 0 ? Math.round((checkedItems / totalItems) * 100) : 0;
document.getElementById('progressCount').textContent = progress + '%';
let msg = `${source}: ${checked}/${total}`;
if (skipped > 0) msg += ` (kept ${keptPct}% of original)`;
if (data.signals) msg += `, ${data.signals} signals`;
setStatus('scanning', msg);
log('debug', 'PROGRESS:', msg);
});
eventSource.addEventListener('signal', function(e) {
const sig = JSON.parse(e.data);
if (sig.stats) {
stats = sig.stats;
updateStats();
}
allSignals.push(sig);
renderAllSignals();
document.getElementById('totalCount').textContent = allSignals.length;
document.getElementById('totalCount').className = 'stat-value';
const container = document.getElementById('resultsContainer');
container.scrollTop = container.scrollHeight;
log('info', 'SIGNAL:', sig.symbol, sig.strategy, sig.price);
});
eventSource.addEventListener('done', function(e) {
const data = JSON.parse(e.data);
setStatus('done', data.message);
log('success', '=== Scan complete ===', data.message);
btn.disabled = false;
btn.textContent = '🔄 Scan Again';
btn.className = 'scan-btn';
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (data.stats) {
stats = data.stats;
updateStats();
}
document.getElementById('progressCount').textContent = '100%';
// ─── 🔁 Autoapply stored data ──────────────────────────────────
applyStoredData();
});
eventSource.addEventListener('error', function(e) {
log('error', 'EventSource error:', e);
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
btn.disabled = false;
btn.textContent = '🔄 Scan Again';
btn.className = 'scan-btn';
setStatus('error', 'Connection closed unexpectedly');
log('error', 'Connection closed unexpectedly');
}
});
eventSource.addEventListener('close', function(e) {
btn.disabled = false;
btn.textContent = '🔄 Scan Again';
btn.className = 'scan-btn';
log('info', 'EventSource closed');
});
}
// ─── Sort Table ──────────────────────────────────────────────────────────
function sortTable(column) {
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = column;
sortDirection = 'desc';
}
const th = document.querySelector(`th[data-col="${column}"]`);
sortType = th ? th.dataset.type : 'string';
document.querySelectorAll('th').forEach(h => {
h.classList.remove('sorted', 'sorted-asc', 'sorted-desc');
});
th.classList.add('sorted', `sorted-${sortDirection}`);
renderAllSignals();
}
// ─── Render All Signals ──────────────────────────────────────────────────
function renderAllSignals() {
const tbody = document.getElementById('signalBody');
tbody.innerHTML = '';
const sorted = [...allSignals].sort((a, b) => {
let valA = a[sortColumn];
let valB = b[sortColumn];
if (valA === null || valA === undefined) valA = '';
if (valB === null || valB === undefined) valB = '';
if (sortType === 'string') {
valA = String(valA).toLowerCase();
valB = String(valB).toLowerCase();
}
if (sortType === 'number') {
valA = parseFloat(valA) || 0;
valB = parseFloat(valB) || 0;
}
if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
sorted.forEach(sig => {
const row = createSignalRow(sig);
tbody.appendChild(row);
});
}
// ─── Create a single signal row ──────────────────────────────────────────
function createSignalRow(sig) {
const rsiClass = sig.rsi > 70 ? 'rsi-hot' : (sig.rsi > 50 ? 'rsi-warn' : 'rsi-ok');
const stratClass = {
'BREAKOUT': 'strat-breakout',
'VOL-SURGE': 'strat-vol',
'REVERSAL': 'strat-rev',
'HIGH-MOMENTUM': 'strat-high',
'MOM-GAP': 'strat-gap',
}[sig.strategy] || '';
const sourceClassMap = {
'TradingView': 'source-tv',
'NASDAQ': 'source-nas',
'NasdaqGainers': 'source-nasg',
'YahooTrending': 'source-ytrend',
};
const sourceLabelMap = {
'TradingView': 'TV',
'NASDAQ': 'NAS',
'NasdaqGainers': 'NASG',
'YahooTrending': 'YTR',
};
const sourceClass = sourceClassMap[sig.source] || '';
const sourceLabel = sourceLabelMap[sig.source] || sig.source;
const bandClass = sig.band ? 'band-' + sig.band.toLowerCase() : '';
let isinHtml = '';
if (sig.isin && sig.gettexKnown) {
// Restored from stockscreener.json - status already known, NO iframe
const escapedIsin = sig.isin;
const statusText = (sig.gettexStatus && sig.gettexStatus.trim()) ? sig.gettexStatus.trim() : '—';
isinHtml = `
<span class="isin-link" style="color:#3fb950;border-color:#3fb950;">${escapedIsin}</span>
<div class="market-badge-wrapper" style="cursor:pointer; display:block; margin-top:4px; font-size:11px; padding:2px 6px; border-radius:4px; background:#21262d; color:#8b949e; border:1px solid #30363d;"
title="Click to check on xchangemarketcheck_gettex"
onclick="window.open('xchangemarketcheck_gettex.php?q=' + encodeURIComponent('${escapedIsin}'), '_blank')">${statusText}</div>
`;
} else if (sig.isin) {
// ISIN known live (fresh lookup) show clickable ISIN and live iframe badge
const escapedIsin = sig.isin;
isinHtml = `
<span class="isin-link" style="color:#3fb950;border-color:#3fb950;">${escapedIsin}</span>
<div class="market-badge-wrapper" style="cursor:pointer; display:block; margin-top:4px;"
title="Click to check on xchangemarketcheck_gettex"
onclick="window.open('xchangemarketcheck_gettex.php?q=' + encodeURIComponent('${escapedIsin}'), '_blank')">
<iframe class="market-badge-iframe"
src="xchangemarket_badge.php?q=${encodeURIComponent(escapedIsin)}"
style="width:100%;height:60px;border:none;display:block;pointer-events:none;"
loading="lazy">
</iframe>
</div>
`;
} else {
// No ISIN yet show lookup link
const companyName = sig.companyName || sig.symbol;
const escapedCompany = companyName.replace(/'/g, "\\'").replace(/"/g, '&quot;');
isinHtml = `<span class="isin-link" onclick="lookupISIN('${sig.symbol}', '${escapedCompany}', this.closest('tr'))">🔍 Lookup ISIN</span>`;
}
const commentValue = sig.comment || '';
const commentHtml = `<input type="text" class="comment-input" placeholder="Add note..." value="${escapeHtml(commentValue)}" oninput="handleCommentInput('${sig.symbol}', this)">`;
const display = (currentFilter === 'all' || sig.source === currentFilter) ? '' : 'style="display:none;"';
const tr = document.createElement('tr');
tr.className = 'signal-flash';
tr.setAttribute('data-symbol', escapeHtml(sig.symbol));
tr.setAttribute('data-source', escapeHtml(sig.source));
tr.setAttribute('data-strategy', escapeHtml(sig.strategy));
tr.setAttribute('data-trigger', escapeHtml(sig.trigger));
tr.setAttribute('data-trigger_pct', sig.trigger_pct);
tr.setAttribute('data-price', sig.price);
tr.setAttribute('data-vol', sig.vol);
tr.setAttribute('data-avg_vol', sig.avg_vol);
tr.setAttribute('data-spike', sig.spike);
tr.setAttribute('data-rsi', sig.rsi);
tr.setAttribute('data-mom3d', sig.mom3d);
tr.setAttribute('data-band', sig.band || '');
tr.setAttribute('data-isin', sig.isin || '');
tr.setAttribute('data-company', sig.companyName || sig.symbol);
tr.setAttribute('style', display);
tr.innerHTML = `
<td class="sym">
<a href="<?= $TICKER_BASE_URL ?>${escapeHtml(sig.symbol)}" target="_blank" rel="noopener">${escapeHtml(sig.symbol)}</a>
<span class="company-name">${escapeHtml(sig.companyName || sig.symbol)}</span>
</td>
<td><span class="source-badge ${sourceClass}">${sourceLabel}</span></td>
<td><span class="strat ${stratClass}">${sig.strategy}</span></td>
<td style="color:#8b949e;font-size:12px;">${escapeHtml(sig.trigger)}</td>
<td>${sig.trigger_pct >= 0 ? '+' : ''}${sig.trigger_pct}%</td>
<td>$${sig.price.toFixed(2)}</td>
<td>${formatVol(sig.vol)}</td>
<td>${formatVol(sig.avg_vol)}</td>
<td class="spike">${sig.spike}x</td>
<td class="${rsiClass}">${sig.rsi}</td>
<td class="${sig.mom3d >= 0 ? 'mom-pos' : 'mom-neg'}">${sig.mom3d > 0 ? '+' : ''}${sig.mom3d}%</td>
<td>${sig.band ? `<span class="band-badge ${bandClass}">${sig.band}</span>` : ''}</td>
<td data-cell="isin">${isinHtml}</td>
<td data-cell="comment">${commentHtml}</td>
`;
// Apply strikethrough state on initial render if the restored comment already flags a reverse split
applyReversesplitRowState(tr, commentValue);
return tr;
}
// ─── Update Stats ──────────────────────────────────────────────────────────
function updateStats() {
document.getElementById('tvCount').textContent = stats.tv_signals || 0;
document.getElementById('nasCount').textContent = stats.nas_signals || 0;
document.getElementById('nasgCount').textContent = stats.nasg_signals || 0;
document.getElementById('ytrendCount').textContent = stats.ytrend_signals || 0;
const boCount = allSignals.filter(s => s.strategy === 'BREAKOUT').length;
const vsCount = allSignals.filter(s => s.strategy === 'VOL-SURGE').length;
const rvCount = allSignals.filter(s => s.strategy === 'REVERSAL').length;
const hmCount = allSignals.filter(s => s.strategy === 'HIGH-MOMENTUM').length;
const mgCount = allSignals.filter(s => s.strategy === 'MOM-GAP').length;
document.getElementById('boCount').textContent = boCount;
document.getElementById('vsCount').textContent = vsCount;
document.getElementById('rvCount').textContent = rvCount;
document.getElementById('hmCount').textContent = hmCount;
document.getElementById('mgCount').textContent = mgCount;
const totalItems = Object.keys(SOURCE_KEY_MAP).reduce((sum, s) => sum + (stats[`${SOURCE_KEY_MAP[s]}_total`] || 0), 0);
const checkedItems = Object.keys(SOURCE_KEY_MAP).reduce((sum, s) => sum + (stats[`${SOURCE_KEY_MAP[s]}_checked`] || 0), 0);
const progress = totalItems > 0 ? Math.round((checkedItems / totalItems) * 100) : 0;
document.getElementById('progressCount').textContent = progress + '%';
}
// ─── Status Bar ──────────────────────────────────────────────────────────
function setStatus(type, message) {
const dot = document.getElementById('statusDot');
const msg = document.getElementById('statusMessage');
dot.className = 'status-dot ' + type;
msg.textContent = message;
}
// ─── Filtering ────────────────────────────────────────────────────────────
function filterSource(source) {
currentFilter = source;
document.querySelectorAll('.source-pill').forEach(p => {
p.classList.toggle('active', p.dataset.filter === source);
});
const rows = document.querySelectorAll('#signalBody tr');
rows.forEach(row => {
if (source === 'all' || row.dataset.source === source) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
// ─── Helpers ──────────────────────────────────────────────────────────────
function formatVol(v) {
return v >= 1000000 ? (v/1000000).toFixed(1) + 'M' : (v/1000).toFixed(1) + 'K';
}
// ─── Initialize ────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', function() {
const defaultTh = document.querySelector('th[data-col="trigger_pct"]');
if (defaultTh) {
defaultTh.classList.add('sorted', 'sorted-desc');
}
sortColumn = 'trigger_pct';
sortDirection = 'desc';
sortType = 'number';
window.onclick = function(event) {
const modal = document.getElementById('isinModal');
if (event.target === modal) {
closeISINModal();
}
};
log('info', 'Page loaded, ISIN lookup ready (proxy active).');
});
</script>
</body>
</html>