From be5144bc6748616988089d2d3d1736bcade1b9af Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Thu, 13 Aug 2026 23:30:30 -0400 Subject: [PATCH] dashboard: redesign on Bootstrap 5, split page into external css/js Page now uses Bootstrap 5.3 color modes (data-bs-theme), Bootstrap Icons, and Bootstrap modals. Custom code shrinks to dashboard.css (app tokens, score bar, signal accents) and dashboard.js (rendering/polling logic, ported verbatim from the inline script). dashboard_publisher copies the new css/js into the docroot; all 381 tests still green. --- dashboard_publisher.py | 2 +- static/dashboard.css | 226 ++++++ static/dashboard.html | 1565 ++++------------------------------------ static/dashboard.js | 834 +++++++++++++++++++++ test_all.py | 2 +- 5 files changed, 1185 insertions(+), 1444 deletions(-) create mode 100644 static/dashboard.css create mode 100644 static/dashboard.js diff --git a/dashboard_publisher.py b/dashboard_publisher.py index d6f7541..f31adec 100644 --- a/dashboard_publisher.py +++ b/dashboard_publisher.py @@ -82,7 +82,7 @@ SUMMARY_PHP_FILE = "news_summary.php" # Repo files copied into the out dir at daemon start (git pull + restart # updates them). The PHP endpoint reads news.json in place, so it lives # next to the page. -STATIC_FILES = [PAGE_FILE, SUMMARY_PHP_FILE] +STATIC_FILES = [PAGE_FILE, "dashboard.css", "dashboard.js", SUMMARY_PHP_FILE] # ══════════════════════════════════════════════════════════════════ diff --git a/static/dashboard.css b/static/dashboard.css new file mode 100644 index 0000000..81995be --- /dev/null +++ b/static/dashboard.css @@ -0,0 +1,226 @@ +/* Gold Desk — XAU/USD dashboard styles + * + * Builds on Bootstrap 5.3 color modes (data-bs-theme). Only app-specific + * tokens and components live here; layout, typography, tables, badges, + * cards, alerts, modals, etc. come from Bootstrap. + */ + +:root { + --xau-font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; + --xau-font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Consolas, Menlo, monospace; +} + +html, body { + font-family: var(--xau-font-sans); + letter-spacing: -0.005em; + -webkit-font-smoothing: antialiased; +} + +.num, .xau-price, .xau-symbol { + font-family: var(--xau-font-mono); + font-variant-numeric: tabular-nums; +} + +.xau-symbol { + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--bs-primary); +} + +.xau-price { + line-height: 1; + color: var(--bs-primary-text-emphasis); +} + +.live-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--bs-success); + margin-right: 5px; + animation: pulse 2.4s ease-in-out infinite; +} + +.live-dot.closed { + background: var(--bs-warning); + animation: none; +} + +@keyframes pulse { + 50% { opacity: 0.35; } +} + +/* signal-coloured accents */ +.sig-buy { --xau-signal: var(--bs-success); } +.sig-sell { --xau-signal: var(--bs-danger); } +.sig-neutral { --xau-signal: var(--bs-secondary); } + +.border-signal { + border-left: 3px solid var(--xau-signal, var(--bs-secondary)) !important; +} + +.text-signal { + color: var(--xau-signal, var(--bs-secondary)) !important; +} + +/* timeframe cards */ +.tf-card { + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.tf-card:hover, .tf-card:focus-visible { + border-color: var(--bs-primary); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.15); +} + +.tf-name { + font-family: var(--xau-font-mono); + font-weight: 700; + font-size: 1.1rem; + color: var(--bs-primary); + letter-spacing: 0.05em; +} + +/* score bar: zero-centered diverging with midpoint tick */ +.scorebar { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.scorebar .track { + position: relative; + flex: 1; + height: 9px; + border-radius: 5px; + background: var(--bs-secondary-bg); + overflow: hidden; +} + +.scorebar .track::after { + content: ""; + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 2px; + background: var(--bs-secondary); + transform: translateX(-1px); +} + +.scorebar .fill { + position: absolute; + top: 0; + bottom: 0; + border-radius: 4px; + transition: width 0.5s ease; +} + +.scorebar .fill.pos { + left: 50%; + background: var(--bs-success); +} + +.scorebar .fill.neg { + right: 50%; + background: var(--bs-danger); +} + +.scorebar .val { + width: 58px; + text-align: right; + font-size: 0.95rem; + font-weight: 500; +} + +.scorebar .lbl { + font-size: 0.8rem; + color: var(--bs-secondary); + width: 44px; +} + +/* rail chips in hero */ +.rail-chip { + cursor: pointer; + font-family: var(--xau-font-mono); + font-size: 0.9rem; +} + +.rail-chip:hover { + border-color: var(--bs-primary) !important; +} + +/* detail dialog sections */ +.d-stat .k { + display: block; + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-secondary); + font-weight: 600; +} + +.d-stat .v { + font-size: 1.15rem; + font-weight: 600; +} + +.d-table { + font-size: 0.9rem; +} + +.d-table .ind-k { + font-family: var(--xau-font-mono); + font-weight: 600; + white-space: nowrap; +} + +.wbar { + display: block; + height: 4px; + border-radius: 2px; + background: var(--bs-secondary-bg); + margin-top: 3px; +} + +.wbar > i { + display: block; + height: 100%; + border-radius: 2px; + background: var(--bs-primary); +} + +/* news */ +.wire-item { + cursor: pointer; + border-radius: var(--bs-border-radius); +} + +.wire-item:hover { + background: rgba(var(--bs-primary-rgb), 0.08); +} + +.wire-score { + font-family: var(--xau-font-mono); + font-size: 0.85rem; + font-weight: 700; + padding: 2px 7px; + border-radius: 5px; + white-space: nowrap; +} + +/* calendar highlight for next upcoming event */ +.cal-next-up td:first-child { + box-shadow: inset 4px 0 0 var(--bs-primary); + padding-left: 0.75rem !important; +} + +/* reduced motion */ +@media (prefers-reduced-motion: reduce) { + .live-dot { animation: none; } + .scorebar .fill { transition: none; } + * { transition: none !important; } +} diff --git a/static/dashboard.html b/static/dashboard.html index 1307113..debf646 100644 --- a/static/dashboard.html +++ b/static/dashboard.html @@ -1,1465 +1,146 @@ - + - - Gold Desk — XAU/USD + + - + + -
-
-
XAU/USD
-
—
-
-
-
-
…
-
M5 close in --:--
-
updated —
-
- -
+
- - - - - -
Timeframes — select one for full detail
-
- - -
- — - — - - -
-
-
- - -
- News detail - -
-
-
- -
Fundamentals
-
-
-
-

