/* app.js — NSE Paper Trading Platform */
'use strict';
// ── Splash loader ──────────────────────────────────────────────────────────────
let _loaderDismissed = false;
function dismissLoader() {
if (_loaderDismissed) return;
_loaderDismissed = true;
const el = document.getElementById('app-loader');
if (!el) return;
el.classList.add('app-loader--done');
setTimeout(() => el.remove(), 450);
}
// Failsafe: auto-dismiss after 8s in case the first API call hangs
setTimeout(dismissLoader, 8000);
// ── Universe cache ─────────────────────────────────────────────────────────────
let UNIVERSE = [];
let _UNIVERSE_MAP = {};
async function loadUniverse() {
try {
const res = await fetch('/api/universe');
const data = await res.json();
UNIVERSE = data.universe || [];
_UNIVERSE_MAP = {};
UNIVERSE.forEach(u => { _UNIVERSE_MAP[u.ticker] = u.name; });
} catch (e) { console.warn('Universe load failed:', e); }
}
// Returns the bare symbol and company name for any ticker string
function tickerMeta(ticker) {
const sym = ticker.replace(/\.(NS|BO)$/i, '');
const exch = ticker.toUpperCase().endsWith('.BO') ? 'BSE' : 'NSE';
const name = _UNIVERSE_MAP[ticker] || _UNIVERSE_MAP[sym + '.NS'] || _UNIVERSE_MAP[sym + '.BO'] || '';
return { sym, exch, name };
}
// Compact two-line stock cell for use inside
function stockCell(ticker) {
const { sym, exch, name } = tickerMeta(ticker);
return `
${name ? `
${name}
` : ''}
${sym}${exch}
`;
}
// ── Candlestick chart via Lightweight Charts (yfinance / Yahoo Finance data) ──
async function mountLwChart(containerId, ticker, interval) {
const el = document.getElementById(containerId);
if (!el) return;
// If remounting with a new interval, clear existing chart
if (el.dataset.chartMounted && !interval) return;
el._lwChart?.remove();
clearInterval(el._chartRefreshTimer);
el._chartRefreshTimer = null;
el.dataset.chartMounted = '1';
el.innerHTML = '';
// Wait for layout to be calculated if element is newly visible
await new Promise(resolve => {
if (el.clientWidth > 0) {
resolve();
} else {
requestAnimationFrame(() => resolve());
}
});
// Get actual dimensions, with minimum fallbacks
const width = el.clientWidth > 0 ? el.clientWidth : 400;
const height = el.clientHeight > 0 ? el.clientHeight : 300;
const chart = LightweightCharts.createChart(el, {
width: width,
height: height,
layout: { background: { color: '#1a1d2e' }, textColor: '#9ca3af' },
grid: { vertLines: { color: '#2d3040' }, horzLines: { color: '#2d3040' } },
timeScale: {
borderColor: '#2d3040',
timeVisible: true,
secondsVisible: false,
},
rightPriceScale: { borderColor: '#2d3040' },
});
el._lwChart = chart;
const series = chart.addCandlestickSeries({
upColor: '#22c55e', downColor: '#ef4444',
borderUpColor: '#22c55e', borderDownColor: '#ef4444',
wickUpColor: '#22c55e', wickDownColor: '#ef4444',
});
const sym = ticker.replace('.NS', '').replace('.BO', '');
const iv = interval || '1d';
async function fetchAndUpdate(isInitial) {
try {
const res = await fetch(`/api/chart/${sym}?interval=${iv}`);
const data = await res.json();
const candles = data.candles || [];
if (!candles.length) {
if (isInitial) el.innerHTML = '
No chart data
';
return;
}
if (isInitial) {
series.setData(candles);
chart.timeScale().fitContent();
// Server fell back to daily data (e.g. ticker has no intraday feed) — sync the active pill
if (data.interval && data.interval !== iv) {
el.parentNode?.querySelectorAll?.('.chart-iv-pill').forEach(b => {
b.classList.toggle('active', b.dataset.iv === data.interval);
});
}
} else {
// Incremental update: upsert the last few candles without reflowing the whole chart
const tail = candles.slice(-5);
tail.forEach(c => series.update(c));
}
} catch (e) {
if (isInitial) el.innerHTML = '
`;
document.getElementById('news-modal')?.classList.remove('hidden');
}
// ── TF cell constants & renderer (module-level so _fetchAndUpdateTfCell can use them) ──
const _TF_SIGNALS = {
'INTRADAY': ['S1', 'S4', 'S4V2', 'S8', 'S16', 'S_CTRIO'],
'1D': ['S1', 'S4', 'S4V2', 'S7', 'S8', 'S11', 'PED', 'S_CAPFLOW', 'S_CTRIO', 'S15', 'S16', 'S20'],
'3D': ['S1', 'S4', 'S4V2', 'S5', 'S5V2', 'S6', 'S6V2', 'S7', 'S8', 'S9', 'S11', 'SUPER', 'PED',
'S_CAPFLOW', 'S_CTRIO', 'S14', 'S15', 'S16', 'S17', 'S18', 'S20'],
'5D': ['S2', 'S5', 'S5V2', 'S6', 'S6V2', 'S9', 'S10', 'S11', 'MFS', 'NIRA', 'SUPER',
'S_CAPFLOW', 'S_CTRIO', 'S_SEASONAL', 'S12', 'S13', 'S14', 'S15', 'S16', 'S17', 'S18', 'S19'],
};
const _NO_TRADE_LABELS = {
'no_signal': '— No signal',
'wrong_timeframe': '— Signal ≠ horizon',
'neutral_signal': '— Neutral (no edge)',
'too_close_to_close': '🕐 Too late (post 2:15pm)',
'vix_block': '⚠ BLOCKED (VIX > 25)',
'data_error': '— Data unavailable',
'ai_unavailable': '🤖 AI loading…',
'market_closed': '🕐 Market closed',
'timeout': '🤖 AI loading…',
};
// Max background auto-retries per AI cell (~1/min). Covers per-minute rate-limit
// resets and Ollama cold starts so the AI forecast fills in once a provider frees up.
const _AI_RETRY_MAX = 30;
const _NO_TRADE_REASONS = {
'no_signal': 'No strategy signal is active for this timeframe right now.',
'wrong_timeframe': 'A signal may exist, but it is not validated for this timeframe.',
'neutral_signal': 'Strategy signals are firing, but the AI forecast is NEUTRAL — no directional edge, so there is no trade to take.',
'too_close_to_close': 'It is past 2:15pm IST — too little session left for a fresh intraday trade to reach its target by close.',
'vix_block': 'Market risk gate is active (India VIX above threshold).',
'data_error': 'Required market data is incomplete, so the setup is skipped.',
'ai_unavailable': 'AI forecast is loading — retrying automatically as providers free up. The ML estimate is shown meanwhile.',
'market_closed': 'NSE is currently closed. INTRADAY prediction unavailable until market opens.',
'timeout': 'AI forecast is loading — retrying automatically as providers free up. The ML estimate is shown meanwhile.',
};
// Render one TF cell — used by renderPickCard and _fetchAndUpdateTfCell for live updates.
function _renderOneTfCell(tf, d, pick) {
const signals = pick.signals || {};
const pickPrice = pick.price || 0;
const bestTf = pick.best_tf || null;
const safeId = (pick.ticker || '').replace(/[^a-zA-Z0-9]/g, '_');
// ML forecast for this TF — computed up front (also used later for the AI banner "ML" line
// and the risk block) so it can be shown even while the slower AI call is still pending.
const _mlData = d.ml || (_mlCache.get(pick.ticker || '') || {}).tfs?.[tf] || null;
// "pending" = this timeframe's AI forecast is still being computed in the
// background (streaming top-picks). Show a spinner for the AI part, but still render the
// ML forecast (row + banner line) so 1D/3D aren't blank while the AI catches up.
if (d.no_trade_reason === 'pending') {
const isBestTfP = bestTf !== null && (tf === bestTf);
const _mlSlotPending = _mlData ? _renderMlRow(tf, _mlData) : '
`;
// Confidence bar for the main (AI-blended) call — rendered in every timeframe cell.
const _confPct = c => c === 'HIGH' ? 90 : c === 'MEDIUM' ? 55 : c === 'LOW' ? 25 : 0;
const mainConf = (d.ai_forecast && d.ai_forecast.confidence) || d.confidence || '';
const mainConfBar = (!isNoTrade && mainConf)
? `
AI conf
${mainConf}
`
: '';
const pd = d.predicted_direction;
const pdLo = d.predicted_return_lo;
const pdHi = d.predicted_return_hi;
const af = d.ai_forecast;
// AI is loading whenever this TF is in a retryable state (timeout/ai_unavailable) — the
// frontend keeps refetching in the background, so show a soft "loading" note, never a
// terminal error. The ML forecast renders independently in its own slot meanwhile.
const aiLoading = noTradeReason === 'timeout' || noTradeReason === 'ai_unavailable'
|| (af && af.source === 'ai_unavailable');
let aiForecastHtml = '';
if (aiLoading) {
aiForecastHtml = `
🤖 AI forecast loading — retrying automatically. ML estimate shown below.
`;
}
const tfTarget = d.expected_target_price ?? d.min_target;
const hasSl = d.stop_loss && tfTarget;
const tgMet = hasSl && (d.actual_rr === undefined || d.actual_rr === null || d.actual_rr >= 1.5);
const rrPct = hasSl && pickPrice > d.stop_loss
? Math.min(100, Math.max(2, (pickPrice - d.stop_loss) / (tfTarget - d.stop_loss) * 100)).toFixed(0)
: 50;
const entryLbl = d.entry_basis === 'est_open' ? 'Est. Open' : 'Entry';
const isLiveEntry = d.entry_basis === 'live';
const entryTitle = isLiveEntry
? 'Live intraday price — entry for a same-session trade'
: `Based on previous close ₹${num(pickPrice)} — actual fill at next-day open`;
// ML risk data for this TF. The ML model may have its own directional call even when the
// AI side is a no-trade, so this block can render independently (keeps e.g. 3D from blanking).
// (_mlData is computed near the top of this function so the AI banner can also use it.)
const mlHasCall = _mlData && _mlData.direction && _mlData.direction !== 'N/A' && !_mlData.market_closed;
const mlEntry = _mlData && (_mlData.buy_price_suggestion || _mlData.expected_entry_price);
const mlTgt = _mlData && _mlData.expected_target_price;
const mlSL = _mlData && _mlData.stop_loss;
// R:R for the ML plan (same visual as the AI block): reward/risk multiple + a gradient bar
// showing where entry sits between SL and target. Works for both long and short calls
// because numerator and denominator flip sign together.
const mlHasRr = mlHasCall && mlEntry && mlSL && mlTgt && (mlTgt - mlSL) !== 0 && (mlEntry - mlSL) !== 0;
const mlRr = mlHasRr ? Math.abs((mlTgt - mlEntry) / (mlEntry - mlSL)) : null;
const mlRrMet = mlRr !== null && mlRr >= 1.5;
const mlRrPct = mlHasRr
? Math.min(100, Math.max(2, (mlEntry - mlSL) / (mlTgt - mlSL) * 100)).toFixed(0)
: 50;
const mlRiskBlock = (mlHasCall && mlEntry) ? `
` : '';
// No-trade cells (no signal, neutral, blocked, etc.) and range-only 1D calls have no
// actionable AI entry/SL/target, so suppress the AI risk block — but still show the ML block
// beneath when ML has its own call.
const aiRiskBlock = (isNoTrade || isRangeBound) ? '' : `
';
const _aiDirAttr = (af && af.direction) ? af.direction : '';
const _agreeInner = _agreeHtml(_mlData && _mlData.direction, _aiDirAttr, _mlData && _mlData.dir_basis);
// Session's final INTRADAY call, shown after the market closes instead of a "market closed"
// stub — it's the last real call made during the session, not a live prediction.
const finalCallBadge = d.intraday_final_call
? `
`;
}
// Fetch one TF for a watchlist card and update its cell in place (no full-card reload).
// Fetch one TF for a watchlist card and update its cell in place (no full-card reload).
// attempt: self-retry counter. A transient 'timeout' (a provider that is only per-minute
// rate-limited) is re-fetched after a short delay — single TF only, so it catches the
// provider reset WITHOUT re-bursting the whole watchlist the way a full reload would.
async function _fetchAndUpdateTfCell(ticker, tf, pick, attempt = 0, opts = {}) {
const { silent = false, force = false } = opts;
const safeId = ticker.replace(/[^a-zA-Z0-9]/g, '_');
const cell = document.getElementById('tf-' + safeId + '-' + tf);
if (!cell) return;
const tfLabel = tf === 'INTRADAY' ? 'Today' : tf;
// Keep the ML block in the loading cell (with its slot id) so the independent ML forecast
// stays visible while the slow AI call is in flight — ML must never disappear behind AI.
const _loadingCellHtml = `
${tfLabel}
⟳
`
+ `
🤖 AI loading…
`
+ `
🤖 ML MODEL
`
+ `
🤖 ML…
`;
// Silent mode (periodic INTRADAY auto-refresh of an already-populated cell): DON'T paint the
// loader — keep the current cell visible and swap it only once the fresh forecast arrives, so
// the auto-refresh doesn't flash a spinner every few minutes.
if (!silent) {
cell.className = 'tf-cell tf-cell--loading';
cell.innerHTML = _loadingCellHtml;
_fetchAndFillMl(ticker, false, [tf]); // keep ML visible during the AI fetch (cached → instant)
}
try {
// force → append ?refresh=1 so the server bypasses its 15-min INTRADAY cache and re-runs the AI.
const _url = '/api/watchlist-pick/' + encodeURIComponent(ticker) + '/' + tf + (force ? '?refresh=1' : '');
const res = await fetch(_url, {cache: 'no-store'});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || `Server error ${res.status}`);
const tfData = data.data || {};
// Silent auto-refresh must never replace an already-good forecast with a transient
// AI-unavailable/timeout placeholder — keep the current cell and retry on the next tick.
const _silentReason = tfData.no_trade_reason;
if (silent && (_silentReason === 'timeout' || _silentReason === 'ai_unavailable')) {
if (attempt < _AI_RETRY_MAX) setTimeout(() => _fetchAndUpdateTfCell(ticker, tf, pick, attempt + 1, opts), 60000);
return;
}
// Update cache so trade modal and retry button use fresh TF data
const cached = _predictionCache.get(ticker);
if (cached && cached.pick) cached.pick.timeframes[tf] = tfData;
// Persist onto the pick object too so any later re-render from a stored snapshot
// (e.g. the top-picks sort toggle, which re-renders from _lastTop5.picks) keeps this
// resolved AI forecast instead of reverting to the stale "AI loading" cell.
if (pick && pick.timeframes) pick.timeframes[tf] = tfData;
// Merge into local pick snapshot for rendering.
const updatedPick = Object.assign({}, pick, {
timeframes: Object.assign({}, pick.timeframes, {[tf]: tfData}),
});
const tmp = document.createElement('div');
tmp.innerHTML = _renderOneTfCell(tf, tfData, updatedPick);
const newCell = tmp.firstElementChild;
if (newCell) cell.replaceWith(newCell);
_fetchAndFillMl(ticker, false, [tf]); // refill only THIS cell's ML slot (cached → instant)
// AI still loading (rate-limited / provider busy)? Keep retrying this single cell every
// ~60s until a provider frees up — no terminal "AI unavailable". The ML forecast is
// already shown beside it, so the card is never blocked. Targeted per-cell retry does
// not touch the other cards/providers.
const _r = tfData.no_trade_reason;
if ((_r === 'timeout' || _r === 'ai_unavailable') && attempt < _AI_RETRY_MAX) {
setTimeout(() => _fetchAndUpdateTfCell(ticker, tf, pick, attempt + 1, opts), 60000);
}
} catch(e) {
// Silent auto-refresh must never surface an error or paint a loader — just leave the
// existing cell as-is and try again on the next periodic tick.
if (silent) return;
// Network / server hiccup while fetching the AI forecast — keep retrying quietly
// (the ML estimate is already shown), don't surface a terminal error.
if (attempt < _AI_RETRY_MAX) {
cell.className = 'tf-cell tf-cell--loading';
cell.innerHTML = _loadingCellHtml;
_fetchAndFillMl(ticker, false, [tf]); // keep ML visible during the retry wait
setTimeout(() => _fetchAndUpdateTfCell(ticker, tf, pick, attempt + 1, opts), 60000);
} else {
cell.className = 'tf-cell';
cell.innerHTML = `
${tfLabel}
🤖 AI still loading — tap ↺ to retry
`;
}
}
}
// ── ML forecast (standalone quantile model) — instant, local, no rate limits ──
// Each TF cell reserves a "ml--" slot; ML fills it as soon as one fast
// /api/ml-predict call resolves, while the AI row keeps its own (slower) loader.
const _mlCache = new Map(); // ticker -> /api/ml-predict result (all 3 TFs)
const _mlCacheTs = new Map(); // ticker -> Date.now() of last ML fetch
// INTRADAY must stay fresh (≤5 min), so the whole per-ticker ML payload is treated as stale
// after this window and refetched; 1D/3D are effectively cached between refreshes. A 5-min
// interval (below) also force-refreshes during market hours so INTRADAY is never cache-served
// longer than 5 minutes.
const _ML_CACHE_TTL_MS = 5 * 60 * 1000;
// Small muted ML status message (no forecast available) with an optional hover tooltip.
function _mlMsgHtml(msg, title = '') {
return `
${msg}
`;
}
// Map an ml_predictor "unavailable" reason to a clear, human message (not a bare "n/a").
function _mlReasonMsg(reason) {
const r = String(reason || '').toLowerCase();
if (r.includes('insufficient') || r.includes('history')) return '🤖 ML: not enough price history';
if (r.includes('ohlcv') || r.includes('fetch') || r.includes('data')) return '🤖 ML: market data unavailable';
if (r.includes('not loaded') || r.includes('artifacts') || r.includes('not trained')) return '🤖 ML model not loaded';
if (r.includes('feature')) return '🤖 ML: feature build failed';
if (r.includes('unknown timeframe')) return '🤖 ML: n/a for this horizon';
return '🤖 ML unavailable';
}
// Decide what to render in one TF's ML slot from the full /api/ml-predict payload.
function _mlSlotHtml(tf, ml) {
if (!ml) return _mlMsgHtml('🤖 ML unavailable');
if (ml.available === false) return _mlMsgHtml(_mlReasonMsg(ml.error || ml.source), ml.error || '');
const mlTf = (ml.tfs || {})[tf];
if (!mlTf) return _mlMsgHtml('🤖 ML: no data for this horizon');
if (mlTf.market_closed) return _mlMsgHtml('🕐 Market closed — no intraday ML', 'NSE closed for the day (post 15:30 IST)');
if (!mlTf.direction || mlTf.direction === 'N/A') return _mlMsgHtml('🤖 ML: no directional call');
return _renderMlRow(tf, mlTf);
}
// Render the ML mini-row for one timeframe from an ml_predictor TF object.
// Mirrors the main cell layout (return range → price range → target → direction) so ML and
// the AI/main call read the same, plus a calibrated ML confidence bar in every timeframe.
function _renderMlRow(tf, ml) {
if (!ml || !ml.direction || ml.direction === 'N/A') {
if (ml && ml.market_closed) return _mlMsgHtml('🕐 Market closed — no intraday ML');
return _mlMsgHtml('🤖 ML: no directional call');
}
const dir = ml.direction;
const color = dir === 'BULLISH' ? 'var(--green)' : dir === 'BEARISH' ? 'var(--red)' : 'var(--text-muted)';
const arrow = dir === 'BULLISH' ? '▲' : dir === 'BEARISH' ? '▼' : '◆';
// 1D/3D direction is trained on EXCESS-of-Nifty (alpha): BULLISH = outperform the market,
// BEARISH = UNDERPERFORM it — NOT an absolute crash/rally. Relabel so a red "BEARISH" next to
// an absolute -10% band doesn't read as a predicted crash. INTRADAY stays absolute.
const relative = ml.dir_basis === 'vs_nifty';
const dirLabel = relative
? (dir === 'BULLISH' ? 'OUTPERFORM' : dir === 'BEARISH' ? 'UNDERPERFORM' : 'IN-LINE')
: dir;
const basisTip = relative
? 'ML 1D/3D direction is measured vs Nifty (alpha): OUTPERFORM = expected to beat the market, UNDERPERFORM = expected to lag it. The ₹ range/target is the absolute modeled move if that relative call plays out — not a standalone crash/rally forecast.'
: 'ML directional call';
const basisChip = relative ? 'vs Nifty' : '';
// The model's raw band is the q10–q90 (80%) prediction interval — deliberately wide.
// For display we tighten it toward the MEDIAN (q50): halve the width on each side, centered
// on the most-likely move. Backend keeps the full q10/q90 (backtests/validation read those).
const q = ml.quantiles || {};
// Center on the SAME expected move the headline target uses (from expected_target_price), NOT
// the raw q50 — the raw median is uncapped/unscaled (INTRADAY scales it by ~0.42 + caps it), so
// centering on it pushed the tightened low bound ABOVE the target, making Target read below the
// range. Deriving medPct from expected_target_price keeps the target inside the shown band.
const medFromTarget = (ml.expected_target_price && ml.current_price && ml.current_price > 0)
? (ml.expected_target_price / ml.current_price - 1) * 100 : null;
const medPct = medFromTarget != null ? medFromTarget
: (dir === 'BULLISH' ? q.up_q50 : dir === 'BEARISH' ? q.down_q50 : ml.midpoint);
let nLo = ml.predicted_return_lo, nHi = ml.predicted_return_hi;
if (medPct != null && nLo != null && nHi != null) {
const lo = Math.min(nLo, nHi), hi = Math.max(nLo, nHi);
nLo = medPct + 0.5 * (lo - medPct);
nHi = medPct + 0.5 * (hi - medPct);
}
// Line 1: big return range — styled with the same visual weight as the AI/main return line.
const rangeStr = (nLo != null && nHi != null) ? formatReturnRange(nLo, nHi, 1) : '';
const retHtml = rangeStr
? `
${rangeStr}
` : '';
// Line 1b: ₹ price range — mirrors the AI's ₹ target range. Derived from the SAME tightened
// band shown just above (nLo/nHi) applied to the model's current price, so the ₹ range and the
// % range always agree. Shown for range-bound calls too (the flat ±1% band as a ₹ "stays
// within" range), exactly like the AI ₹ range.
const _mlCp = ml.current_price;
let rupeeRangeHtml = '';
if (_mlCp && _mlCp > 0 && nLo != null && nHi != null) {
const _rA = _mlCp * (1 + nLo / 100), _rB = _mlCp * (1 + nHi / 100);
rupeeRangeHtml = `
`;
}
// Line 2: headline MEDIAN (most-likely) target price.
// NEUTRAL / range-bound calls have no directional target (backend sends expected_target_price
// = null + range_bound = true) — show "Range-bound" instead of a target == current price.
const medPrice = ml.expected_target_price;
const tgtTitle = relative
? 'Absolute modeled price if the relative (vs-Nifty) call plays out — not a guaranteed move'
: 'ML expected (median) target price';
const priceHtml = (ml.range_bound || dir === 'NEUTRAL')
? `
Range-bound · no buy
`
: ((medPrice && medPrice > 0)
? `
Target ₹${num(medPrice, 0)}
` : '');
// Line 3: direction row (dot + arrow + label) — mirrors the AI direction row.
const note = (tf === 'INTRADAY' && dir === 'BULLISH')
? 'signal' : '';
// INTRADAY: if the modeled high has ALREADY been reached this session, flag it so the
// target isn't mistaken for a fresh entry (the "price already passed" case).
const gone = (tf === 'INTRADAY' && ml.intraday && ml.intraday.already_gone)
? 'high reached' : '';
// NEUTRAL / range-bound: no buy price and no directional edge — make it explicit that this
// is not a trade (the "ML says no price" case) with a clear "no trade" tag.
const hold = (ml.range_bound || dir === 'NEUTRAL')
? 'no trade' : '';
// Rare high-conviction flag: the model's calibrated probability is in the empirically-reliable
// tail (~85%+ OOS direction accuracy for this TF). Fires seldom by design — a precision badge.
const hiConv = (ml.high_conviction && dir !== 'NEUTRAL')
? '⭐ high-conviction' : '';
// Recently-listed / IPO guard: fewer than ~1 trading year of bars means the stock is outside
// the model's training distribution (long-window features + calibrated confidence unreliable),
// so the call is capped and flagged so it isn't over-trusted.
const lowHist = ml.low_history
? `⚠ limited history` : '';
const dirClass = dir.replace(/\s+/g, '-');
const dirHtml = `
`;
// Line 4: calibrated confidence — same bar visual as the AI confidence bar, labeled "ML conf".
let conf = (ml.confidence || '').toUpperCase();
if (!conf && ml.confidence_prob != null) {
const p = ml.confidence_prob;
conf = p >= 0.66 ? 'HIGH' : p >= 0.5 ? 'MEDIUM' : 'LOW';
}
const cl = conf.toLowerCase();
const _confPct = c => c === 'HIGH' ? 90 : c === 'MEDIUM' ? 55 : c === 'LOW' ? 25 : 0;
const confPctTitle = (ml.confidence_prob != null) ? ` (${Math.round(ml.confidence_prob * 100)}%)` : '';
const confHtml = conf
? `
ML conf
${conf}
`
: '';
return `${retHtml}${rupeeRangeHtml}${priceHtml}${dirHtml}${confHtml}`;
}
// Agreement badge between the ML and AI directional calls.
// `mlBasis` = ML's direction basis ('vs_nifty' for 1D/3D excess-labels, else absolute). When
// ML is relative-to-Nifty and AI is an absolute call, they measure DIFFERENT things, so a
// direction mismatch is NOT a contradiction (a stock can rise yet lag the market) — show a
// neutral "different axes" note instead of a scary "⚠ ML / AI split".
function _agreeHtml(mlDir, aiDir, mlBasis) {
if (!mlDir || !aiDir) return '';
const m = String(mlDir).toUpperCase(), a = String(aiDir).toUpperCase();
if (m === 'N/A' || a === 'N/A') return '';
const dirM = (m === 'BULLISH' || m === 'BEARISH');
const dirA = (a === 'BULLISH' || a === 'BEARISH');
const relative = mlBasis === 'vs_nifty';
if (dirM && dirA) {
if (m === a) return relative
? `
✓ ML + AI aligned
`
: `
✓ ML + AI agree
`;
// Relative ML vs absolute AI — different axes, not a real contradiction.
if (relative)
return `
◐ ML (vs Nifty) / AI (absolute)
`;
return `
⚠ ML / AI split
`;
}
// Exactly one side has a directional call, the other is NEUTRAL — a milder divergence
// (weak / mixed signal), still worth flagging so it isn't mistaken for agreement.
if (dirM || dirA)
return `
◐ ML / AI differ
`;
// Both NEUTRAL — they agree there is no directional edge.
return `
✓ ML + AI agree
`;
}
// Fetch ML predictions for a ticker (one call = all TFs) and fill each cell's ML slot.
// INTRADAY is never served from cache for longer than _ML_CACHE_TTL_MS (5 min); 1D reuses
// the cached payload within that window. Pass force=true to bypass the cache entirely.
// `tfs` limits WHICH slots get repainted — the periodic 5-min refresh passes ['INTRADAY'] so
// only the intraday slot repaints (1D stays put; no full-card flicker).
async function _fetchAndFillMl(ticker, force = false, tfs = ['INTRADAY', '1D']) {
if (!ticker) return;
const safeId = ticker.replace(/[^a-zA-Z0-9]/g, '_');
try {
let ml = _mlCache.get(ticker);
const age = Date.now() - (_mlCacheTs.get(ticker) || 0);
const stale = age > _ML_CACHE_TTL_MS; // INTRADAY freshness window
if (!ml || force || stale) {
const res = await fetch('/api/ml-predict/' + encodeURIComponent(ticker) + '?archive=1', { cache: 'no-store' });
ml = await res.json();
// Always cache the payload — even when NSE is closed. The ML row (especially 1D/3D,
// which don't move while the market is shut) must render instantly from cache on every
// re-render, otherwise each AI-retry rebuild regenerates the '🤖 ML…' placeholder and the
// ML forecast appears stuck/hung behind the slow AI call. INTRADAY freshness is preserved
// by the 5-min stale window and the market-hours refresh tick, which refetch the INTRADAY
// slot once the session is live again.
if (ml) {
_mlCache.set(ticker, ml);
_mlCacheTs.set(ticker, Date.now());
}
}
tfs.forEach(tf => _fillMlSlot(safeId, tf, ml));
} catch (e) {
tfs.forEach(tf => {
const slot = document.getElementById('ml-' + safeId + '-' + tf);
if (slot) slot.innerHTML = _mlMsgHtml('🤖 ML: request failed', String(e && e.message || e));
});
}
}
function _fillMlSlot(safeId, tf, ml) {
const slot = document.getElementById('ml-' + safeId + '-' + tf);
if (slot) slot.innerHTML = _mlSlotHtml(tf, ml);
const mlTf = (ml && ml.available && ml.tfs) ? ml.tfs[tf] : null;
const cell = document.getElementById('tf-' + safeId + '-' + tf);
const agree = document.getElementById('agree-' + safeId + '-' + tf);
if (agree) agree.innerHTML = _agreeHtml(mlTf && mlTf.direction, cell ? cell.dataset.aiDir : '', mlTf && mlTf.dir_basis);
}
// ── Render: Pick Card (Top 5) ─────────────────────────────────────────────────
// ML-based recommendation fallback: pick the ML model's strongest directional call
// (BULLISH/BEARISH, confidence-tier-first) across INTRADAY/1D. Used when the AI produced
// no actionable "best timeframe" (all AI cells N/A) so the card can still recommend a trade.
function _mlBestTf(ticker) {
const ml = _mlCache.get(ticker);
if (!ml || !ml.available || !ml.tfs) return null;
const rank = { HIGH: 3, MEDIUM: 2, LOW: 1 };
let best = null, bestKey = -1;
['INTRADAY', '1D'].forEach(tf => {
const d = ml.tfs[tf];
if (!d || d.market_closed) return;
if (d.direction !== 'BULLISH' && d.direction !== 'BEARISH') return; // need a directional call
const conf = String(d.confidence || '').toUpperCase();
const tfWeight = tf === 'INTRADAY' ? 2 : 1; // prefer the intraday horizon
const key = (rank[conf] || 0) * 10 + tfWeight;
if (key > bestKey) { bestKey = key; best = tf; }
});
return best;
}
function renderPickCard(pick, idx, idPrefix = 'pick', mode = 'top5') {
const dir = pick.direction || 'NEUTRAL';
const isUp = dir.includes('BULLISH');
const cls = isUp ? 'bullish' : dir.includes('BEARISH') ? 'bearish' : '';
const tfs = pick.timeframes || {};
const tvId = 'tv-' + idPrefix + '-' + pick.ticker.replace(/[^a-zA-Z0-9]/g, '_');
const news = pick.news || {};
_newsDataCache[pick.ticker] = news;
const risk = pick.risk || {};
const signals = pick.signals || {};
const warning = pick.warning || '';
const pickPrice = pick.price || 0;
let bestTf = pick.best_tf || null;
// Recommendation source: AI by default; if the AI produced no actionable best timeframe
// (all AI cells N/A), fall back to the ML model's strongest directional call.
let recSource = bestTf ? 'ai' : null;
let slBest, tgtBest, planDataBestJSON;
if (!bestTf) {
const mlTf = _mlBestTf(pick.ticker);
if (mlTf) {
bestTf = mlTf;
recSource = 'ml';
pick.best_tf = mlTf; // so the TF cell shows the "Best Bet" badge on the ML pick
const mld = (_mlCache.get(pick.ticker) || {}).tfs?.[mlTf] || {};
slBest = mld.stop_loss || 0;
tgtBest = mld.expected_target_price || Math.max(mld.target_price_lo || 0, mld.target_price_hi || 0) || 0;
planDataBestJSON = JSON.stringify({
timeframe: mlTf,
direction: mld.direction,
confidence: mld.confidence,
expected_entry_price: mld.buy_price_suggestion || mld.current_price || pickPrice,
stop_loss: mld.stop_loss,
expected_target_price: mld.expected_target_price,
target_price_lo: mld.target_price_lo,
target_price_hi: mld.target_price_hi,
source: 'ml',
}).replace(/'/g, "'");
}
}
if (recSource !== 'ml') {
slBest = (tfs[bestTf] || {}).stop_loss || 0;
tgtBest = (tfs[bestTf] || {}).expected_target_price || (tfs[bestTf] || {}).min_target || 0;
planDataBestJSON = JSON.stringify(Object.assign({}, tfs[bestTf] || {}, { timeframe: bestTf })).replace(/'/g, "'");
}
// HIGH-conviction = the best actionable timeframe carries HIGH confidence.
// Backtest: HIGH-conf directional calls hit 95-97% and are the profit bucket.
const _bestConf = ((tfs[bestTf] || {}).confidence || '').toUpperCase();
const _bestDir = ((tfs[bestTf] || {}).direction || '').toUpperCase();
const _actionable = ['BULLISH','BEARISH','SLIGHTLY BULLISH','SLIGHTLY BEARISH'].includes(_bestDir);
const isHighConviction = _bestConf === 'HIGH' && _actionable;
const convictionBadge = isHighConviction
? `⭐ HIGH CONVICTION`
: '';
// Retry button: shown when any TF timed out or AI was unavailable (watchlist only)
const _retryReasons = new Set(['timeout', 'ai_unavailable']);
const _hasRetryable = mode === 'watchlist' && ['INTRADAY','1D'].some(tf => {
const r = (tfs[tf] || {}).no_trade_reason;
return _retryReasons.has(r);
});
const retryBtn = _hasRetryable
? ``
: '';
// ML-selection verdict: was this stock chosen by the ML selector, and did AI confirm?
let mlVerdictBadge = '';
if (pick.ml_selected) {
if (pick.ml_ai_verdict === 'confirmed')
mlVerdictBadge = `⭐ ML pick · AI confirmed`;
else if (pick.ml_ai_verdict === 'disagree')
mlVerdictBadge = `⚠ ML pick · AI disagrees`;
else
mlVerdictBadge = `🤖 ML pick`;
}
const tfHtml = ['INTRADAY','1D'].map(tf => _renderOneTfCell(tf, tfs[tf] || {}, pick)).join('');
const safeCompany = (pick.company || '').replace(/'/g, "\\'");
const headerLeft = mode === 'watchlist'
? ''
: `${idx + 1}`;
const recTfLabel = bestTf ? (bestTf === 'INTRADAY' ? 'Today' : bestTf) : 'N/A';
const recLabel = bestTf
? `Recommended: ${recTfLabel}${recSource === 'ml' ? ' · 🤖 ML' : ''}`
: 'Recommended: N/A';
const recTitle = recSource === 'ml'
? 'AI forecast unavailable — recommendation from the ML model'
: 'Best timeframe from the AI forecast';
const actionBtns = mode === 'watchlist'
? `
`
: `
`;
const bareSym = pick.ticker.replace(/\.(NS|BO)$/i, '');
const exchange = pick.ticker.endsWith('.BO') ? 'BSE' : 'NSE';
const aiDirs = ['INTRADAY', '1D']
.map(tf => ((tfs[tf] || {}).ai_forecast || {}).direction)
.filter(d => d === 'BULLISH' || d === 'BEARISH');
const aiConsensus = aiDirs[0] || null;
const newsSentimentLabel = (label) => {
if (label === 'BULLISH') return 'POSITIVE';
if (label === 'BEARISH') return 'NEGATIVE';
if (label === 'NEUTRAL') return 'NEUTRAL';
return 'N/A';
};
const newsSentiment = newsSentimentLabel(news.label);
const hasNewsAiDivergence = !!(news.label && aiConsensus &&
((news.label === 'BULLISH' && aiConsensus === 'BEARISH') ||
(news.label === 'BEARISH' && aiConsensus === 'BULLISH')));
// Show softer note when news is directional but AI sees mixed/neutral technicals
const hasNeutralVsStrongNews = !aiConsensus && news.label && news.label !== 'NEUTRAL';
const divergenceHtml = hasNewsAiDivergence
? `
News sentiment is ${newsSentiment.toLowerCase()} while AI direction is ${aiConsensus.toLowerCase()}. AI also uses trend, momentum, and volatility signals.
`
: hasNeutralVsStrongNews
? `
News signal is ${newsSentiment.toLowerCase()} but AI sees mixed technical signals — no clear directional edge.
${pick.error || 'Data unavailable'} ${(pick.error || '').includes('All data sources failed') ? 'Symbol may be delisted or renamed — try removing and searching for the correct ticker.' : 'Predictions unavailable — historical data may be insufficient for this ticker.'}
`;
}
return renderSection('Recommended Buys', 'good', buys)
+ renderSection('Avoid', 'bad', bears)
+ renderSection('Blocked (VIX/Macro)', 'warn', blocked);
}
// ── Shared helpers ────────────────────────────────────────────────────────────
function num(n, decimals = 2) {
if (n === null || n === undefined) return '—';
return Number(n).toLocaleString('en-IN', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
function pnlClass(val) {
if (val > 0) return 'pnl-pos';
if (val < 0) return 'pnl-neg';
return 'pnl-zero';
}
function showEl(id) { const e = document.getElementById(id); if (e) { e.classList.remove('hidden'); } }
function hideEl(id) { const e = document.getElementById(id); if (e) { e.classList.add('hidden'); } }
// ── Dashboard ─────────────────────────────────────────────────────────────────
async function loadDashboard() {
// Top 5 preview — kick off immediately so it runs in parallel with portfolio/trades fetches
const cardsEl = document.getElementById('dash-top5-cards');
if (!cardsEl.hasAttribute('data-loaded') || cardsEl.querySelectorAll('.pick-card').length === 0) {
loadTop5Cards('dash-top5-cards', 'dash-top5-loading', 20);
}
// Portfolio summary
try {
const res = await fetch('/api/portfolio', { cache: 'no-store' });
const data = await res.json();
const pnlCls = pnlClass(data.closed_pnl);
document.getElementById('ds-open').textContent = data.open_count ?? '0';
document.getElementById('ds-invested').textContent = '₹' + num(data.total_invested);
document.getElementById('ds-pnl').innerHTML = `${data.closed_pnl > 0 ? '+' : ''}₹${num(data.closed_pnl)}`;
document.getElementById('ds-winrate').textContent = data.win_rate + '%';
document.getElementById('ds-total').textContent = data.total_trades ?? '0';
} catch (e) { console.warn(e); } finally { dismissLoader(); }
// Open trades mini + unrealised P&L strip card
try {
const res = await fetch('/api/trades/open', { cache: 'no-store' });
const data = await res.json();
const trades = data.trades || [];
const el = document.getElementById('dash-open-trades');
el.innerHTML = renderOpenTradesMini(trades);
const totalUnrealised = trades.reduce((sum, t) => sum + (t.unrealised_pnl ?? 0), 0);
const uEl = document.getElementById('ds-unrealised');
if (uEl) {
const cls = pnlClass(totalUnrealised);
uEl.innerHTML = `${totalUnrealised >= 0 ? '+' : ''}₹${num(totalUnrealised)}`;
}
} catch (e) { console.warn(e); }
}
document.getElementById('dash-refresh-top5')?.addEventListener('click', () => {
const cardsEl = document.getElementById('dash-top5-cards');
cardsEl.removeAttribute('data-loaded');
cardsEl.innerHTML = '';
loadTop5Cards('dash-top5-cards', 'dash-top5-loading', 20, true);
});
// ── Top 5 view ────────────────────────────────────────────────────────────────
let _top5PollCount = 0;
let _top5PollTimer = null;
let _top5AutoRetried = false; // tracks one-shot auto-retry when cached result is empty
let _top5PollStart = 0; // epoch ms of the first poll — used for the elapsed-time cap
const _top5AiRetried = new Set(); // 'ticker|tf' cells that already have a background AI retry running
// Render top-pick cards into a container and mount their mini charts.
// Shared by the final render and the streaming (partial) render.
function _renderTop5CardsInto(cardsEl, idPrefix, picks, bannerHtml = '') {
_lastTop5 = { cardsEl, idPrefix, picks, banner: bannerHtml };
const ordered = _applyTop5Sort(picks);
// Preserve already-mounted chart nodes across re-renders. Streaming polls this every ~4s and
// the sort toggle re-renders too; a naive innerHTML rebuild tore down + remounted every live
// chart each time, causing visible blinking. Stash each mounted chart's wrapper by ticker id
// and splice it back into the fresh (empty) slot instead of remounting.
const chartStash = {};
ordered.forEach(p => {
const tvId = 'tv-' + idPrefix + '-' + p.ticker.replace(/[^a-zA-Z0-9]/g, '_');
const existing = document.getElementById(tvId);
if (existing && existing.dataset.chartMounted) {
chartStash[tvId] = existing.closest('.chart-wrap') || existing;
}
});
const sortBar = `
Rank by
${[['ai','AI'],['ml','🤖 ML'],['blend','Blend']].map(([m,l]) =>
``).join('')}
ML ranks on INTRADAY/1D conviction
`;
cardsEl.innerHTML = sortBar + bannerHtml + ordered.map((p, i) => renderPickCard(p, i, idPrefix)).join('');
ordered.forEach(p => {
const tvId = 'tv-' + idPrefix + '-' + p.ticker.replace(/[^a-zA-Z0-9]/g, '_');
const stashed = chartStash[tvId];
if (stashed) {
const freshSlot = document.getElementById(tvId);
if (freshSlot) freshSlot.replaceWith(stashed); // reuse the live chart — no remount, no blink
} else {
observeTvChart(tvId, p.ticker);
}
_fetchAndFillMl(p.ticker); // instant ML row; AI row keeps its own loader
// Top picks have no per-card ↺ Retry button, so without this any TF that came back
// 'ai_unavailable'/'timeout' would sit on "AI loading… retrying automatically" forever.
// Kick off the same per-cell background retry the watchlist uses (single-ticker/TF
// endpoint), guarded so each cell only spawns one retry chain across re-renders/sorts.
['INTRADAY', '1D'].forEach(tf => {
const r = (p.timeframes || {})[tf]?.no_trade_reason;
if (r !== 'timeout' && r !== 'ai_unavailable') return;
const key = p.ticker + '|' + tf;
if (_top5AiRetried.has(key)) return;
_top5AiRetried.add(key);
_fetchAndUpdateTfCell(p.ticker, tf, p);
});
});
// Top picks are cached for the day (same stocks), but INTRADAY moves all session — freshen
// each pick's INTRADAY cell in place right after render (silent, deduped to ≤1/60s per ticker).
_refreshTop5Intraday(ordered);
}
// Silent INTRADAY auto-refresh for the currently-rendered top picks. Keeps the SAME day-cached
// stocks/ranking but repaints each INTRADAY cell with a fresh prediction (no spinner flash).
// Deduped per ticker so the ~4s streaming polls + sort toggles don't re-fetch repeatedly.
const _top5IntradayLastRefresh = new Map(); // ticker -> epoch ms of last silent INTRADAY refresh
function _refreshTop5Intraday(picks, minGapMs = 60000, force = false) {
if (!_isMarketHoursIST()) return;
(picks || []).forEach(p => {
if (!p || !p.ticker) return;
const last = _top5IntradayLastRefresh.get(p.ticker) || 0;
if (Date.now() - last < minGapMs) return;
_top5IntradayLastRefresh.set(p.ticker, Date.now());
_fetchAndUpdateTfCell(p.ticker, 'INTRADAY', p, 0, { silent: true, force });
});
}
// ── Top-pick ranking: AI (server order) / ML (quantile conviction) / Blend ─────
let _top5Sort = 'ai';
let _lastTop5 = { cardsEl: null, idPrefix: '', picks: [], banner: '' };
// ML conviction score for a ticker from its cached prediction — INTRADAY/1D
// (matches the AI ranking horizons).
function _mlPickScore(ticker) {
const ml = _mlCache.get(ticker);
if (!ml || !ml.available || !ml.tfs) return 0;
let best = 0;
['INTRADAY', '1D'].forEach(tf => {
const d = ml.tfs[tf];
if (!d) return;
const prob = d.confidence_prob || 0.5;
let exp = 0;
if (d.current_price && d.expected_target_price) exp = (d.expected_target_price / d.current_price - 1) * 100;
else if (d.predicted_return_hi != null) exp = d.predicted_return_hi;
const dirMult = d.direction === 'BULLISH' ? 1 : d.direction === 'BEARISH' ? 0.2 : 0.4;
const s = prob * Math.abs(exp) * dirMult;
if (s > best) best = s;
});
return best;
}
function _applyTop5Sort(picks) {
if (_top5Sort === 'ai') return picks;
const arr = picks.map((p, i) => ({ p, ai: i, ml: _mlPickScore(p.ticker) }));
if (_top5Sort === 'ml') { arr.sort((a, b) => b.ml - a.ml || a.ai - b.ai); return arr.map(x => x.p); }
// Blend: average the AI position and the ML position (lower = better).
const byMl = arr.slice().sort((a, b) => b.ml - a.ml);
byMl.forEach((x, idx) => { x.mlRank = idx; });
arr.sort((a, b) => (a.ai + a.mlRank) - (b.ai + b.mlRank));
return arr.map(x => x.p);
}
function _setTop5Sort(mode) {
_top5Sort = mode;
if (_lastTop5.cardsEl) _renderTop5CardsInto(_lastTop5.cardsEl, _lastTop5.idPrefix, _lastTop5.picks, _lastTop5.banner);
}
async function loadTop5Cards(cardsId, loadingId, limit = 20, forceRefresh = false) {
_clearAiRetry();
const cardsEl = document.getElementById(cardsId);
if (!cardsEl) return;
// Only show the "Loading picks…" spinner on the very first call (not on poll retries).
// Retries update the existing "Analysing…" message in place so the UI doesn't flicker.
const isRetry = !forceRefresh && _top5PollCount > 0;
if (!isRetry) { showEl(loadingId); cardsEl.innerHTML = ''; _top5PollStart = Date.now(); _top5AiRetried.clear(); }
if (forceRefresh) { _top5PollCount = 0; _top5AutoRetried = false; _top5PollStart = Date.now(); _top5AiRetried.clear(); if (_top5PollTimer) { clearTimeout(_top5PollTimer); _top5PollTimer = null; } }
// Abort the fetch after 5 min — allows the server time to finish computing on a cold start.
const ctrl = new AbortController();
const fetchTimeout = setTimeout(() => ctrl.abort(), 300000);
try {
const top5Url = forceRefresh ? '/api/top5?refresh=1' : '/api/top5';
const res = await fetch(top5Url, { cache: 'no-store', signal: ctrl.signal });
clearTimeout(fetchTimeout);
// Read as text first so a non-JSON response (e.g. HF Spaces HTML wake-up page
// or a proxy error page) gives an actionable error instead of "Unexpected token '<'".
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch (_parseErr) {
// Server is starting up or unavailable — retry in 15s rather than showing an error.
cardsEl.innerHTML = `
⏳
Server is starting up…
Retrying in 15 seconds. (HTTP ${res.status})
`;
hideEl(loadingId);
_top5PollTimer = setTimeout(() => loadTop5Cards(cardsId, loadingId, limit, false), 15000);
return;
}
if (!res.ok || data.error) throw new Error(data.error || `Server error ${res.status}`);
// Server is computing picks in the background — stream ready cards as they come.
if (data.computing) {
_top5PollCount++;
hideEl(loadingId);
const idPrefix = cardsId.replace(/[^a-zA-Z0-9]/g, '_');
const partial = (data.picks || []).slice(0, limit);
const elapsedMin = (Date.now() - _top5PollStart) / 60000;
// Hard cap (~12 min elapsed) so a stuck backend never spins forever —
// surface a genuine error with a manual retry instead of an endless loader.
if (elapsedMin > 12 && !partial.length) {
_top5PollCount = 0;
cardsEl.innerHTML = `
Top picks are still not ready after ~12 minutes — the market scan may be failing (data source blocked or market data unavailable).
`;
return;
}
const phaseMsg = data.message
|| (data.phase === 'predicting' ? 'Running AI on shortlisted candidates…' : 'Scanning the NSE market…');
if (partial.length) {
// Progressive reveal: render ready cards immediately with a slim progress banner,
// and keep filling in the rest. Poll fast (4s) so pending cards resolve quickly.
if (data.market) applyMarket(data.market);
const banner = `
`;
_renderTop5CardsInto(cardsEl, idPrefix, partial, banner);
cardsEl.setAttribute('data-loaded', '1');
_top5PollTimer = setTimeout(() => loadTop5Cards(cardsId, loadingId, limit, false), 4000);
return;
}
// No cards ready yet — show the phase spinner. Poll every 6s so the first
// cards appear promptly once Phase 2 starts resolving.
const waitMsg = elapsedMin > 3
? `${phaseMsg} ~${Math.round(elapsedMin)} min so far. Slowest on a cold cache, faster on later runs. Checking automatically.`
: `${phaseMsg} This can take a few minutes on a cold start. Checking automatically.`;
cardsEl.innerHTML = `
⏳
Analysing top stocks…
${waitMsg}
`;
_top5PollTimer = setTimeout(() => loadTop5Cards(cardsId, loadingId, limit, false), 6000);
return;
}
if (data.market) applyMarket(data.market);
if (data.market_closed) showMarketClosedBanner(data.market_closed);
const picks = (data.picks || []).slice(0, limit);
if (!picks.length) {
// Cache returned empty — auto-retry once with a fresh compute (no cache).
if (!forceRefresh && !_top5AutoRetried) {
_top5AutoRetried = true;
cardsEl.innerHTML = `