Files
ForexBot2/static/dashboard.js
T
Harwinder Sekhon ad14ea4fed news corpus for a local scorer + agent panel v2 + dashboard read-only (2026-09-17); books byte-identical
Agent panel v2: personas return p_win anchored to the book base rates with a
code-computed decision context; verdict = equal-weight mean (verdict_source
"mean"); only the macro persona runs live (agent_panel.personas ["macro"]);
prompt version and exact payloads logged; offline replay harness
scripts/panel_replay.py; base-rate table agent_panel_base_rates.json
(scripts/build_panel_base_rates.py). Backtester `strategy` column now carries
the generator's own stamp (label-only; five reference books re-pinned, all
62 other columns identical).

News corpus (after evaluating the owner's ML-NEWS scikit model against
DeepSeek: golden set 39/78 vs 78/78, out-of-sample agreement 64% against a
90% self-agreement ceiling; verdict: fallback only, not a replacement):
- NewsSentiment.xau_score + archive column xau_score keep DeepSeek's gold
  score for every headline (930 of the first 1,546 DeepSeek rows had none);
  an existing newsdata.db gains the column in place on first open
- headlines naming no tracked currency are scored and archived
  (currencies_json []) but never returned to the platform
- _apply_llm_sentiment sends every new headline in its cycle, 25 per call;
  a failed call ends the cycle's sending

Dashboard publisher never writes a database: main() calls
app_config.set_db_writes_for_process(False), honoured by signal_log_enabled()
and news_archive_enabled() (owner decision: the bot is the single writer).

Test suite: the hermetic app config now points both writers at a
suite-private temp dir — the RSS fixture tests were writing fixture
headlines into the real logs/newsdata.db — with a Module 32 test pinning it.
529 tests.
2026-09-17 19:49:29 -04:00

