dashboard: redesign signal workspace and refresh flow

This commit is contained in:
Harwinder Sekhon
2026-08-18 14:54:47 -04:00
parent 1f632eaa6a
commit 71eb703355
6 changed files with 492 additions and 300 deletions
+34 -15
View File
@@ -82,8 +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, "dashboard.css", "dashboard.js", "gauge.js",
SUMMARY_PHP_FILE]
STATIC_FILES = [PAGE_FILE, "dashboard.css", "dashboard.js", SUMMARY_PHP_FILE]
# ══════════════════════════════════════════════════════════════════
@@ -323,6 +322,7 @@ class DashboardPublisher:
def __init__(self, out_dir: str):
self.out_dir = out_dir
self.snapshots: Dict[str, dict] = {}
self._reports = {}
self.news_payload: Optional[dict] = None
self._dp = None
self._news_engine: Optional[NewsEngine] = None
@@ -359,13 +359,7 @@ class DashboardPublisher:
try:
report = produce_signal(XAUUSD, tf, self._provider(),
CONFIRMATIONS[tf])
# Advisory AI panel, post-hoc and display-only. Off unless
# config.json agent_panel.dashboard is true. The reuse-TTL
# cache + max_panels_per_day budget bound the spend against
# this process's own DeepSeek cap (a persisting M5 signal
# re-serves its cached verdict instead of re-running every
# bar); a fresh panel adds ≤~25 s before this TF publishes.
attach_panel(report, surface="dashboard")
self._reports[tf.value] = report
self.snapshots[tf.value] = {
"status": "ok",
"scanned_at": datetime.now(timezone.utc).isoformat(),
@@ -377,16 +371,41 @@ class DashboardPublisher:
logger.info(f"{tf.value}: {report.final_signal.name} "
f"q={report.signal_quality or '-'} "
f"({time.time() - started:.1f}s)")
except Exception as e:
except Exception:
# Keep the last good report; the page shows it with an
# error badge instead of going blank.
logger.exception(f"{tf.value} scan failed")
self._reports.pop(tf.value, None)
entry = self.snapshots.get(tf.value) or _empty_tf_entry()
entry["status"] = "error"
entry["error"] = f"{type(e).__name__}: {e}"
# The public snapshot carries a stable message; exception
# detail remains in dashboard.log for the operator.
entry["error"] = "Scan unavailable"
entry["error_at"] = datetime.now(timezone.utc).isoformat()
self.snapshots[tf.value] = entry
def attach_panels(self, tfs: List[Timeframe]) -> bool:
"""Add advisory panels after publishing the technical snapshot.
A panel is display-only and can take tens of seconds on a cold LLM
request. Keeping its report object locally lets the page receive the
candle-close signal first, then a second atomic snapshot only when a
panel produced a result.
"""
updated = False
for tf in tfs:
report = self._reports.get(tf.value)
if report is None:
continue
if not attach_panel(report, surface="dashboard"):
continue
entry = self.snapshots.get(tf.value)
if entry is None or entry.get("status") != "ok":
continue
entry["report"] = report_to_webapp_dict(report)
updated = True
return updated
def refresh_news(self) -> None:
try:
ne = self._news()
@@ -413,10 +432,10 @@ class DashboardPublisher:
def run_cycle(self, tfs: List[Timeframe]) -> None:
for tf in tfs: # sequential — see module docstring (cache dedup)
self.scan_tf(tf)
# Snapshot lands BEFORE the news refresh: when the news TTL has
# expired, refresh_news does FF + 4 RSS fetches (and possibly
# DeepSeek calls) — 20-30 s that must not keep fresh signals off
# the page.
# Technical signals land before any optional slow work. News and the
# advisory panel are display-only and may take 20-30 seconds cold.
self.publish_snapshot()
if self.attach_panels(tfs):
self.publish_snapshot()
self.refresh_news()
self.publish_news()
+113 -27
View File
@@ -96,17 +96,6 @@ html, body {
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;
@@ -115,6 +104,58 @@ html, body {
letter-spacing: 0.05em;
}
.tf-selector {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.tf-select {
min-width: 0;
padding: 0.7rem 0.8rem;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
text-align: left;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
.tf-select:hover {
border-color: rgba(var(--bs-primary-rgb), 0.65);
}
.tf-select[aria-pressed="true"] {
border-color: var(--xau-gold);
background: rgba(245, 179, 1, 0.09);
box-shadow: inset 0 0 0 1px rgba(245, 179, 1, 0.16);
}
.tf-select .tf-name {
font-size: 1rem;
}
.workspace-card {
overflow: hidden;
}
.workspace-signal {
font-family: var(--xau-font-mono);
font-size: clamp(1.55rem, 3vw, 2.35rem);
font-weight: 700;
letter-spacing: 0.03em;
line-height: 1;
}
.workspace-meta {
min-width: 0;
}
.workspace-reasons > div + div {
margin-top: 0.35rem;
}
/* score bar: zero-centered diverging with midpoint tick */
.scorebar {
display: flex;
@@ -122,24 +163,17 @@ html, body {
gap: 0.75rem;
}
.scorebar .track {
.scorebar .gauge-container {
position: relative;
flex: 1;
height: 9px;
min-width: 60px;
height: 10px;
border-radius: 5px;
background: var(--bs-secondary-bg);
overflow: hidden;
}
/* Chart.js gauge canvas host (see gauge.js) — thin bar in the row */
.scorebar .gauge-container {
position: relative;
flex: 1;
min-width: 60px;
height: 22px;
}
.scorebar .track::after {
.scorebar .gauge-container::after {
content: "";
position: absolute;
left: 50%;
@@ -155,7 +189,8 @@ html, body {
top: 0;
bottom: 0;
border-radius: 4px;
transition: width 0.5s ease;
width: var(--score-width);
transition: width 0.25s ease;
}
.scorebar .fill.pos {
@@ -183,13 +218,24 @@ html, body {
/* rail chips in hero */
.rail-chip {
cursor: pointer;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
font-family: var(--xau-font-mono);
font-size: 0.9rem;
line-height: 1.3;
padding: 0.3rem 0.5rem;
}
.rail-chip:hover {
border-color: var(--bs-primary) !important;
.rail-chip:hover, .rail-chip[aria-current="true"] {
border-color: var(--xau-gold);
}
.rail-chip:focus-visible, .tf-select:focus-visible, .wire-item:focus-visible,
.cal-event:focus-visible {
outline: 2px solid var(--xau-gold);
outline-offset: 2px;
}
/* detail dialog sections */
@@ -211,6 +257,10 @@ html, body {
font-size: 0.9rem;
}
.detail-table {
overflow-x: auto;
}
.d-table .ind-k {
font-family: var(--xau-font-mono);
font-weight: 600;
@@ -234,14 +284,36 @@ html, body {
/* news */
.wire-item {
cursor: pointer;
display: flex;
width: 100%;
border: 0;
border-radius: var(--bs-border-radius);
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
text-align: start;
}
.wire-item:hover {
background: rgba(var(--bs-primary-rgb), 0.08);
}
.cal-event {
display: block;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: inherit;
}
.cal-event:hover {
color: var(--bs-primary);
text-decoration: underline;
}
.wire-score {
font-family: var(--xau-font-mono);
font-size: 0.85rem;
@@ -263,3 +335,17 @@ html, body {
.scorebar .fill { transition: none; }
* { transition: none !important; }
}
@media (max-width: 575.98px) {
.tf-selector {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workspace-card .card-body {
padding: 1rem;
}
.rail-chip {
flex: 1 1 calc(50% - 0.5rem);
}
}
+25 -20
View File
@@ -11,7 +11,6 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap">
<link rel="stylesheet" href="dashboard.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
</head>
<body>
@@ -20,12 +19,12 @@
<header class="card mb-3" id="hero">
<div class="card-body d-flex align-items-center flex-wrap gap-3">
<div>
<div class="xau-symbol"><i class="bi bi-coin me-1"></i>XAU/USD</div>
<h1 class="xau-symbol mb-1"><i class="bi bi-coin me-1"></i>XAU/USD</h1>
<div class="display-5 fw-semibold xau-price" id="hero-price">—</div>
</div>
<div class="d-flex flex-wrap gap-2" id="hero-rail" aria-label="Signal by timeframe"></div>
<div class="d-flex flex-wrap gap-2" id="hero-rail" aria-label="Select timeframe"></div>
<div class="ms-auto text-end">
<div id="hero-status"><span class="live-dot"></span>…</div>
<div id="hero-status" role="status"><span class="live-dot"></span>…</div>
<div class="small text-secondary"><i class="bi bi-hourglass-split me-1"></i>M5 close in <span class="num" id="hero-cd">--:--</span></div>
<div class="small text-secondary" id="hero-updated">updated —</div>
</div>
@@ -36,31 +35,36 @@
<i class="bi bi-moon-fill me-2"></i>Market closed (weekend) — showing Friday's last state. Calendar and news stay live.
</div>
<div class="alert alert-danger d-none" id="banner-fail" role="alert"></div>
<div class="visually-hidden" id="dashboard-live" aria-live="polite"></div>
<section class="card mb-4 d-none" id="context">
<main>
<section class="card mb-4 d-none" id="context" aria-label="Market context">
<div class="card-body d-flex flex-wrap gap-3 align-items-start">
<div class="flex-grow-1" id="ctx-blurb" style="min-width:300px"></div>
<div class="flex-grow-1" id="ctx-blurb"><strong>Market context</strong></div>
<div class="d-flex flex-wrap gap-2" id="ctx-chips"></div>
</div>
</section>
<h6 class="text-uppercase text-secondary small fw-bold mb-3 d-flex align-items-center gap-2">
<i class="bi bi-clock-history text-primary"></i>
Timeframes
<span class="fw-normal text-body-secondary" style="text-transform:none">— select one for full detail</span>
</h6>
<div class="row g-3" id="tf-grid"></div>
<section class="mb-4" aria-labelledby="workspace-heading">
<h2 class="text-uppercase text-secondary small fw-bold mb-3 d-flex align-items-center gap-2" id="workspace-heading">
<i class="bi bi-crosshair text-primary"></i>
Signal workspace
<span class="fw-normal text-body-secondary" style="text-transform:none">— select a timeframe</span>
</h2>
<div id="tf-grid"></div>
</section>
<h6 class="text-uppercase text-secondary small fw-bold mb-3 mt-4 d-flex align-items-center gap-2">
<section aria-labelledby="fundamentals-heading">
<h2 class="text-uppercase text-secondary small fw-bold mb-3 mt-4 d-flex align-items-center gap-2" id="fundamentals-heading">
<i class="bi bi-globe2 text-primary"></i>Fundamentals
</h6>
</h2>
<div class="row g-3">
<div class="col-lg-8">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-center gap-3 mb-3 flex-wrap">
<h5 class="card-title mb-0"><i class="bi bi-calendar-event me-2 text-primary"></i>Economic calendar</h5>
<span class="text-secondary small">times local · USD events</span>
<h3 class="h5 card-title mb-0"><i class="bi bi-calendar-event me-2 text-primary"></i>Economic calendar</h3>
<span class="text-secondary small" id="calendar-note">times local · USD events</span>
<div class="form-check form-switch ms-auto">
<input class="form-check-input" type="checkbox" id="hi-only">
<label class="form-check-label small" for="hi-only">high impact only</label>
@@ -89,7 +93,7 @@
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-center gap-3 mb-3 flex-wrap">
<h5 class="card-title mb-0"><i class="bi bi-newspaper me-2 text-primary"></i>Newswire</h5>
<h3 class="h5 card-title mb-0"><i class="bi bi-newspaper me-2 text-primary"></i>Newswire</h3>
<span class="text-secondary small" id="wire-note"></span>
</div>
<div id="wire-total" class="d-none"></div>
@@ -98,6 +102,8 @@
</div>
</div>
</div>
</section>
</main>
<footer class="mt-4 pt-3 border-top text-secondary small">
<i class="bi bi-info-circle me-1"></i>
@@ -107,7 +113,7 @@
</div>
<div class="modal fade" id="tf-detail" tabindex="-1" aria-hidden="true">
<div class="modal fade" id="tf-detail" tabindex="-1" aria-labelledby="detail-title" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
@@ -125,7 +131,7 @@
</div>
</div>
<div class="modal fade" id="news-detail" tabindex="-1" aria-hidden="true">
<div class="modal fade" id="news-detail" tabindex="-1" aria-labelledby="nd-title" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
@@ -138,7 +144,6 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<script src="gauge.js"></script>
<script src="dashboard.js"></script>
</body>
</html>
+244 -120
View File
@@ -19,7 +19,10 @@ const ARROW = { STRONG_BUY: "⇈", BUY: "↑", NEUTRAL: "→", SELL: "↓", STRO
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 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",
@@ -34,6 +37,10 @@ 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;
@@ -43,6 +50,8 @@ const aiPending = new Set();
let tfModal = null;
let newsModal = null;
let tfModalOpen = false;
let newsModalOpen = false;
const $ = id => document.getElementById(id);
@@ -57,6 +66,19 @@ const sigClass = s => s?.includes("BUY") ? "buy" : s?.includes("SELL") ? "sell"
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));
@@ -101,13 +123,13 @@ function renderHero() {
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 `<span class="rail-chip badge border ${badgeCls(cls)}" data-tf="${tf}" tabindex="0"
role="button" aria-haspopup="dialog"
title="${esc(sigLabel(sig))} (score ${esc(score)}) — click for full ${TF_LABEL[tf]} detail">
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>
</span>`;
</button>`;
}).join("");
const closed = snapshot.market_closed;
@@ -151,9 +173,12 @@ function renderContext() {
/* ── 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" data-label="${label}" data-score="${v}"></div>
<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>`;
}
@@ -171,61 +196,105 @@ function setupCard(ts) {
<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>
<table class="table table-sm table-borderless mb-0">
<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>
<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>
</table>
<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;
$("tf-grid").innerHTML = TF_ORDER.map(tf => {
if (!TF_LABEL[selectedTf]) selectedTf = "5min";
const selector = TF_ORDER.map(tf => {
const entry = snapshot.timeframes[tf] || {};
const r = entry.report;
const scls = r ? sigClass(r.signal) : "neutral";
const open = `<article class="col-12 col-md-6 col-xl-3"><div class="card tf-card h-100 sig-${scls} border-signal" data-tf="${tf}" tabindex="0" role="button" aria-haspopup="dialog" aria-label="${TF_LABEL[tf]} timeframe — open full detail"><div class="card-body d-flex flex-column gap-2">`;
const foot = `<div class="mt-auto pt-2 border-top d-flex justify-content-between small text-secondary">
<span>${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""}</span>
<span>closes in <span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[tf]}">--:--</span></span>
</div>`;
if (!r) {
return `${open}
<div class="d-flex align-items-center gap-2 flex-wrap">
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="ms-auto small text-primary fw-semibold">details ›</span>
</div>
<div class="text-center text-secondary py-3">${entry.status === "error"
? `<span class="badge text-bg-danger" title="${esc(entry.error || "")}">SCAN ERROR</span>`
: "warming up…"}</div>
</div></div></article>`;
}
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}
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="tf-name">${TF_LABEL[tf]}</span>
<span class="badge ${badgeCls(scls)}">${sigLabel(r.signal)}</span>
${r.quality ? `<span class="badge ${qHi ? "text-bg-primary" : "text-bg-secondary"}" title="Signal quality grade">${r.quality}</span>` : ""}
<span class="ms-auto small text-primary fw-semibold">details ›</span>
</div>
${entry.status === "error" ? `<span class="badge text-bg-danger" title="${esc(entry.error || "")} — showing last good scan">STALE</span>` : ""}
${scoreBar("score", pa ? pa.composite_score : r.score)}
<div class="small text-secondary">${esc(meta)}</div>
${foot}
</div></div></article>`;
<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 bg-body-tertiary h-100"><div class="card-body p-2">
`<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>`;
@@ -266,9 +335,9 @@ function indTable(src) {
? `<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 `<table class="table table-sm d-table"><thead><tr>
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>`;
<th class="text-end">Weight</th><th>Reading</th></tr></thead><tbody>${rows}</tbody></table></div>`;
}
function ladder(levels, price) {
@@ -292,8 +361,8 @@ function ladder(levels, price) {
<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 `<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>`;
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) {
@@ -408,10 +477,10 @@ function detailHTML(tf) {
<strong class="${vcls}">${esc(r.panel_verdict)}</strong> ·
conf ${Math.round(r.panel_confidence || 0)}%<br>
${esc(r.panel_rationale || "")}</div>
${takes ? `<table class="table table-sm d-table"><tbody>${takes}</tbody></table>` : ""}`);
${takes ? `<div class="detail-table"><table class="table table-sm d-table"><tbody>${takes}</tbody></table></div>` : ""}`);
}
out += sec("Scan detail", `<table class="table table-sm d-table"><tbody>
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 || "—")}
@@ -419,12 +488,12 @@ function detailHTML(tf) {
<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>`);
</tbody></table></div>`);
return out;
}
function renderDetail() {
if (!detailTf || !tfModal._isShown) return;
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]")]
@@ -454,27 +523,13 @@ function openDetail(tf) {
}
$("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 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");
@@ -498,12 +553,11 @@ function renderCalendar() {
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" : ""}"
data-ev-key="${esc(evKey(e))}" tabindex="0" role="button" aria-haspopup="dialog">
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>${esc(e.event)}</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>`;
@@ -559,12 +613,13 @@ function renderWire() {
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 `<div class="wire-item d-flex gap-2 align-items-baseline py-1 px-1"${open}>
<span class="wire-score ${scoreCls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
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>
</div>`;
<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);
@@ -673,7 +728,7 @@ function calDlgHTML() {
}
function renderNewsDlg() {
if (!ndState || !newsModal._isShown) return;
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();
@@ -704,21 +759,6 @@ function openCalDetail(key) {
}
$("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) {
@@ -729,15 +769,71 @@ async function fetchJSON(name) {
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 snapChanged = snap.text !== lastSnapText;
const priorSnapshot = snapshot;
let snapChanged = false;
let selectedChanged = 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]);
detailChanged = detailTf != null && tfFingerprint(priorSnapshot?.timeframes?.[detailTf]) !==
tfFingerprint(incoming.timeframes?.[detailTf]);
snapshot = incoming;
lastSnapText = snap.text;
}
}
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();
@@ -745,9 +841,12 @@ async function poll() {
if (snapChanged) {
renderHero();
renderCards();
if (window.renderGauges) window.renderGauges();
renderDetail();
if (selectedChanged) 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();
@@ -756,21 +855,19 @@ async function poll() {
}
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);
}
}
scheduleBoundaryRefresh();
} catch (err) {
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
const b = $("banner-fail");
b.textContent = `Data feed unreachable (${err.message}).${last} Retrying…`;
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();
}
}
}
@@ -809,21 +906,48 @@ function tick() {
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";
// 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; });
$("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; });
wireOpeners($("tf-grid"));
wireOpeners($("hero-rail"));
wireNewsOpeners($("wire"), "data-news-id", openNewsDetail);
wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail);
$("hi-only")?.addEventListener("change", renderCalendar);
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();
setInterval(poll, POLL_MS);
scheduleFallbackRefresh();
setInterval(tick, 1000);
});
-70
View File
@@ -1,70 +0,0 @@
/* Gold Desk — score gauges.
*
* Draws a horizontal diverging Chart.js bar (−100…+100, zero-centered)
* into every `.gauge-container` element. The containers are created
* dynamically by dashboard.js after each snapshot poll, so this module
* exposes window.renderGauges() which dashboard.js calls after every
* card render (and on resize). Re-running it destroys and redraws each
* chart, so it is safe to call repeatedly.
*/
(function () {
"use strict";
function renderGauges() {
if (typeof Chart === "undefined") return;
document.querySelectorAll(".gauge-container").forEach(function (container) {
var score = parseFloat(container.dataset.score);
if (!isFinite(score)) return;
var canvas = container.querySelector("canvas");
if (!canvas) {
container.innerHTML = "";
canvas = document.createElement("canvas");
container.appendChild(canvas);
} else {
var existing = Chart.getChart(canvas);
if (existing) existing.destroy();
}
var pos = score >= 0;
new Chart(canvas.getContext("2d"), {
type: "bar",
data: {
labels: [container.dataset.label || ""],
datasets: [{
data: [score],
backgroundColor: pos ? "rgba(25,135,84,0.85)" : "rgba(220,53,69,0.85)",
borderColor: pos ? "#198754" : "#dc3545",
borderWidth: 1,
barThickness: 10,
maxBarThickness: 10,
borderRadius: 3
}]
},
options: {
indexAxis: "y",
scales: {
x: { min: -100, max: 100, display: false, grid: { display: false }, ticks: { display: false } },
y: { display: false, grid: { display: false } }
},
plugins: {
legend: { display: false },
tooltip: { enabled: false },
title: { display: false }
},
maintainAspectRatio: false,
responsive: true,
animation: { duration: 400, easing: "easeOutQuad" }
}
});
});
}
window.renderGauges = renderGauges;
var resizeTimer = null;
window.addEventListener("resize", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(renderGauges, 150);
});
})();
+33 -5
View File
@@ -5670,7 +5670,21 @@ def _():
pub = dpub.DashboardPublisher(out_dir=tmp)
pub.install_static()
assert sorted(os.listdir(tmp)) == \
["dashboard.css", "dashboard.html", "dashboard.js", "gauge.js", "news_summary.php"] # all installed, no .tmp
["dashboard.css", "dashboard.html", "dashboard.js", "news_summary.php"] # all installed, no .tmp
@test("dashboard: static UI uses CSS gauges and a coordinated refresh loop")
def _():
static_dir = os.path.join(os.path.dirname(__file__), "static")
with open(os.path.join(static_dir, "dashboard.html"), encoding="utf-8") as fh:
html = fh.read()
with open(os.path.join(static_dir, "dashboard.js"), encoding="utf-8") as fh:
js = fh.read()
assert "chart.js" not in html.lower()
assert "gauge.js" not in html
assert "setInterval(poll" not in js
assert "FALLBACK_POLL_MS" in js and "pollInFlight" in js
assert "data-select-tf" in js and "localStorage" in js
@test("dashboard: publish is atomic and leaves no temp file")
@@ -5683,21 +5697,35 @@ def _():
assert os.listdir(tmp) == ["snapshot.json"] # no .tmp left
@test("dashboard: run_cycle publishes snapshot BEFORE news refresh")
@test("dashboard: run_cycle publishes technical snapshot before panels/news")
def _():
# News I/O (FF + RSS + DeepSeek) can take 20-30 s — fresh signals
# must reach the page first (2026-08-06 sync-latency fix).
# News and advisory panels can take 20-30 s cold — fresh technical
# signals must reach the page first.
pub = dpub.DashboardPublisher(out_dir="/nonexistent")
order = []
pub.scan_tf = lambda tf: order.append(f"scan:{tf.value}")
pub.publish_snapshot = lambda: order.append("snapshot")
pub.attach_panels = lambda tfs: (order.append("panels") or True)
pub.refresh_news = lambda: order.append("news_refresh")
pub.publish_news = lambda: order.append("news_publish")
pub.run_cycle([Timeframe.M5])
assert order == ["scan:5min", "snapshot", "news_refresh",
assert order == ["scan:5min", "snapshot", "panels", "snapshot", "news_refresh",
"news_publish"], order
@test("dashboard: scan errors keep private exception detail out of snapshots")
def _():
pub = dpub.DashboardPublisher(out_dir="/nonexistent")
pub._provider = lambda: object()
with patch("dashboard_publisher.produce_signal",
side_effect=RuntimeError("provider token leaked")):
pub.scan_tf(Timeframe.M5)
entry = pub.snapshots["5min"]
assert entry["status"] == "error"
assert entry["error"] == "Scan unavailable"
assert "token" not in entry["error"]
@test("dashboard: seconds_until_boundary bounds and constants")
def _():
for interval in (5, 15, 60, 240):