Economic calendar

- times local · USD events - +
+
+
+
XAU/USD
+
—
+
+
+
+
…
+
M5 close in --:--
+
updated —
+
+
-
- - - - -
TimeCurImpactEventFcstPrevActual
Loading calendar…
+
+ + + + +
+
+
+
-
-

Newswire

- -
-
+ +
+ Timeframes + — select one for full detail +
+
+ +
Fundamentals
+
+
+
+
+
+
Economic calendar
+ times local · USD events +
+ + +
+
+
+ + + + + + + + + + + + + +
TimeCurImpactEventFcstPrevActual
Loading calendar…
+
+
+
+
+
+
+
+
+
Newswire
+ +
+
+
+
+
+
+
+ +
+ Signals are generated on closed candles only and refresh at candle boundaries + (M5/M15/H1/H4). Not financial advice — educational and research use only. +
+
-
- Signals are generated on closed candles only and refresh at candle boundaries - (M5/M15/H1/H4). Not financial advice — educational and research use only. -
- - + + diff --git a/static/dashboard.js b/static/dashboard.js new file mode 100644 index 0000000..9b866e9 --- /dev/null +++ b/static/dashboard.js @@ -0,0 +1,834 @@ +"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 POLL_MS = 5000; + +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 detailTf = null; +let ndState = null; +const aiMemo = new Map(); +const aiErr = new Map(); +const aiPending = new Set(); + +let tfModal = null; +let newsModal = null; + +const $ = id => document.getElementById(id); + +/* ── helpers ──────────────────────────────────────────────────── */ +const esc = s => String(s ?? "").replace(/[&<>"']/g, + c => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[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 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 ────────────────────────────────────────────────────── */ +(function () { + const KEY = "xau-theme"; + const sysDark = matchMedia("(prefers-color-scheme: dark)"); + const stored = () => localStorage.getItem(KEY); + const apply = theme => { + document.documentElement.setAttribute("data-bs-theme", theme); + const btn = $("theme-toggle"); + if (!btn) return; + const icon = btn.querySelector("i"); + if (theme === "dark") { + btn.title = "Switch to light mode"; + if (icon) icon.className = "bi bi-sun-fill"; + } else { + btn.title = "Switch to dark mode"; + if (icon) icon.className = "bi bi-moon-fill"; + } + }; + apply(stored() || (sysDark.matches ? "dark" : "light")); + sysDark.addEventListener("change", e => { if (!stored()) apply(e.matches ? "dark" : "light"); }); + const btn = $("theme-toggle"); + if (btn) btn.addEventListener("click", () => { + const next = document.documentElement.getAttribute("data-bs-theme") === "dark" ? "light" : "dark"; + localStorage.setItem(KEY, next); + apply(next); + }); +})(); + +/* ── 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 ` + ${TF_LABEL[tf]} + ${arrow} + ${esc(score)} + `; + }).join(""); + + const closed = snapshot.market_closed; + $("hero-status").innerHTML = + `${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 + ? `Market context: ${esc(news.llm_summary)}` + : `Market context: no summary available`; + + 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 += ` + ${cur} news ${v >= 0 ? "+" : ""}${v.toFixed(2)}`; + } + 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 += `event risk: ${risk.toUpperCase()}`; + $("ctx-chips").innerHTML = chips; +} + +/* ── timeframe cards ──────────────────────────────────────────── */ +function scoreBar(label, score) { + const v = Math.max(-100, Math.min(100, score ?? 0)); + const pct = Math.abs(v) / 2; + const cls = v >= 0 ? "pos" : "neg"; + return `
+ ${label} + + ${v >= 0 ? "+" : ""}${v.toFixed(1)} +
`; +} + +function setupCard(ts) { + if (!ts) return ""; + const dir = ts.direction === "BUY" ? "buy" : "sell"; + 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 `
+
+
+ ${ts.direction} + ${esc(ts.strategy || "")} + R:R ${(+ts.risk_reward).toFixed(1)} +
+ + + + + + +
Entry${fmt$(ts.entry)}TP1${fmt$(ts.take_profit_1)}
Stop${fmt$(ts.stop_loss)}TP2${fmt$(ts.take_profit_2)}
Trail${esc(trail)}
+
+
`; +} + +function renderCards() { + if (!snapshot) return; + $("tf-grid").innerHTML = TF_ORDER.map(tf => { + const entry = snapshot.timeframes[tf] || {}; + const r = entry.report; + const scls = r ? sigClass(r.signal) : "neutral"; + const open = `
`; + const foot = `
+ ${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""} + closes in --:-- +
`; + if (!r) { + return `${open} +
+ ${TF_LABEL[tf]} + details › +
+
${entry.status === "error" + ? `SCAN ERROR` + : "warming up…"}
+
`; + } + const pa = r.primary_analysis; + const qHi = r.quality === "A+" || r.quality === "A"; + const meta = [r.market_regime, r.volatility_regime && r.volatility_regime + " vol", + r.mtf_agreement ? "MTF aligned" : "MTF mixed"].filter(Boolean).join(" · "); + return `${open} +
+ ${TF_LABEL[tf]} + ${sigLabel(r.signal)} + ${r.quality ? `${r.quality}` : ""} + details › +
+ ${entry.status === "error" ? `STALE` : ""} + ${scoreBar("score", pa ? pa.composite_score : r.score)} +
${esc(meta)}
+ ${foot} +
`; + }).join(""); +} + +/* ── timeframe detail dialog ──────────────────────────────────── */ +const tile = (k, v, cls = "") => + `
+ ${esc(k)} + ${v} +
`; + +const sec = (title, html) => html ? `
${esc(title)}
${html}
` : ""; + +function indTable(src) { + const rows = IND_ORDER.map(name => { + const i = src?.[name]; + const k = `${IND_SHORT[name]}`; + if (!i) return `${k}— absent + ——— + no signal this scan`; + const w = i.weight == null ? null : +i.weight; + return `${k} + ${esc(sigLabel(i.signal))} + ${i.value != null ? (+i.value).toFixed(2) : "—"} + ${i.confidence != null ? Math.round(i.confidence) : "—"} + ${w != null ? w.toFixed(1) : "—"}${w != null + ? `` : ""} + ${esc(i.description || "")}`; + }).join(""); + return ` + + ${rows}
IndicatorSignalValueConfWeightReading
`; +} + +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 `current price + ${fmt$(row.v)}—spot`; + const d = price != null ? row.v - price : null; + return ` 0 ? "text-danger" : "text-success"}"> + ${esc(row.label)} + ${fmt$(row.v)} + ${d == null ? "" : signed(d) + (atr ? ` · ${signed(d / atr, 2)} ATR` : "")} + ${d == null ? "" : d > 0 ? "resistance" : "support"}`; + }).join(""); + return ` + ${body}
LevelPricevs. spotRole
`; +} + +function analysisBlock(a, price) { + if (!a) return ""; + const pats = a.pattern_signals?.length + ? `
${a.pattern_signals.map(p => + `${esc(p)}`).join("")}
` : ""; + const atr = a.atr_context + ? `
${esc(a.atr_context.description || + "ATR " + (+a.atr_context.value).toFixed(2))}
` : ""; + const lv = ladder(a.key_levels, price); + return indTable(a.indicators) + atr + pats + + (lv ? `
Key levels
${lv}` : ""); +} + +function listBlock(r) { + const group = (title, arr, cls, icon) => arr?.length + ? `
${title} (${arr.length})
+
${arr.map(t => `
${esc(t)}
`).join("")}
` : ""; + return 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 `
${entry.status === "error" + ? `Scan error — ${esc(entry.error || "unknown")}` + : "Warming up — no completed scan for this timeframe yet."}
`; + + 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 = `
+
${esc(r.action || sigLabel(r.signal))}
+ ${r.news_description || r.event_description + ? `
${esc([r.event_description, r.news_description].filter(Boolean).join(" · "))}
` + : ""} +
`; + + const ns = r.news_sentiment; + out += sec("At a glance", `
+ ${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" : "")} +
`); + + out += sec("Trade setup", r.trade_setup + ? setupCard(r.trade_setup) + : `
No setup published for this timeframe — the engine only emits entry/stop/target once a signal clears its quality gate.
`); + + out += sec("Why this signal", listBlock(r) || `
No reasons recorded on this scan.
`); + + out += sec(`${TF_LABEL[tf]} indicators (primary)`, + analysisBlock(pa, price) || indTable(r.indicators) + + (r.key_levels ? `
Key levels
${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 `${esc(TF_LABEL[k] || k)} + ${esc(sigLabel(sig))} + ${s != null ? signed(s, 1) : ""}`; + }).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 `
+ ${esc(lbl)} + ${esc(sigLabel(a.composite_signal))} + ${signed(a.composite_score, 1)} · conf ${a.confidence != null ? Math.round(a.confidence) : "—"} · ${esc(a.market_regime || "")} + ${analysisBlock(a, price)}
`; + }).join(""); + out += sec("Multi-timeframe confirmation", + `
${chips} + ${r.mtf_agreement ? "✓ aligned" : "✗ mixed"}
${confs}`); + } + + if (r.news_summary || r.news_description) { + out += sec("News context", `
+ ${r.news_description ? `${esc(r.news_description)}${ns != null ? ` (${signed(ns, 2)})` : ""}
` : ""} + ${esc(r.news_summary || "")}
`); + } + + 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 => + `${esc(t.persona)} + ${esc(t.stance)} + ${t.confidence != null ? Math.round(t.confidence) : "—"} + ${esc(t.note || "")}`).join(""); + out += sec("AI panel (advisory)", `
+ ${esc(r.panel_verdict)} · + conf ${Math.round(r.panel_confidence || 0)}%
+ ${esc(r.panel_rationale || "")}
+ ${takes ? `${takes}
` : ""}`); + } + + out += sec("Scan detail", ` + + + + +
Instrument${esc(r.display_name || r.symbol || "—")}
Candle${esc(r.timestamp || "—")}
Scanned${esc(entry.scanned_at || "—")} + (${fmtAge(entry.scanned_at)}${entry.scan_seconds ? `, took ${entry.scan_seconds}s` : ""})
Status${esc(entry.status || "—")}${entry.error + ? ` — ${esc(entry.error)} + ${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}
`); + return out; +} + +function renderDetail() { + if (!detailTf || !tfModal._isShown) 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 + --:--`; + $("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()); +document.getElementById("tf-detail")?.addEventListener("hidden.bs.modal", () => { detailTf = null; }); + +function wireOpeners(host) { + host.addEventListener("click", e => { + if (e.target.closest("details, summary, a, input, button")) return; + const el = e.target.closest("[data-tf]"); + if (el) openDetail(el.dataset.tf); + }); + host.addEventListener("keydown", e => { + if (e.key !== "Enter" && e.key !== " ") return; + const el = e.target.closest?.("[data-tf]"); + if (!el || e.target !== el) return; + e.preventDefault(); + openDetail(el.dataset.tf); + }); +} + +/* ── calendar ─────────────────────────────────────────────────── */ +function renderCalendar() { + const body = $("cal-body"); + if (!news) return; + const hiOnly = $("hi-only").checked; + let events = (news.events || []); + if (hiOnly) events = events.filter(e => e.impact === "high"); + if (!events.length) { + body.innerHTML = `${ + news.calendar_available ? "No matching events this week." : "Calendar unavailable — source could not be reached."}`; + 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 += `${esc(day)}`; + 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 + ? `${e.surprise === "positive" ? "▲" : e.surprise === "negative" ? "▼" : "="}` : ""; + rows += ` + ${esc(time)} + ${esc(e.currency)} + ${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)} + ${esc(e.event)} + ${esc(e.forecast ?? "—")} + ${esc(e.previous ?? "—")} + ${esc(e.actual ?? "—")} ${surp}`; + } + 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 = `
+
+ Total news score + ${signed(mean, 2)} + ${dir} + ${scored.length} headlines · sum ${signed(sum, 1)}
+ ${up} bullish · ${dn} bearish · ${scored.length - up - dn} neutral
+
+
`; + box.title = `Mean sentiment of all ${scored.length} scored headlines (−1…+1). Sum ${signed(sum, 1)}. Bullish/bearish counted at |score| > 0.05.`; +} + +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 = `
No recent gold/dollar headlines.
`; + 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 open = h.id ? ` data-news-id="${h.id}" tabindex="0" role="button" aria-haspopup="dialog"` : ""; + return `
+ ${scoreTxt} + ${esc(h.headline)} + ${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""} +
`; + }; + 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 + ? `
${rest.length} more headlines… +
${rest.map(item).join("")}
` : ""); +} + +/* ── 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 = `
${tiles}
`; + if (h.keywords?.length) { + out += `
Matched keywords: ${esc(h.keywords.join(", "))}
`; + } + out += `
Scores range −1 (bearish) to +1 (bullish); the gold score flips monetary-policy keywords (hawkish = dollar-bullish, gold-bearish).
`; + return out; +} + +function aiSection(id) { + if (aiMemo.has(id)) { + const m = aiMemo.get(id); + return `
${esc(m.summary)}
` + (m.generated_at + ? `
generated ${fmtAge(m.generated_at)}
` : ""); + } + if (aiPending.has(id)) return `
Generating gold-impact summary…
`; + if (aiErr.has(id)) { + return `
${esc(AI_ERR_MSG[aiErr.get(id)] || "AI summary unavailable right now.")}
`; + } + return `
Generating gold-impact summary…
`; +} + +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 = `
${esc(h.headline)}
+
${esc(h.publisher || host)}${when ? " · " + when : ""}
`; + if (!fresh) out += `
No longer in the live feed — showing saved copy.
`; + out += sec("Score", scoreTiles(h)); + if (h.description) out += sec("From the source", `
${esc(h.description)}
`); + out += sec("AI gold-impact summary", aiSection(ndState.id)); + if (h.link && /^https?:\/\//.test(h.link)) { + out += `Read full article ↗`; + } + 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 = `${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}`; + let out = `
${esc(e.event)}
+
${esc(e.currency)} · ${when}
`; + const timing = t > nowServer() + ? `releases in --:--` + : `released ${fmtAge(e.time)}`; + out += sec("Timing", `
${impact} impact · ${timing}
`); + const surpTxt = { positive: "actual above forecast", negative: "actual below forecast", inline: "actual matches forecast" }[e.surprise]; + out += sec("Numbers", `
${ + 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" : "") + }
` + (surpTxt + ? `
${surpTxt} (numeric only — not gold direction)
` : "")); + return out; +} + +function renderNewsDlg() { + if (!ndState || !newsModal._isShown) 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()); +document.getElementById("news-detail")?.addEventListener("hidden.bs.modal", () => { ndState = null; }); + +function wireNewsOpeners(host, attr, open) { + host.addEventListener("click", e => { + const el = e.target.closest(`[${attr}]`); + if (el) open(el.getAttribute(attr)); + }); + host.addEventListener("keydown", e => { + if (e.key !== "Enter" && e.key !== " ") return; + const el = e.target.closest?.(`[${attr}]`); + if (!el || e.target !== el) return; + e.preventDefault(); + open(el.getAttribute(attr)); + }); +} + +/* ── 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 }; +} + +async function poll() { + try { + const [snap, nw] = await Promise.all([ + fetchJSON("snapshot.json"), + fetchJSON("news.json").catch(() => null) + ]); + const snapChanged = snap.text !== lastSnapText; + const newsChanged = nw != null && nw.text !== lastNewsText; + if (snapChanged) { snapshot = JSON.parse(snap.text); lastSnapText = snap.text; } + 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(); + renderCards(); + renderDetail(); + } + if (newsChanged) { + renderCalendar(); + renderWire(); + renderNewsDlg(); + } + if (snapChanged || newsChanged) renderContext(); + tick(); + + if (snapChanged) { + const nextScan = Math.min(...TF_ORDER.map(tf => + snapshot.timeframes[tf]?.next_scan_epoch || Infinity)); + if (isFinite(nextScan)) { + clearTimeout(refetchTimer); + const delay = (nextScan - nowServer() + 2) * 1000; + if (delay > 0) refetchTimer = setTimeout(poll, delay); + } + } + } catch (err) { + const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : ""; + const b = $("banner-fail"); + b.textContent = `Data feed unreachable (${err.message}).${last} Retrying…`; + b.classList.remove("d-none"); + } +} + +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")); + + // track modal shown state for renderDetail / renderNewsDlg guards + $("tf-detail")?.addEventListener("shown.bs.modal", () => { tfModal._isShown = true; }); + $("tf-detail")?.addEventListener("hidden.bs.modal", () => { tfModal._isShown = false; detailTf = null; }); + $("news-detail")?.addEventListener("shown.bs.modal", () => { newsModal._isShown = true; }); + $("news-detail")?.addEventListener("hidden.bs.modal", () => { newsModal._isShown = false; ndState = null; }); + + wireOpeners($("tf-grid")); + wireOpeners($("hero-rail")); + wireNewsOpeners($("wire"), "data-news-id", openNewsDetail); + wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail); + + $("hi-only")?.addEventListener("change", renderCalendar); + + poll(); + setInterval(poll, POLL_MS); + setInterval(tick, 1000); +}); diff --git a/test_all.py b/test_all.py index 8d909f8..c64727f 100644 --- a/test_all.py +++ b/test_all.py @@ -5670,7 +5670,7 @@ def _(): pub = dpub.DashboardPublisher(out_dir=tmp) pub.install_static() assert sorted(os.listdir(tmp)) == \ - ["dashboard.html", "news_summary.php"] # both installed, no .tmp + ["dashboard.css", "dashboard.html", "dashboard.js", "news_summary.php"] # all installed, no .tmp @test("dashboard: publish is atomic and leaves no temp file")