985 lines
48 KiB
JavaScript
Raw 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.
"use strict";
/* Gold Desk — XAU/USD dashboard frontend
*
* Reads snapshot.json + news.json published by dashboard_publisher.py and
* renders a responsive, keyboard-accessible dashboard. Uses Bootstrap 5 for
* layout/components/modals and a small custom CSS file for app-specific
* visuals (score bar, signal colours, mono fonts).
*
* This file is intentionally self-contained: it mirrors a few constants from
* the Python signal pipeline so the HTML does not depend on telegram_bot.
*/
const IND_ORDER = ["ema_trend", "rsi", "macd", "bollinger", "adx", "roc", "keltner", "divergence", "candlestick"];
const IND_SHORT = {
ema_trend: "EMA", rsi: "RSI", macd: "MACD", bollinger: "BB",
adx: "ADX", roc: "ROC", keltner: "KC", divergence: "Div", candlestick: "Cndl"
};
const ARROW = { STRONG_BUY: "⇈", BUY: "↑", NEUTRAL: "→", SELL: "↓", STRONG_SELL: "⇊" };
const TF_LABEL = { "5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4" };
const TF_ORDER = ["5min", "15min", "1h", "4h"];
const TF_SECONDS = { "5min": 300, "15min": 900, "1h": 3600, "4h": 14400 };
const FALLBACK_POLL_MS = 60000;
const RETRY_MS = 15000;
const HIGH_IMPACT_PREF = "goldDeskHighImpactOnly";
const TIMEFRAME_PREF = "goldDeskSelectedTimeframe";
const LEVEL_LABEL = {
ema_fast: "EMA fast", ema_medium: "EMA medium", ema_slow: "EMA slow", ema_trend: "EMA trend",
bb_upper: "Bollinger upper", bb_mid: "Bollinger mid", bb_lower: "Bollinger lower",
kc_upper: "Keltner upper", kc_mid: "Keltner mid", kc_lower: "Keltner lower",
swing_high_1: "Swing high 1", swing_high_2: "Swing high 2", swing_high_3: "Swing high 3",
swing_low_1: "Swing low 1", swing_low_2: "Swing low 2", swing_low_3: "Swing low 3"
};
let snapshot = null, news = null;
let clockOffset = 0;
let lastPollOk = null;
let lastSnapText = null, lastNewsText = null;
let refetchTimer = null;
let fallbackTimer = null;
let pollInFlight = false;
let pollQueued = false;
let selectedTf = "5min";
let detailTf = null;
let ndState = null;
const aiMemo = new Map();
const aiErr = new Map();
const aiPending = new Set();
let tfModal = null;
let newsModal = null;
let tfModalOpen = false;
let newsModalOpen = false;
const $ = id => document.getElementById(id);
/* ── helpers ──────────────────────────────────────────────────── */
const esc = s => String(s ?? "").replace(/[&<>"']/g,
c => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
const fmt$ = v => v == null ? "—" : Number(v).toLocaleString("en-US",
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
const sigClass = s => s?.includes("BUY") ? "buy" : s?.includes("SELL") ? "sell" : "neutral";
const sigLabel = s => (s || "NEUTRAL").replace("_", " ");
const nowServer = () => Date.now() / 1000 + clockOffset;
function localGet(key) {
try { return window.localStorage.getItem(key); } catch { return null; }
}
function localSet(key, value) {
try { window.localStorage.setItem(key, value); } catch { /* storage is optional */ }
}
function announce(message) {
const live = $("dashboard-live");
if (live) live.textContent = message;
}
function fmtCountdown(secs) {
if (secs == null || !isFinite(secs)) return "--:--";
secs = Math.max(0, Math.floor(secs));
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
const mm = String(m).padStart(2, "0"), ss = String(s).padStart(2, "0");
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}
function fmtAge(iso) {
if (!iso) return "";
const secs = nowServer() - Date.parse(iso) / 1000;
if (secs < 90) return `${Math.max(0, Math.round(secs))}s ago`;
if (secs < 5400) return `${Math.round(secs / 60)}m ago`;
if (secs < 172800) return `${Math.round(secs / 3600)}h ago`;
return `${Math.round(secs / 86400)}d ago`;
}
const signed = (v, dp = 2) => v == null ? "—" : (v >= 0 ? "+" : "") + Number(v).toFixed(dp);
const badgeCls = s => s === "buy" ? "bg-success-subtle text-success-emphasis border-success-subtle"
: s === "sell" ? "bg-danger-subtle text-danger-emphasis border-danger-subtle"
: "bg-secondary-subtle text-secondary-emphasis border-secondary-subtle";
/* ── theme ────────────────────────────────────────────────────── */
// Dark theme is forced site-wide (owner decision 2026-08-16): dark
// background, pure-white text, red/green data accents.
document.documentElement.setAttribute("data-bs-theme", "dark");
/* ── hero ─────────────────────────────────────────────────────── */
function renderHero() {
if (!snapshot) return;
let price = null;
for (const tf of TF_ORDER) {
const p = snapshot.timeframes[tf]?.report?.current_price;
if (p) { price = p; break; }
}
$("hero-price").textContent = price ? fmt$(price) : "—";
$("hero-rail").innerHTML = TF_ORDER.map(tf => {
const r = snapshot.timeframes[tf]?.report;
const sig = r?.signal || null;
const cls = sigClass(sig);
const arrow = sig ? (ARROW[sig] || "→") : "·";
const score = r?.score != null ? r.score.toFixed(0) : "–";
const textColor = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
return `<button type="button" class="rail-chip" data-select-tf="${tf}"
aria-current="${tf === selectedTf ? "true" : "false"}"
title="${esc(sigLabel(sig))} (score ${esc(score)}) — select ${TF_LABEL[tf]} workspace">
<span class="tf-name">${TF_LABEL[tf]}</span>
<span class="${textColor} fw-bold">${arrow}</span>
<span>${esc(score)}</span>
</button>`;
}).join("");
const closed = snapshot.market_closed;
$("hero-status").innerHTML =
`<span class="live-dot${closed ? " closed" : ""}"></span>${closed ? "CLOSED" : "OPEN"}`;
$("banner-closed").classList.toggle("d-none", !closed);
}
/* ── market context strip ─────────────────────────────────────── */
function renderContext() {
if (!news) return;
$("context").classList.remove("d-none");
$("ctx-blurb").innerHTML = news.llm_summary
? `<strong>Market context:</strong> ${esc(news.llm_summary)}`
: `<strong>Market context:</strong> <span class="text-secondary">no summary available</span>`;
let chips = "";
for (const cur of ["XAU", "USD"]) {
const v = news.sentiment?.[cur];
if (v == null) continue;
const cls = v > 0.05 ? "text-bg-success" : v < -0.05 ? "text-bg-danger" : "text-bg-secondary";
chips += `<span class="badge ${cls}" title="Mean headline sentiment for ${cur} (−1…+1)">
${cur} news <span class="num">${v >= 0 ? "+" : ""}${v.toFixed(2)}</span></span>`;
}
let risk = "clear", riskDesc = "No significant events";
if (snapshot) {
for (const tf of TF_ORDER) {
const r = snapshot.timeframes[tf]?.report;
if (r?.event_risk && r.event_risk !== "clear") { risk = r.event_risk; riskDesc = r.event_description; break; }
if (r?.event_description) riskDesc = r.event_description;
}
}
if (news.high_impact_warning) { risk = "danger"; riskDesc = news.warning_message || riskDesc; }
const riskCls = risk === "clear" ? "text-bg-success"
: risk === "danger" ? "text-bg-danger"
: "text-bg-warning";
chips += `<span class="badge ${riskCls}" title="${esc(riskDesc)}">event risk: ${risk.toUpperCase()}</span>`;
$("ctx-chips").innerHTML = chips;
}
/* ── timeframe cards ──────────────────────────────────────────── */
function scoreBar(label, score) {
const v = Math.max(-100, Math.min(100, score ?? 0));
const width = (Math.abs(v) / 2).toFixed(1);
return `<div class="scorebar">
<span class="lbl">${label}</span>
<div class="gauge-container" aria-hidden="true">
<i class="fill ${v >= 0 ? "pos" : "neg"}" style="--score-width:${width}%"></i>
</div>
<span class="val num ${v > 0 ? "text-success" : v < 0 ? "text-danger" : ""}">${v >= 0 ? "+" : ""}${v.toFixed(1)}</span>
</div>`;
}
function setupCard(ts) {
if (!ts) return "";
const dir = ts.direction === "BUY" ? "buy" : "sell";
const dirIcon = ts.direction === "BUY" ? "bi-arrow-up-circle-fill" : "bi-arrow-down-circle-fill";
const trail = (ts.trail_arm_r != null && ts.trail_dist_r != null)
? `arm ${ts.trail_arm_r}R · dist ${ts.trail_dist_r}R` : "platform default";
return `<div class="card border-signal sig-${dir} bg-body-tertiary">
<div class="card-body py-2 px-3">
<div class="d-flex align-items-baseline gap-2 mb-2">
<span class="fw-bold text-signal sig-${dir}"><i class="bi ${dirIcon} me-1"></i>${ts.direction}</span>
<span class="small text-secondary font-monospace">${esc(ts.strategy || "")}</span>
<span class="small text-secondary ms-auto font-monospace">R:R ${(+ts.risk_reward).toFixed(1)}</span>
</div>
<div class="table-responsive"><table class="table table-sm table-borderless mb-0">
<tbody><tr><td class="text-secondary"><i class="bi bi-box-arrow-in-right me-1"></i>Entry</td><td class="num">${fmt$(ts.entry)}</td>
<td class="text-secondary ps-4"><i class="bi bi-flag-fill me-1 text-success"></i>TP1</td><td class="num text-success">${fmt$(ts.take_profit_1)}</td></tr>
<tr><td class="text-secondary"><i class="bi bi-shield-x me-1 text-danger"></i>Stop</td><td class="num text-danger">${fmt$(ts.stop_loss)}</td>
<td class="text-secondary ps-4"><i class="bi bi-flag-fill me-1 text-success"></i>TP2</td><td class="num text-success">${fmt$(ts.take_profit_2)}</td></tr>
<tr><td class="text-secondary"><i class="bi bi-signpost-split me-1"></i>Trail</td><td class="num" colspan="3">${esc(trail)}</td></tr></tbody>
</table></div>
</div>
</div>`;
}
function workspacePanel(tf) {
const entry = snapshot?.timeframes?.[tf] || {};
const r = entry.report;
if (!r) {
return `<article class="card workspace-card border-signal sig-neutral" id="workspace-panel" aria-labelledby="workspace-panel-heading">
<div class="card-body py-4 text-center text-secondary">
<h3 class="tf-name mb-2" id="workspace-panel-heading">${TF_LABEL[tf]}</h3>
${entry.status === "error"
? `<span class="badge text-bg-danger">SCAN UNAVAILABLE</span><div class="small mt-2">Showing again when the next scan succeeds.</div>`
: "Warming up — no completed scan for this timeframe yet."}
</div>
</article>`;
}
const cls = sigClass(r.signal);
const textCls = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
const qHi = r.quality === "A+" || r.quality === "A";
const pa = r.primary_analysis;
const meta = [r.market_regime, r.volatility_regime && r.volatility_regime + " volatility",
r.mtf_agreement ? "MTF aligned" : "MTF mixed"].filter(Boolean).join(" · ");
const facts = `<div class="row row-cols-2 row-cols-md-4 g-2 mt-1">
${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
${tile("Quality", esc(r.quality || "—"), qHi ? "text-primary" : "")}
${tile("Event risk", esc((r.event_risk || "—").toUpperCase()), r.event_risk === "clear" ? "text-success" : r.event_risk ? "text-warning" : "")}
${tile("MTF", r.mtf_agreement ? "aligned" : "mixed", r.mtf_agreement ? "text-success" : "")}
</div>`;
const setup = r.trade_setup
? setupCard(r.trade_setup)
: `<div class="text-secondary small">No setup published. The signal has not cleared its setup gate.</div>`;
return `<article class="card workspace-card border-signal sig-${cls}" id="workspace-panel" aria-labelledby="workspace-panel-heading">
<div class="card-body p-3 p-lg-4">
<div class="row g-4">
<div class="col-lg-7 workspace-meta">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
<div>
<div class="d-flex align-items-center gap-2 flex-wrap mb-2">
<h3 class="tf-name mb-0" id="workspace-panel-heading">${TF_LABEL[tf]} signal</h3>
<span class="badge ${badgeCls(cls)}">${esc(sigLabel(r.signal))}</span>
${r.quality ? `<span class="badge ${qHi ? "text-bg-primary" : "text-bg-secondary"}">quality ${esc(r.quality)}</span>` : ""}
${entry.status === "error" ? `<span class="badge text-bg-danger">STALE</span>` : ""}
</div>
<div class="workspace-signal ${textCls}">${esc(r.action || sigLabel(r.signal))}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" data-open-detail="${tf}">
Full analysis <i class="bi bi-arrow-up-right ms-1"></i>
</button>
</div>
${scoreBar("score", pa ? pa.composite_score : r.score)}
<div class="small text-secondary mt-2">${esc(meta)}</div>
${facts}
<div class="workspace-reasons mt-3 pt-3 border-top">${listBlock(r) || "<div class=\"small text-secondary\">No supporting reasons recorded on this scan.</div>"}</div>
<div class="small text-secondary mt-3">${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""} · closes in <span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[tf]}">--:--</span></div>
</div>
<div class="col-lg-5">
<div class="small text-uppercase text-secondary fw-bold mb-2">Trade plan</div>
${setup}
</div>
</div>
</div>
</article>`;
}
function renderCards() {
if (!snapshot) return;
if (!TF_LABEL[selectedTf]) selectedTf = "5min";
const selector = TF_ORDER.map(tf => {
const entry = snapshot.timeframes[tf] || {};
const r = entry.report;
const cls = r ? sigClass(r.signal) : "neutral";
const arrow = r ? (ARROW[r.signal] || "→") : "·";
const score = r?.score != null ? signed(r.score, 0) : "—";
const signal = r ? sigLabel(r.signal) : entry.status === "error" ? "UNAVAILABLE" : "WARMING";
return `<button type="button" class="tf-select" data-select-tf="${tf}" aria-pressed="${tf === selectedTf}">
<span class="d-flex align-items-center justify-content-between gap-2">
<span class="tf-name">${TF_LABEL[tf]}</span>
<span class="${cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary"} fw-bold">${arrow}</span>
</span>
<span class="small ${cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary"}">${esc(signal)}</span>
<span class="small num float-end">${esc(score)}</span>
</button>`;
}).join("");
$("tf-grid").innerHTML = `<div class="tf-selector">${selector}</div>${workspacePanel(selectedTf)}`;
tick();
}
/* ── timeframe detail dialog ──────────────────────────────────── */
const tile = (k, v, cls = "") =>
`<div class="col"><div class="card d-stat bg-body-tertiary h-100"><div class="card-body p-2">
<span class="k">${esc(k)}</span>
<span class="v ${cls}">${v}</span>
</div></div></div>`;
const SEC_ICON = {
"At a glance": "bi-speedometer2",
"Trade setup": "bi-bullseye",
"Why this signal": "bi-lightbulb",
"Multi-timeframe confirmation": "bi-layers",
"News context": "bi-newspaper",
"AI panel (advisory)": "bi-robot",
"Scan detail": "bi-clock-history",
"Score": "bi-graph-up",
"From the source": "bi-quote",
"AI gold-impact summary": "bi-stars",
"Timing": "bi-alarm",
"Numbers": "bi-calculator"
};
const sec = (title, html) => {
if (!html) return "";
const icon = SEC_ICON[title];
return `<section class="mb-4"><h6 class="text-uppercase text-secondary small fw-bold mb-2 d-flex align-items-center gap-2">${icon ? `<i class="bi ${icon} text-primary"></i>` : ""}<span>${esc(title)}</span><span class="flex-fill border-bottom"></span></h6>${html}</section>`;
};
function indTable(src) {
const rows = IND_ORDER.map(name => {
const i = src?.[name];
const k = `<td class="ind-k">${IND_SHORT[name]}</td>`;
if (!i) return `<tr>${k}<td class="text-secondary">— absent</td>
<td class="num text-end">—</td><td class="num text-end">—</td><td class="num text-end">—</td>
<td class="text-secondary small">no signal this scan</td></tr>`;
const w = i.weight == null ? null : +i.weight;
return `<tr>${k}
<td class="fw-bold ${sigClass(i.signal) === "buy" ? "text-success" : sigClass(i.signal) === "sell" ? "text-danger" : "text-secondary"}">${esc(sigLabel(i.signal))}</td>
<td class="num text-end">${i.value != null ? (+i.value).toFixed(2) : "—"}</td>
<td class="num text-end">${i.confidence != null ? Math.round(i.confidence) : "—"}</td>
<td class="num text-end">${w != null ? w.toFixed(1) : "—"}${w != null
? `<i class="wbar"><i style="width:${Math.min(100, w / 2 * 100)}%"></i></i>` : ""}</td>
<td class="text-secondary small">${esc(i.description || "")}</td></tr>`;
}).join("");
return `<div class="detail-table"><table class="table table-sm d-table"><thead><tr>
<th>Indicator</th><th>Signal</th><th class="text-end">Value</th><th class="text-end">Conf</th>
<th class="text-end">Weight</th><th>Reading</th></tr></thead><tbody>${rows}</tbody></table></div>`;
}
function ladder(levels, price) {
if (!levels) return "";
const atr = levels.atr;
const rows = Object.keys(LEVEL_LABEL)
.filter(k => levels[k] != null)
.map(k => ({ label: LEVEL_LABEL[k], v: +levels[k] }));
if (!rows.length) return "";
if (price != null) rows.push({ here: true, v: +price });
rows.sort((a, b) => b.v - a.v);
const body = rows.map(row => {
if (row.here) return `<tr class="table-primary fw-bold"><td><i class="bi bi-geo-alt-fill me-1"></i>current price</td>
<td class="num text-end">${fmt$(row.v)}</td><td class="num text-end">—</td><td class="small text-secondary">spot</td></tr>`;
const d = price != null ? row.v - price : null;
const cls = d == null ? "" : d > 0 ? "text-danger" : "text-success";
const icon = d == null ? "" : d > 0 ? "bi-arrow-up-short" : "bi-arrow-down-short";
return `<tr>
<td class="text-secondary">${esc(row.label)}</td>
<td class="num text-end ${cls}">${fmt$(row.v)}</td>
<td class="num text-end ${cls}">${d == null ? "" : signed(d) + (atr ? ` · ${signed(d / atr, 2)} ATR` : "")}</td>
<td class="small ${cls}">${d == null ? "" : `<i class="bi ${icon}"></i>${d > 0 ? "resistance" : "support"}`}</td></tr>`;
}).join("");
return `<div class="detail-table"><table class="table table-sm ladder"><thead><tr><th>Level</th><th class="text-end">Price</th>
<th class="text-end">vs. spot</th><th>Role</th></tr></thead><tbody>${body}</tbody></table></div>`;
}
function analysisBlock(a, price) {
if (!a) return "";
const pats = a.pattern_signals?.length
? `<div class="d-flex flex-wrap gap-1 mb-2">${a.pattern_signals.map(p =>
`<span class="badge text-bg-primary bg-opacity-10">${esc(p)}</span>`).join("")}</div>` : "";
const atr = a.atr_context
? `<div class="small text-secondary mb-2">${esc(a.atr_context.description ||
"ATR " + (+a.atr_context.value).toFixed(2))}</div>` : "";
const lv = ladder(a.key_levels, price);
return indTable(a.indicators) + atr + pats +
(lv ? `<div class="small text-secondary fw-semibold mt-2 mb-1">Key levels</div>${lv}` : "");
}
function listBlock(r) {
const group = (title, arr, cls, icon) => arr?.length
? `<div class="small text-secondary fw-semibold mt-2 mb-1">${title} (${arr.length})</div>
<div class="small">${arr.map(t => `<div class="${cls}"><i class="bi ${icon} me-1"></i>${esc(t)}</div>`).join("")}</div>` : "";
// Violent-move watch: display-only advisory line, stamped post-hoc by
// the publisher. Empty unless the feature is enabled.
const watch = r.watch_line
? `<div class="small text-warning fw-semibold mt-2">${esc(r.watch_line)}</div>`
: "";
return watch
+ group("Warnings", r.warnings, "text-warning", "bi-exclamation-triangle-fill")
+ group("Bullish", r.bullish_reasons, "text-success", "bi-arrow-up-circle-fill")
+ group("Bearish", r.bearish_reasons, "text-danger", "bi-arrow-down-circle-fill");
}
function detailHTML(tf) {
const entry = snapshot?.timeframes?.[tf] || {};
const r = entry.report;
if (!r) return `<div class="text-center text-secondary py-4">${entry.status === "error"
? `Scan error — ${esc(entry.error || "unknown")}`
: "Warming up — no completed scan for this timeframe yet."}</div>`;
const pa = r.primary_analysis;
const price = r.current_price;
const cls = sigClass(r.signal);
const textCls = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
const borderCls = cls === "buy" ? "border-start border-success border-4"
: cls === "sell" ? "border-start border-danger border-4"
: "border-start border-secondary border-4";
let out = `<div class="card ${borderCls} mb-3"><div class="card-body">
<div class="fw-bold fs-5 ${textCls}">${esc(r.action || sigLabel(r.signal))}</div>
${r.news_description || r.event_description
? `<div class="small text-secondary">${esc([r.event_description, r.news_description].filter(Boolean).join(" · "))}</div>`
: ""}
</div></div>`;
const ns = r.news_sentiment;
out += sec("At a glance", `<div class="row row-cols-2 row-cols-md-3 row-cols-lg-4 g-2">
${tile("Signal", esc(sigLabel(r.signal)), textCls)}
${tile("Score", signed(r.score, 1), textCls)}
${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
${tile("Quality", esc(r.quality || "—"))}
${tile("Regime", esc(r.market_regime || "—"))}
${tile("Volatility", esc(r.volatility_regime || "—"))}
${tile("Event risk", esc((r.event_risk || "—").toUpperCase()),
r.event_risk === "clear" ? "text-success" : r.event_risk ? "text-warning" : "")}
${tile("News sentiment", ns != null ? signed(ns, 2) : "—",
ns > 0.05 ? "text-success" : ns < -0.05 ? "text-danger" : "")}
${tile("MTF", r.mtf_agreement ? "aligned" : "mixed", r.mtf_agreement ? "text-success" : "")}
${tile("Primary score", signed(r.primary_score, 1), sigClass(r.primary_signal) === "buy" ? "text-success" : sigClass(r.primary_signal) === "sell" ? "text-danger" : "")}
${tile("Exhaustion", esc(r.exhaustion_count ?? "—"), r.exhaustion_count ? "text-warning" : "")}
</div>`);
out += sec("Trade setup", r.trade_setup
? setupCard(r.trade_setup)
: `<div class="text-secondary small">No setup published for this timeframe — the engine only emits entry/stop/target once a signal clears its quality gate.</div>`);
out += sec("Why this signal", listBlock(r) || `<div class="text-secondary small">No reasons recorded on this scan.</div>`);
out += sec(`${TF_LABEL[tf]} indicators (primary)`,
analysisBlock(pa, price) || indTable(r.indicators) +
(r.key_levels ? `<div class="small text-secondary fw-semibold mt-2 mb-1">Key levels</div>${ladder(r.key_levels, price)}` : ""));
const mtf = Object.entries(r.mtf_signals || {});
if (mtf.length) {
const chips = mtf.map(([k, sig]) => {
const s = r.mtf_scores?.[k];
const sc = sigClass(sig);
return `<span class="badge ${badgeCls(sc)}">${esc(TF_LABEL[k] || k)}
<span class="${sc === "buy" ? "text-success" : sc === "sell" ? "text-danger" : "text-secondary"}">${esc(sigLabel(sig))}</span>
<span class="num">${s != null ? signed(s, 1) : ""}</span></span>`;
}).join("");
const confs = (r.confirmation_analyses || []).filter(Boolean).map(a => {
const lbl = TF_LABEL[a.timeframe] || a.timeframe;
const sc = sigClass(a.composite_signal);
return `<details data-dkey="conf-${esc(a.timeframe)}">
<summary><strong>${esc(lbl)}</strong>
<span class="${sc === "buy" ? "text-success" : sc === "sell" ? "text-danger" : "text-secondary"} fw-bold">${esc(sigLabel(a.composite_signal))}</span>
<span class="text-secondary small">${signed(a.composite_score, 1)} · conf ${a.confidence != null ? Math.round(a.confidence) : "—"} · ${esc(a.market_regime || "")}</span></summary>
${analysisBlock(a, price)}</details>`;
}).join("");
out += sec("Multi-timeframe confirmation",
`<div class="d-flex flex-wrap gap-2 align-items-center mb-2">${chips}
<span class="fw-bold ${r.mtf_agreement ? "text-success" : "text-secondary"}">${r.mtf_agreement ? "✓ aligned" : "✗ mixed"}</span></div>${confs}`);
}
if (r.news_summary || r.news_description) {
out += sec("News context", `<div class="text-secondary small">
${r.news_description ? `<strong>${esc(r.news_description)}</strong>${ns != null ? ` (${signed(ns, 2)})` : ""}<br>` : ""}
${esc(r.news_summary || "")}</div>`);
}
if (r.panel_verdict && r.panel_verdict_source === "mean") {
// v2 panel: a probability anchored to the setup class's base rate,
// one reason each way per persona. Advisory only; no verdict colour.
const base = (r.panel_context && r.panel_context.base_rate) || {};
const usual = base.win_rate != null ? ` (usual for this kind of setup: ${Math.round(base.win_rate)}%)` : "";
const takes = (r.panel_takes || []).map(t =>
`<tr><td class="ind-k">${esc(t.persona)}</td>
<td class="num text-end">${t.p_win != null ? Math.round(t.p_win) + "%" : "—"}</td>
<td class="small text-secondary">${esc(t.reason_for || "")}</td>
<td class="small text-secondary">${esc(t.reason_against || "")}</td></tr>`).join("");
out += sec("AI estimate (advisory, still being tested)", `<div class="text-secondary small mb-2">
About a <strong>${Math.round(r.panel_probability || 0)}%</strong> chance this trade pays off${esc(usual)}.<br>
${esc(r.panel_rationale || "")}</div>
${takes ? `<div class="detail-table"><table class="table table-sm d-table">
<thead><tr><th></th><th class="text-end">chance</th><th>for</th><th>against</th></tr></thead>
<tbody>${takes}</tbody></table></div>` : ""}`);
} else if (r.panel_verdict) {
const vcls = r.panel_verdict === "AGREE" ? "text-success"
: r.panel_verdict === "OPPOSE" ? "text-danger" : "text-secondary";
const takes = (r.panel_takes || []).map(t =>
`<tr><td class="ind-k">${esc(t.persona)}</td>
<td>${esc(t.stance)}</td>
<td class="num text-end">${t.confidence != null ? Math.round(t.confidence) : "—"}</td>
<td class="small text-secondary">${esc(t.note || "")}</td></tr>`).join("");
out += sec("AI panel (advisory)", `<div class="text-secondary small mb-2">
<strong class="${vcls}">${esc(r.panel_verdict)}</strong> ·
conf ${Math.round(r.panel_confidence || 0)}%<br>
${esc(r.panel_rationale || "")}</div>
${takes ? `<div class="detail-table"><table class="table table-sm d-table"><tbody>${takes}</tbody></table></div>` : ""}`);
}
out += sec("Scan detail", `<div class="detail-table"><table class="table table-sm d-table"><tbody>
<tr><td class="ind-k">Instrument</td><td colspan="5">${esc(r.display_name || r.symbol || "—")}</td></tr>
<tr><td class="ind-k">Candle</td><td colspan="5" class="num">${esc(r.timestamp || "—")}</td></tr>
<tr><td class="ind-k">Scanned</td><td colspan="5">${esc(entry.scanned_at || "—")}
(${fmtAge(entry.scanned_at)}${entry.scan_seconds ? `, took ${entry.scan_seconds}s` : ""})</td></tr>
<tr><td class="ind-k">Status</td><td colspan="5">${esc(entry.status || "—")}${entry.error
? ` — <span class="text-danger">${esc(entry.error)}</span>
${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}</td></tr>
</tbody></table></div>`);
return out;
}
function renderDetail() {
if (!detailTf || !tfModalOpen) return;
const entry = snapshot?.timeframes?.[detailTf] || {};
const r = entry.report;
const openKeys = new Set([...document.querySelectorAll("#detail-body details[open][data-dkey]")]
.map(d => d.dataset.dkey));
const scroll = $("detail-body").scrollTop;
$("detail-title").textContent = TF_LABEL[detailTf] || detailTf;
$("detail-price").textContent = r?.current_price ? fmt$(r.current_price) : "—";
$("detail-sub").innerHTML = `${snapshot?.symbol ? esc(snapshot.symbol) + " · " : ""}closes in
<span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[detailTf]}">--:--</span>`;
$("detail-body").innerHTML = detailHTML(detailTf);
document.querySelectorAll("#detail-body details[data-dkey]").forEach(d => {
if (openKeys.has(d.dataset.dkey)) d.open = true;
});
$("detail-body").scrollTop = scroll;
tick();
}
function openDetail(tf) {
if (!TF_LABEL[tf]) return;
detailTf = tf;
tfModal.show();
renderDetail();
$("detail-body").scrollTop = 0;
$("detail-close").focus();
}
$("detail-close")?.addEventListener("click", () => tfModal.hide());
/* ── calendar ─────────────────────────────────────────────────── */
function renderCalendar() {
const body = $("cal-body");
if (!news) return;
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local time";
$("calendar-note").textContent = `times local (${zone}) · USD events${news.generated_at ? " · updated " + fmtAge(news.generated_at) : ""}`;
const hiOnly = $("hi-only").checked;
let events = (news.events || []);
if (hiOnly) events = events.filter(e => e.impact === "high");
if (!events.length) {
body.innerHTML = `<tr><td colspan="7" class="text-secondary text-center py-3">${
news.calendar_available ? "No matching events this week." : "Calendar unavailable — source could not be reached."}</td></tr>`;
return;
}
const now = nowServer();
let nextUpMarked = false, lastDay = "", rows = "";
for (const e of events) {
const t = Date.parse(e.time) / 1000;
const d = new Date(t * 1000);
const day = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
if (day !== lastDay) {
rows += `<tr class="table-group-divider"><td colspan="7" class="text-primary small fw-bold text-uppercase">${esc(day)}</td></tr>`;
lastDay = day;
}
const past = t < now;
const nextUp = !past && !nextUpMarked && (nextUpMarked = true);
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
const surp = e.surprise
? `<span class="${e.surprise === "positive" ? "text-success" : e.surprise === "negative" ? "text-danger" : "text-secondary"}" title="actual ${e.surprise === "positive" ? "above" : e.surprise === "negative" ? "below" : "matches"} forecast (numeric only — not gold direction)">${e.surprise === "positive" ? "▲" : e.surprise === "negative" ? "▼" : "="}</span>` : "";
rows += `<tr class="${past ? "text-secondary" : ""}${nextUp ? " cal-next-up" : ""}">
<td class="num text-nowrap">${esc(time)}</td>
<td class="text-secondary">${esc(e.currency)}</td>
<td><span class="badge ${e.impact === "high" ? "text-bg-danger" : e.impact === "medium" ? "text-bg-warning" : "text-bg-secondary"}">${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span></td>
<td><button type="button" class="cal-event" data-ev-key="${esc(evKey(e))}" aria-haspopup="dialog">${esc(e.event)}</button></td>
<td class="num text-end">${esc(e.forecast ?? "—")}</td>
<td class="num text-end">${esc(e.previous ?? "—")}</td>
<td class="num text-end">${esc(e.actual ?? "—")} ${surp}</td></tr>`;
}
body.innerHTML = rows;
}
/* ── newswire ─────────────────────────────────────────────────── */
function renderWireTotal() {
const box = $("wire-total");
const scored = (news?.headlines || []).filter(h => typeof h.score === "number");
if (!scored.length) { box.classList.add("d-none"); return; }
box.classList.remove("d-none");
const sum = scored.reduce((a, h) => a + h.score, 0);
const mean = sum / scored.length;
const up = scored.filter(h => h.score > 0.05).length;
const dn = scored.filter(h => h.score < -0.05).length;
const cls = mean > 0.05 ? "text-success" : mean < -0.05 ? "text-danger" : "text-secondary";
const dir = mean > 0.05 ? "BULLISH" : mean < -0.05 ? "BEARISH" : "NEUTRAL";
const wrapCls = mean > 0.05 ? "bg-success-subtle border-success-subtle"
: mean < -0.05 ? "bg-danger-subtle border-danger-subtle"
: "bg-body-tertiary";
box.className = `card mb-3 ${wrapCls}`;
box.innerHTML = `<div class="card-body p-2">
<div class="d-flex align-items-baseline gap-2 flex-wrap">
<span class="small text-secondary text-uppercase fw-bold">Total news score</span>
<span class="display-6 fw-bold ${cls}">${signed(mean, 2)}</span>
<span class="fw-bold ${cls}">${dir}</span>
<span class="ms-auto text-end small text-secondary">${scored.length} headlines · sum ${signed(sum, 1)}<br>
${up} bullish · ${dn} bearish</span>
</div>
</div>`;
box.title = `Mean sentiment of all ${scored.length} scored headlines (−1…+1). Sum ${signed(sum, 1)}. Only headlines from the last 4 hours that lean one way are published (neutral ones are removed before scoring).`;
}
function renderWire() {
if (!news) return;
const items = (news.headlines || []).slice(0, 40);
$("wire-note").textContent = news.generated_at ? `updated ${fmtAge(news.generated_at)}` : "";
renderWireTotal();
if (!items.length) {
$("wire").innerHTML = `<div class="text-secondary small py-2">No gold or dollar headlines in the last 4 hours.</div>`;
return;
}
const item = h => {
const s = h.score;
const cls = s == null || Math.abs(s) < 0.05 ? "zero" : s > 0 ? "pos" : "neg";
const scoreCls = cls === "pos" ? "bg-success-subtle text-success"
: cls === "neg" ? "bg-danger-subtle text-danger"
: "bg-body-tertiary text-secondary";
const scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
let host = "";
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
const content = `<span class="wire-score ${scoreCls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
<span class="small text-secondary flex-fill">${esc(h.headline)}</span>
<span class="small text-secondary text-nowrap">${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span>`;
if (!h.id) return `<div class="wire-item gap-2 align-items-baseline py-1 px-1">${content}</div>`;
return `<button type="button" class="wire-item gap-2 align-items-baseline py-1 px-1" data-news-id="${h.id}" aria-haspopup="dialog">
${content}
</button>`;
};
const head = items.slice(0, 12).map(item).join("");
const rest = items.slice(12);
const moreOpen = $("wire")?.querySelector("details.more")?.open ?? false;
$("wire").innerHTML = head + (rest.length
? `<details class="more mt-1"${moreOpen ? " open" : ""}><summary class="small text-secondary">${rest.length} more headlines…</summary>
<div class="mt-1">${rest.map(item).join("")}</div></details>` : "");
}
/* ── news / calendar detail dialog ────────────────────────────── */
const evKey = e => `${e.time}|${e.currency}|${e.event}`;
const findHeadline = id => (news?.headlines || []).find(h => h.id === id);
const findEvent = key => (news?.events || []).find(e => evKey(e) === key);
const AI_ERR_MSG = {
unknown_id: "This headline has left the live feed — no summary available.",
cap_exceeded: "Daily AI budget reached — try again tomorrow.",
no_key: "AI summaries are not configured on this server."
};
async function fetchAiSummary(id) {
aiPending.add(id);
try {
const res = await fetch("news_summary.php?id=" + encodeURIComponent(id));
const data = await res.json().catch(() => null);
if (res.ok && data?.summary) aiMemo.set(id, data);
else aiErr.set(id, data?.error || `http_${res.status}`);
} catch {
aiErr.set(id, "network");
} finally {
aiPending.delete(id);
if (ndState?.kind === "wire" && ndState.id === id) renderNewsDlg();
}
}
function scoreTiles(h) {
const cls = v => v > 0.05 ? "text-success" : v < -0.05 ? "text-danger" : "text-secondary";
let tiles = tile("Overall sentiment", signed(h.score, 2), cls(h.score));
const xau = h.score_by_currency?.XAU;
if (xau != null) tiles += tile("Gold (XAU) effect", signed(xau, 2), cls(xau));
for (const [cur, v] of Object.entries(h.score_by_currency || {})) {
if (cur !== "XAU") tiles += tile(cur, signed(v, 2), cls(v));
}
let out = `<div class="row row-cols-2 row-cols-md-3 g-2">${tiles}</div>`;
if (h.keywords?.length) {
out += `<div class="small text-secondary mt-2">Matched keywords: ${esc(h.keywords.join(", "))}</div>`;
}
out += `<div class="small text-secondary">Scores range −1 (bearish) to +1 (bullish); the gold score flips monetary-policy keywords (hawkish = dollar-bullish, gold-bearish).</div>`;
return out;
}
function aiSection(id) {
if (aiMemo.has(id)) {
const m = aiMemo.get(id);
return `<div class="text-secondary small">${esc(m.summary)}</div>` + (m.generated_at
? `<div class="small text-secondary">generated ${fmtAge(m.generated_at)}</div>` : "");
}
if (aiPending.has(id)) return `<div class="text-secondary small">Generating gold-impact summary…</div>`;
if (aiErr.has(id)) {
return `<div class="text-secondary small">${esc(AI_ERR_MSG[aiErr.get(id)] || "AI summary unavailable right now.")}</div>`;
}
return `<div class="text-secondary small">Generating gold-impact summary…</div>`;
}
function wireDlgHTML() {
const fresh = findHeadline(ndState.id);
const h = fresh || ndState.snap;
if (!h) return "";
let host = "";
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
const when = h.timestamp
? `${new Date(h.timestamp).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })} · ${fmtAge(h.timestamp)}` : "";
let out = `<div class="fs-5 fw-semibold mb-1">${esc(h.headline)}</div>
<div class="small text-secondary mb-3">${esc(h.publisher || host)}${when ? " · " + when : ""}</div>`;
if (!fresh) out += `<div class="small text-secondary mb-2">No longer in the live feed — showing saved copy.</div>`;
out += sec("Score", scoreTiles(h));
if (h.description) out += sec("From the source", `<div class="text-secondary small">${esc(h.description)}</div>`);
out += sec("AI gold-impact summary", aiSection(ndState.id));
if (h.link && /^https?:\/\//.test(h.link)) {
out += `<a class="btn btn-outline-primary" href="${esc(h.link)}" target="_blank" rel="noopener noreferrer">Read full article ↗</a>`;
}
return out;
}
function calDlgHTML() {
const fresh = findEvent(ndState.key);
const e = fresh || ndState.snap;
if (!e) return "";
const t = Date.parse(e.time) / 1000;
const when = new Date(e.time).toLocaleString(undefined, { dateStyle: "full", timeStyle: "short" });
const impact = `<span class="badge ${e.impact === "high" ? "text-bg-danger" : e.impact === "medium" ? "text-bg-warning" : "text-bg-secondary"}">${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span>`;
let out = `<div class="fs-5 fw-semibold mb-1">${esc(e.event)}</div>
<div class="small text-secondary mb-3">${esc(e.currency)} · ${when}</div>`;
const timing = t > nowServer()
? `releases in <span class="num" data-close="${t}">--:--</span>`
: `released ${fmtAge(e.time)}`;
out += sec("Timing", `<div class="text-secondary small">${impact} impact · ${timing}</div>`);
const surpTxt = { positive: "actual above forecast", negative: "actual below forecast", inline: "actual matches forecast" }[e.surprise];
out += sec("Numbers", `<div class="row row-cols-3 g-2">${
tile("Forecast", esc(e.forecast ?? "—")) +
tile("Previous", esc(e.previous ?? "—")) +
tile("Actual", esc(e.actual ?? "—"), e.surprise === "positive" ? "text-success" : e.surprise === "negative" ? "text-danger" : "")
}</div>` + (surpTxt
? `<div class="small text-secondary mt-2">${surpTxt} (numeric only — not gold direction)</div>` : ""));
return out;
}
function renderNewsDlg() {
if (!ndState || !newsModalOpen) return;
const scroll = $("nd-body").scrollTop;
$("nd-title").textContent = ndState.kind === "wire" ? "Newswire" : "Economic calendar";
$("nd-body").innerHTML = ndState.kind === "wire" ? wireDlgHTML() : calDlgHTML();
$("nd-body").scrollTop = scroll;
tick();
}
function openNewsDetail(id) {
const h = findHeadline(id);
if (!h) return;
ndState = { kind: "wire", id, snap: h };
aiErr.delete(id);
newsModal.show();
if (!aiMemo.has(id) && !aiPending.has(id)) fetchAiSummary(id);
renderNewsDlg();
$("nd-body").scrollTop = 0;
$("nd-close").focus();
}
function openCalDetail(key) {
const e = findEvent(key);
if (!e) return;
ndState = { kind: "cal", key, snap: e };
newsModal.show();
renderNewsDlg();
$("nd-body").scrollTop = 0;
$("nd-close").focus();
}
$("nd-close")?.addEventListener("click", () => newsModal.hide());
/* ── polling ──────────────────────────────────────────────────── */
async function fetchJSON(name) {
const res = await fetch(name, { cache: "no-cache" });
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
const dateHdr = res.headers.get("Date");
const text = await res.text();
return { text, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null };
}
function tfFingerprint(entry) {
if (!entry) return "";
return JSON.stringify({
status: entry.status,
scanned_at: entry.scanned_at,
error: entry.error,
error_at: entry.error_at,
report: entry.report
});
}
function scheduleRefresh(delay) {
clearTimeout(refetchTimer);
refetchTimer = setTimeout(() => {
refetchTimer = null;
poll();
}, Math.max(0, delay));
}
function scheduleFallbackRefresh() {
clearTimeout(fallbackTimer);
fallbackTimer = setTimeout(() => {
fallbackTimer = null;
poll();
scheduleFallbackRefresh();
}, FALLBACK_POLL_MS);
}
function scheduleBoundaryRefresh() {
if (!snapshot) return;
const nextScan = Math.min(...TF_ORDER.map(tf =>
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
if (!isFinite(nextScan)) return;
const delay = (nextScan - nowServer() + 2) * 1000;
scheduleRefresh(delay > 0 ? delay : RETRY_MS);
}
async function poll() {
if (pollInFlight) {
pollQueued = true;
return;
}
pollInFlight = true;
try {
const [snap, nw] = await Promise.all([
fetchJSON("snapshot.json"),
fetchJSON("news.json").catch(() => null)
]);
const priorSnapshot = snapshot;
let snapChanged = false;
let selectedChanged = false;
let cardsChanged = false;
let detailChanged = false;
if (snap.text !== lastSnapText) {
const incoming = JSON.parse(snap.text);
if (!priorSnapshot || incoming.server_time_epoch >= priorSnapshot.server_time_epoch) {
snapChanged = true;
selectedChanged = tfFingerprint(priorSnapshot?.timeframes?.[selectedTf]) !==
tfFingerprint(incoming.timeframes?.[selectedTf]);
// renderCards() redraws the selector for EVERY timeframe, so it
// must react to any of them changing — not just the selected one.
// Gating it on selectedChanged left the other buttons showing a
// stale arrow/score (up to 4 h on H4) while renderHero's rail,
// which redraws unconditionally, already showed the new value.
cardsChanged = TF_ORDER.some(tf =>
tfFingerprint(priorSnapshot?.timeframes?.[tf]) !==
tfFingerprint(incoming.timeframes?.[tf]));
detailChanged = detailTf != null && tfFingerprint(priorSnapshot?.timeframes?.[detailTf]) !==
tfFingerprint(incoming.timeframes?.[detailTf]);
snapshot = incoming;
lastSnapText = snap.text;
}
}
const newsChanged = nw != null && nw.text !== lastNewsText;
if (newsChanged) { news = JSON.parse(nw.text); lastNewsText = nw.text; }
clockOffset = (snap.serverNow ?? snapshot.server_time_epoch) - Date.now() / 1000;
lastPollOk = new Date();
$("banner-fail").classList.add("d-none");
if (snapChanged) {
renderHero();
if (cardsChanged) renderCards();
if (detailChanged) renderDetail();
if (selectedChanged) {
const signal = snapshot.timeframes[selectedTf]?.report?.signal;
if (signal) announce(`${TF_LABEL[selectedTf]} signal updated: ${sigLabel(signal)}.`);
}
}
if (newsChanged) {
renderCalendar();
renderWire();
renderNewsDlg();
}
if (snapChanged || newsChanged) renderContext();
tick();
scheduleBoundaryRefresh();
} catch (err) {
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
const b = $("banner-fail");
b.textContent = `Dashboard data is temporarily unavailable.${last} Retrying…`;
b.classList.remove("d-none");
scheduleRefresh(RETRY_MS);
} finally {
pollInFlight = false;
if (pollQueued) {
pollQueued = false;
poll();
}
}
}
function tick() {
if (!snapshot) return;
const now = nowServer();
document.querySelectorAll("[data-close]").forEach(el => {
const t = parseFloat(el.dataset.close);
if (!t) return;
let secs = t - now;
if (secs < 0) {
const interval = parseFloat(el.dataset.interval);
secs = interval > 0 ? ((secs % interval) + interval) % interval : 0;
}
el.textContent = fmtCountdown(secs);
});
const m5 = snapshot.timeframes["5min"]?.next_close_epoch;
if (m5) {
let secs = m5 - now;
if (secs < 0) secs = ((secs % TF_SECONDS["5min"]) + TF_SECONDS["5min"]) % TF_SECONDS["5min"];
$("hero-cd").textContent = fmtCountdown(secs);
}
const pubEpoch = snapshot.server_time_epoch;
if (pubEpoch) {
const age = now - pubEpoch;
const el = $("hero-updated");
const stale = !snapshot.market_closed && age > 900;
el.textContent = (stale ? "STALE — published " : "published ") +
fmtAge(new Date(pubEpoch * 1000).toISOString());
el.classList.toggle("text-warning", stale);
el.classList.toggle("fw-semibold", stale);
}
}
/* ── boot ─────────────────────────────────────────────────────── */
document.addEventListener("DOMContentLoaded", () => {
tfModal = new bootstrap.Modal($("tf-detail"));
newsModal = new bootstrap.Modal($("news-detail"));
const storedTf = localGet(TIMEFRAME_PREF);
if (TF_LABEL[storedTf]) selectedTf = storedTf;
$("hi-only").checked = localGet(HIGH_IMPACT_PREF) === "true";
$("tf-detail")?.addEventListener("shown.bs.modal", () => { tfModalOpen = true; renderDetail(); });
$("tf-detail")?.addEventListener("hidden.bs.modal", () => { tfModalOpen = false; detailTf = null; });
$("news-detail")?.addEventListener("shown.bs.modal", () => { newsModalOpen = true; renderNewsDlg(); });
$("news-detail")?.addEventListener("hidden.bs.modal", () => { newsModalOpen = false; ndState = null; });
const selectTf = tf => {
if (!TF_LABEL[tf]) return;
const changed = selectedTf !== tf;
selectedTf = tf;
localSet(TIMEFRAME_PREF, tf);
renderHero();
renderCards();
if (changed) announce(`${TF_LABEL[tf]} workspace selected.`);
};
$("hero-rail")?.addEventListener("click", e => {
const button = e.target.closest("[data-select-tf]");
if (button) selectTf(button.dataset.selectTf);
});
$("tf-grid")?.addEventListener("click", e => {
const detail = e.target.closest("[data-open-detail]");
if (detail) return openDetail(detail.dataset.openDetail);
const button = e.target.closest("[data-select-tf]");
if (button) selectTf(button.dataset.selectTf);
});
$("wire")?.addEventListener("click", e => {
const button = e.target.closest("[data-news-id]");
if (button) openNewsDetail(button.dataset.newsId);
});
$("cal-body")?.addEventListener("click", e => {
const button = e.target.closest("[data-ev-key]");
if (button) openCalDetail(button.dataset.evKey);
});
$("hi-only")?.addEventListener("change", () => {
localSet(HIGH_IMPACT_PREF, String($("hi-only").checked));
renderCalendar();
});
poll();
scheduleFallbackRefresh();
setInterval(tick, 1000);
});