Web dashboard v2: static publisher + Apache, with calendar & news panels

- dashboard_publisher.py: standalone candle-close daemon that atomically
  writes snapshot.json/news.json/dashboard.html to an Apache-served dir
  (DASHBOARD_OUT_DIR). Fully independent of telegram_bot/oanda_daemon —
  local confirmation map + boundary math, no imports either way, no new
  deps, no open port. Due-TF classification from the RAW wake time.
- static/dashboard.html: self-contained dark page — hero rail, four TF
  cards (score bar, 3x3 indicator grid, MTF, setup, reasons), DeepSeek
  market context, week economic calendar, scored newswire. Clock offset
  from the HTTP Date header (publish-time epoch is stale between closes).
- news_engine.get_week_events(): additive read-only accessor over the
  cached FF week; no signal path calls it.
- deploy/: systemd unit + live-server (LAMP/SELinux) checklist.
- test_all.py: Module 20 (9 tests) + get_week_events test; 296 green.

Claude-Session: https://claude.ai/code/session_01Wc9ZoqKLYBT2QJcpRWKoGm
This commit is contained in:
Harwinder Sekhon
2026-08-03 17:10:29 -04:00
parent 9e1c321f05
commit 4e654b5edb
7 changed files with 1574 additions and 1 deletions
+773
View File
@@ -0,0 +1,773 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Gold Desk — XAU/USD</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23d4a72c'/%3E%3C/svg%3E">
<style>
/* ── Gold Desk tokens ─────────────────────────────────────────── */
:root {
--bg: #0f0e0b; /* page plane, warm near-black */
--card: #17150f; /* card surface */
--card2: #1d1b14; /* nested surface (setup, grid cells) */
--line: rgba(242,239,230,0.08);
--line-strong: rgba(242,239,230,0.16);
--ink: #f2efe6;
--ink2: #b5ae9c;
--muted: #7d7768;
--gold: #d4a72c; /* identity accent — never a data encoding */
--gold-bright: #e6c35c;
--gold-wash: rgba(212,167,44,0.10);
--bull: #0ca30c; /* status good — always paired with arrow/text */
--bull-wash: rgba(12,163,12,0.12);
--bear: #d03b3b; /* status critical */
--bear-text: #e66767; /* lighter step for small red text */
--bear-wash: rgba(208,59,59,0.12);
--amber: #fab219; /* warnings / medium impact */
--amber-wash: rgba(250,178,25,0.10);
--neutral-mid: #383835; /* diverging midpoint */
--mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, Menlo, monospace;
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--bg); }
body {
font-family: var(--sans);
color: var(--ink);
background: var(--bg);
font-size: 14px;
line-height: 1.45;
padding: 14px clamp(10px, 2.5vw, 28px) 40px;
max-width: 1500px;
margin: 0 auto;
}
.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* ── Eyebrow section labels ───────────────────────────────────── */
.eyebrow {
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--muted); font-weight: 600; margin: 26px 0 10px;
display: flex; align-items: center; gap: 10px;
}
.eyebrow::after { content: ""; flex: 1; height: 1px; background: var(--line); }
/* ── Hero consensus rail ──────────────────────────────────────── */
#hero {
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
padding: 14px 18px;
background: linear-gradient(180deg, var(--card) 0%, var(--bg) 140%);
border: 1px solid var(--line); border-radius: 10px;
}
#hero .symbol {
font-family: var(--mono); font-weight: 700; font-size: 15px;
letter-spacing: 0.08em; color: var(--gold);
}
#hero .price {
font-family: var(--mono); font-variant-numeric: tabular-nums;
font-size: 30px; font-weight: 600; color: var(--gold-bright);
line-height: 1;
}
#hero .rail { display: flex; gap: 8px; flex-wrap: wrap; }
.rail-chip {
display: inline-flex; align-items: baseline; gap: 6px;
font-family: var(--mono); font-size: 12.5px;
padding: 5px 9px; border-radius: 6px;
background: var(--card2); border: 1px solid var(--line);
color: var(--ink2);
}
.rail-chip .tfname { color: var(--muted); font-weight: 600; }
#hero .meta { margin-left: auto; text-align: right; font-size: 12px; color: var(--ink2); }
#hero .meta .num { color: var(--ink); }
.live-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: var(--bull); margin-right: 5px;
animation: pulse 2.4s ease-in-out infinite;
}
.live-dot.closed { background: var(--amber); animation: none; }
@keyframes pulse { 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) {
.live-dot { animation: none; }
* { transition: none !important; }
}
/* ── Banners / toast ──────────────────────────────────────────── */
.banner {
margin-top: 10px; padding: 9px 14px; border-radius: 8px;
font-size: 13px; display: none;
}
#banner-closed { background: var(--amber-wash); border: 1px solid rgba(250,178,25,0.35); color: var(--amber); }
#banner-fail { background: var(--bear-wash); border: 1px solid rgba(208,59,59,0.4); color: var(--bear-text); }
/* ── Market context strip ─────────────────────────────────────── */
#context {
margin-top: 14px; padding: 12px 16px;
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap;
}
#context .blurb { flex: 1 1 380px; color: var(--ink2); font-size: 13px; }
#context .blurb b { color: var(--ink); font-weight: 600; }
#context .chips { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.chip {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; padding: 4px 9px; border-radius: 999px;
border: 1px solid var(--line-strong); color: var(--ink2);
white-space: nowrap;
}
.chip .num { font-size: 12px; }
.chip.pos { border-color: rgba(12,163,12,0.45); background: var(--bull-wash); color: var(--ink); }
.chip.neg { border-color: rgba(208,59,59,0.45); background: var(--bear-wash); color: var(--ink); }
.chip.risk-clear { border-color: rgba(12,163,12,0.45); color: var(--bull); }
.chip.risk-caution { border-color: rgba(250,178,25,0.5); background: var(--amber-wash); color: var(--amber); }
.chip.risk-danger { border-color: rgba(208,59,59,0.55); background: var(--bear-wash); color: var(--bear-text); }
/* ── Timeframe cards ──────────────────────────────────────────── */
#tf-grid {
display: grid; gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(min(430px, 100%), 1fr));
}
.tf-card {
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 14px 16px 12px; display: flex; flex-direction: column; gap: 11px;
}
.tf-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.tf-name {
font-family: var(--mono); font-weight: 700; font-size: 15px;
color: var(--gold); letter-spacing: 0.05em; min-width: 42px;
}
.sig-badge {
font-weight: 700; font-size: 12.5px; letter-spacing: 0.04em;
padding: 3px 10px; border-radius: 6px; border: 1px solid var(--line-strong);
color: var(--ink2);
}
.sig-badge.buy { background: var(--bull-wash); border-color: rgba(12,163,12,0.5); color: var(--bull); }
.sig-badge.sell { background: var(--bear-wash); border-color: rgba(208,59,59,0.55); color: var(--bear-text); }
.q-chip {
font-family: var(--mono); font-weight: 700; font-size: 12px;
padding: 3px 8px; border-radius: 6px;
background: var(--card2); border: 1px solid var(--line-strong); color: var(--ink2);
}
.q-chip.hi { background: var(--gold-wash); border-color: rgba(212,167,44,0.5); color: var(--gold-bright); }
.regime { font-size: 11.5px; color: var(--muted); }
.tf-head .spacer { margin-left: auto; }
.err-badge {
font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 6px;
background: var(--bear-wash); border: 1px solid rgba(208,59,59,0.5);
color: var(--bear-text); cursor: help;
}
/* score bar: zero-centered diverging, neutral midpoint tick */
.scorebar { display: flex; align-items: center; gap: 10px; }
.scorebar .track {
position: relative; flex: 1; height: 8px; border-radius: 4px;
background: var(--card2); overflow: hidden;
}
.scorebar .track::after { /* midpoint tick */
content: ""; position: absolute; left: 50%; top: 0; bottom: 0;
width: 2px; background: var(--neutral-mid); 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(--bull); }
.scorebar .fill.neg { right: 50%; background: var(--bear); }
.scorebar .val { width: 52px; text-align: right; font-size: 13px; color: var(--ink); }
.scorebar .lbl { font-size: 11px; color: var(--muted); width: 38px; }
/* 3×3 indicator grid */
.ind-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
}
.ind-cell {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 9px; border-radius: 6px;
background: var(--card2); border: 1px solid transparent;
font-family: var(--mono); font-size: 12.5px; cursor: help;
}
.ind-cell:hover, .ind-cell:focus-visible { border-color: var(--line-strong); }
.ind-cell .k { color: var(--ink2); }
.ind-cell .arrow { font-size: 14px; font-weight: 700; }
.ind-cell.buy .arrow { color: var(--bull); }
.ind-cell.sell .arrow { color: var(--bear-text); }
.ind-cell.neutral .arrow { color: var(--muted); }
.ind-cell.absent { opacity: 0.45; }
.ind-cell.absent .arrow { color: var(--muted); }
.atr-line { font-size: 12px; color: var(--muted); }
.atr-line .num { color: var(--ink2); }
.patterns { display: flex; gap: 6px; flex-wrap: wrap; }
.pat-tag {
font-size: 11px; font-family: var(--mono); padding: 2px 7px;
border-radius: 5px; background: var(--gold-wash); color: var(--gold);
border: 1px solid rgba(212,167,44,0.3);
}
/* MTF row */
.mtf-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
font-size: 12px; color: var(--ink2);
}
.mtf-row .agree { font-weight: 700; }
.mtf-row .agree.yes { color: var(--bull); }
.mtf-row .agree.no { color: var(--muted); }
.mtf-chip {
font-family: var(--mono); font-size: 11.5px; padding: 2px 7px;
border-radius: 5px; background: var(--card2); border: 1px solid var(--line);
}
/* trade setup sub-card */
.setup {
border: 1px solid var(--line-strong); border-radius: 8px;
padding: 10px 12px; background: var(--card2);
}
.setup.buy { border-left: 3px solid var(--bull); }
.setup.sell { border-left: 3px solid var(--bear); }
.setup .s-head {
display: flex; align-items: baseline; gap: 8px; margin-bottom: 7px;
font-weight: 700; font-size: 13px;
}
.setup .s-head .dir.buy { color: var(--bull); }
.setup .s-head .dir.sell { color: var(--bear-text); }
.setup .s-head .strat { font-family: var(--mono); font-size: 11px; color: var(--muted); font-weight: 400; }
.setup table { border-collapse: collapse; width: 100%; }
.setup td { padding: 2px 0; font-size: 12.5px; }
.setup td:first-child { color: var(--muted); padding-right: 12px; white-space: nowrap; }
.setup td.num { color: var(--ink); }
/* reasons & warnings */
.reasons { font-size: 12.5px; display: flex; flex-direction: column; gap: 3px; }
.reasons .r-bull::before { content: "▲ "; color: var(--bull); }
.reasons .r-bear::before { content: "▼ "; color: var(--bear-text); }
.reasons .r-warn { color: var(--amber); }
.reasons .r-warn::before { content: "⚠ "; }
.reasons .r-bull, .reasons .r-bear { color: var(--ink2); }
details.more { font-size: 12px; }
details.more summary { cursor: pointer; color: var(--muted); user-select: none; }
details.more summary:hover { color: var(--ink2); }
details.more .reasons { margin-top: 4px; }
.tf-foot {
display: flex; align-items: center; gap: 10px; margin-top: auto;
padding-top: 8px; border-top: 1px solid var(--line);
font-size: 11.5px; color: var(--muted);
}
.tf-foot .cd { margin-left: auto; }
.tf-foot .cd .num { color: var(--ink2); }
.warming { color: var(--muted); font-size: 13px; padding: 18px 0; text-align: center; }
/* confirmations (collapsible extra) */
details.confirms summary {
cursor: pointer; font-size: 11.5px; color: var(--muted); user-select: none;
}
details.confirms summary:hover { color: var(--ink2); }
.confirm-line {
font-family: var(--mono); font-size: 12px; color: var(--ink2);
padding: 4px 0 0 2px;
}
/* ── News section ─────────────────────────────────────────────── */
#news-wrap { display: grid; gap: 14px; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); }
@media (max-width: 1100px) { #news-wrap { grid-template-columns: 1fr; } }
.panel {
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 14px 16px;
}
.panel-head { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.panel-head h2 {
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--ink2); font-weight: 600;
}
.panel-head .note { font-size: 11px; color: var(--muted); }
.panel-head label {
margin-left: auto; font-size: 12px; color: var(--ink2);
display: inline-flex; gap: 6px; align-items: center; cursor: pointer;
}
.table-scroll { overflow-x: auto; }
#cal-table { border-collapse: collapse; width: 100%; min-width: 560px; }
#cal-table th {
text-align: left; font-size: 10.5px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--muted); font-weight: 600;
padding: 4px 10px 6px 0; border-bottom: 1px solid var(--line-strong);
}
#cal-table td { padding: 5px 10px 5px 0; font-size: 12.5px; border-bottom: 1px solid var(--line); }
#cal-table tr.day-row td {
padding-top: 10px; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--gold); font-weight: 600; border-bottom: none;
}
#cal-table tr.past { opacity: 0.55; }
#cal-table tr.next-up td:first-child { box-shadow: inset 3px 0 0 var(--gold); padding-left: 7px; }
#cal-table .t { white-space: nowrap; color: var(--ink2); }
#cal-table .cur { font-family: var(--mono); color: var(--muted); }
#cal-table .ev { color: var(--ink); }
.imp {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em;
padding: 1.5px 7px; border-radius: 999px; white-space: nowrap;
}
.imp.high { background: var(--bear-wash); color: var(--bear-text); border: 1px solid rgba(208,59,59,0.45); }
.imp.medium { background: var(--amber-wash); color: var(--amber); border: 1px solid rgba(250,178,25,0.4); }
.imp.low { background: var(--card2); color: var(--muted); border: 1px solid var(--line-strong); }
.surp { font-weight: 700; cursor: help; }
.surp.positive { color: var(--bull); }
.surp.negative { color: var(--bear-text); }
.surp.inline { color: var(--muted); }
/* newswire */
.wire { display: flex; flex-direction: column; }
.wire-item {
display: flex; gap: 10px; align-items: baseline;
padding: 7px 0; border-bottom: 1px solid var(--line);
}
.wire-item:last-child { border-bottom: none; }
.wire-score {
font-family: var(--mono); font-size: 11.5px; font-weight: 700;
padding: 1px 6px; border-radius: 5px; white-space: nowrap;
}
.wire-score.pos { background: var(--bull-wash); color: var(--bull); }
.wire-score.neg { background: var(--bear-wash); color: var(--bear-text); }
.wire-score.zero { background: var(--card2); color: var(--muted); }
.wire-h { font-size: 12.5px; color: var(--ink2); flex: 1; }
.wire-meta { font-size: 11px; color: var(--muted); white-space: nowrap; }
.empty { color: var(--muted); font-size: 12.5px; padding: 8px 0; }
footer {
margin-top: 30px; font-size: 11px; color: var(--muted);
border-top: 1px solid var(--line); padding-top: 12px;
}
:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; border-radius: 4px; }
</style>
</head>
<body>
<header id="hero">
<div>
<div class="symbol">XAU/USD</div>
<div class="price num" id="hero-price">—</div>
</div>
<div class="rail" id="hero-rail" aria-label="Signal by timeframe"></div>
<div class="meta">
<div id="hero-status"><span class="live-dot"></span>…</div>
<div>M5 close in <span class="num" id="hero-cd">--:--</span></div>
<div id="hero-updated">updated —</div>
</div>
</header>
<div class="banner" id="banner-closed">Market closed (weekend) — showing Friday's last state. Calendar and news stay live.</div>
<div class="banner" id="banner-fail"></div>
<section id="context" hidden>
<div class="blurb" id="ctx-blurb"></div>
<div class="chips" id="ctx-chips"></div>
</section>
<div class="eyebrow">Timeframes</div>
<main id="tf-grid"></main>
<div class="eyebrow">Fundamentals</div>
<div id="news-wrap">
<section class="panel">
<div class="panel-head">
<h2>Economic calendar</h2>
<span class="note">times local · USD events</span>
<label><input type="checkbox" id="hi-only"> high impact only</label>
</div>
<div class="table-scroll">
<table id="cal-table">
<thead><tr><th>Time</th><th>Cur</th><th>Impact</th><th>Event</th>
<th class="num">Fcst</th><th class="num">Prev</th><th class="num">Actual</th></tr></thead>
<tbody id="cal-body"><tr><td colspan="7" class="empty">Loading calendar…</td></tr></tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-head"><h2>Newswire</h2><span class="note" id="wire-note"></span></div>
<div class="wire" id="wire"></div>
</section>
</div>
<footer>
Signals are generated on closed candles only and refresh at candle boundaries
(M5/M15/H1/H4). Not financial advice — educational and research use only.
</footer>
<script>
"use strict";
/* Mirrors telegram_bot._IND_SHORT / _IND_ARROW — kept as local constants
by design: this page must stay independent of the 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 POLL_MS = 20000;
let snapshot = null, news = null;
let clockOffset = 0; // serverEpoch - localEpoch
let lastPollOk = null; // local Date of last successful poll
const $ = id => document.getElementById(id);
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 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`;
}
/* ── hero ─────────────────────────────────────────────────────── */
function renderHero() {
if (!snapshot) return;
let price = null;
for (const tf of ["5min","15min","1h","4h"]) {
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 color = cls === "buy" ? "var(--bull)" : cls === "sell" ? "var(--bear-text)" : "var(--muted)";
const score = r?.score != null ? r.score.toFixed(0) : "–";
return `<span class="rail-chip" title="${esc(sigLabel(sig))} (score ${esc(score)})">
<span class="tfname">${TF_LABEL[tf]}</span>
<span style="color:${color};font-weight:700">${arrow}</span>
<span>${esc(score)}</span></span>`;
}).join("");
const closed = snapshot.market_closed;
$("hero-status").innerHTML =
`<span class="live-dot${closed ? " closed" : ""}"></span>${closed ? "CLOSED" : "OPEN"}`;
$("banner-closed").style.display = closed ? "block" : "none";
}
/* ── market context strip ─────────────────────────────────────── */
function renderContext() {
if (!news) return;
$("context").hidden = false;
$("ctx-blurb").innerHTML = news.llm_summary
? `<b>Market context:</b> ${esc(news.llm_summary)}`
: `<b>Market context:</b> <span style="color:var(--muted)">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 ? "pos" : v < -0.05 ? "neg" : "";
chips += `<span class="chip ${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; }
chips += `<span class="chip risk-${risk}" 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 pct = Math.abs(v) / 2; // half-track = 100
const cls = v >= 0 ? "pos" : "neg";
return `<div class="scorebar">
<span class="lbl">${label}</span>
<div class="track" role="img" aria-label="${label} score ${v.toFixed(1)} of ±100">
<div class="fill ${cls}" style="width:${pct}%"></div></div>
<span class="val num">${v >= 0 ? "+" : ""}${v.toFixed(1)}</span></div>`;
}
function indGrid(pa) {
return `<div class="ind-grid">` + IND_ORDER.map(name => {
const ind = pa?.indicators?.[name];
const short = IND_SHORT[name];
if (!ind) {
return `<div class="ind-cell absent" tabindex="0"
title="${esc(short)}: no signal this scan"><span class="k">${short}</span><span class="arrow">—</span></div>`;
}
const cls = sigClass(ind.signal);
const tip = `${short} — ${sigLabel(ind.signal)}\n${ind.description || ""}` +
(ind.value != null ? `\nvalue ${(+ind.value).toFixed(2)}` : "") +
` conf ${Math.round(ind.confidence ?? 0)} w ${ind.weight}`;
return `<div class="ind-cell ${cls}" tabindex="0" title="${esc(tip)}">
<span class="k">${short}</span><span class="arrow">${ARROW[ind.signal] || "→"}</span></div>`;
}).join("") + `</div>`;
}
function reasonsBlock(r) {
const items = [
...(r.warnings || []).map(t => ({cls: "r-warn", t})),
...(r.bullish_reasons || []).map(t => ({cls: "r-bull", t})),
...(r.bearish_reasons || []).map(t => ({cls: "r-bear", t})),
];
if (!items.length) return "";
const line = i => `<div class="${i.cls}">${esc(i.t)}</div>`;
const head = items.slice(0, 3).map(line).join("");
if (items.length <= 3) return `<div class="reasons">${head}</div>`;
return `<div class="reasons">${head}</div>
<details class="more"><summary>${items.length - 3} more…</summary>
<div class="reasons">${items.slice(3).map(line).join("")}</div></details>`;
}
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 `<div class="setup ${dir}">
<div class="s-head"><span class="dir ${dir}">${ts.direction}</span>
<span class="strat">${esc(ts.strategy || "")}</span>
<span class="strat" style="margin-left:auto">R:R ${(+ts.risk_reward).toFixed(1)}</span></div>
<table>
<tr><td>Entry</td><td class="num">${fmt$(ts.entry)}</td>
<td style="color:var(--muted);padding-left:16px">TP1</td><td class="num">${fmt$(ts.take_profit_1)}</td></tr>
<tr><td>Stop</td><td class="num">${fmt$(ts.stop_loss)}</td>
<td style="color:var(--muted);padding-left:16px">TP2</td><td class="num">${fmt$(ts.take_profit_2)}</td></tr>
<tr><td>Trail</td><td class="num" colspan="3">${esc(trail)}</td></tr>
</table></div>`;
}
function mtfRow(r) {
const entries = Object.entries(r.mtf_signals || {});
if (!entries.length) return "";
const chips = entries.map(([tf, sig]) => {
const score = r.mtf_scores?.[tf];
const cls = sigClass(sig);
const color = cls === "buy" ? "var(--bull)" : cls === "sell" ? "var(--bear-text)" : "var(--muted)";
return `<span class="mtf-chip">${esc(TF_LABEL[tf] || tf)}
<span style="color:${color};font-weight:700">${ARROW[sig] || "→"}</span>
<span class="num">${score != null ? (score >= 0 ? "+" : "") + score.toFixed(0) : ""}</span></span>`;
}).join("");
const agree = r.mtf_agreement;
return `<div class="mtf-row"><span>MTF</span>${chips}
<span class="agree ${agree ? "yes" : "no"}">${agree ? "✓ aligned" : "✗ mixed"}</span></div>`;
}
function confirmsBlock(r) {
const confs = (r.confirmation_analyses || []).filter(Boolean);
if (!confs.length) return "";
const lines = confs.map(a => {
const cls = sigClass(a.composite_signal);
const color = cls === "buy" ? "var(--bull)" : cls === "sell" ? "var(--bear-text)" : "var(--muted)";
return `<div class="confirm-line">${esc(TF_LABEL[a.timeframe] || a.timeframe)}
<span style="color:${color};font-weight:700">${ARROW[a.composite_signal] || "→"}</span>
${esc(sigLabel(a.composite_signal))} ${a.composite_score != null ?
`<span style="color:var(--muted)">(${a.composite_score >= 0 ? "+" : ""}${a.composite_score.toFixed(1)}, ${esc(a.market_regime || "")})</span>` : ""}</div>`;
}).join("");
return `<details class="confirms"><summary>confirmation detail</summary>${lines}</details>`;
}
function renderCards() {
if (!snapshot) return;
$("tf-grid").innerHTML = TF_ORDER.map(tf => {
const entry = snapshot.timeframes[tf] || {};
const r = entry.report;
const head = `<div class="tf-head">
<span class="tf-name">${TF_LABEL[tf]}</span>`;
if (!r) {
return `<article class="tf-card">${head}</div>
<div class="warming">${entry.status === "error"
? `<span class="err-badge" title="${esc(entry.error || "")}">SCAN ERROR</span>`
: "warming up — first scan pending…"}</div>
<div class="tf-foot"><span class="cd">closes in
<span class="num" data-close="${entry.next_close_epoch || ""}">--:--</span></span></div>
</article>`;
}
const pa = r.primary_analysis;
const qHi = r.quality === "A+" || r.quality === "A";
const regime = [r.market_regime, r.volatility_regime && r.volatility_regime + " vol"]
.filter(Boolean).join(" · ");
return `<article class="tf-card">
${head}
<span class="sig-badge ${sigClass(r.signal)}">${sigLabel(r.signal)}</span>
${r.quality ? `<span class="q-chip${qHi ? " hi" : ""}" title="Signal quality grade">${r.quality}</span>` : ""}
<span class="regime">${esc(regime)}</span>
<span class="spacer"></span>
${entry.status === "error" ? `<span class="err-badge" title="${esc(entry.error || "")} — showing last good scan">STALE</span>` : ""}
</div>
${scoreBar("score", pa ? pa.composite_score : r.score)}
${pa ? indGrid(pa) : ""}
${pa?.atr_context ? `<div class="atr-line">${esc(pa.atr_context.description ||
"ATR " + (+pa.atr_context.value).toFixed(2))}</div>` : ""}
${pa?.pattern_signals?.length ? `<div class="patterns">${pa.pattern_signals.map(p =>
`<span class="pat-tag">${esc(p)}</span>`).join("")}</div>` : ""}
${mtfRow(r)}
${setupCard(r.trade_setup)}
${reasonsBlock(r)}
${confirmsBlock(r)}
<div class="tf-foot">
<span>scanned ${fmtAge(entry.scanned_at)}${entry.scan_seconds ? ` (${entry.scan_seconds}s)` : ""}</span>
<span class="cd">closes in <span class="num" data-close="${entry.next_close_epoch || ""}">--:--</span></span>
</div>
</article>`;
}).join("");
}
/* ── 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 = `<tr><td colspan="7" class="empty">${
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="day-row"><td colspan="7">${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="surp ${e.surprise}" 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 ? "past" : ""}${nextUp ? " next-up" : ""}">
<td class="t num">${esc(time)}</td>
<td class="cur">${esc(e.currency)}</td>
<td><span class="imp ${esc(e.impact)}">${e.impact === "medium" ? "Med" :
e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span></td>
<td class="ev">${esc(e.event)}</td>
<td class="num">${esc(e.forecast ?? "—")}</td>
<td class="num">${esc(e.previous ?? "—")}</td>
<td class="num">${esc(e.actual ?? "—")} ${surp}</td></tr>`;
}
body.innerHTML = rows;
}
/* ── newswire ─────────────────────────────────────────────────── */
function renderWire() {
if (!news) return;
const items = (news.headlines || []).slice(0, 40);
$("wire-note").textContent = news.generated_at ? `updated ${fmtAge(news.generated_at)}` : "";
if (!items.length) {
$("wire").innerHTML = `<div class="empty">No recent gold/dollar headlines.</div>`;
return;
}
const item = h => {
const s = h.score;
const cls = s == null || Math.abs(s) < 0.05 ? "zero" : s > 0 ? "pos" : "neg";
const scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
let host = "";
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
return `<div class="wire-item">
<span class="wire-score ${cls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${
h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
<span class="wire-h">${esc(h.headline)}</span>
<span class="wire-meta">${esc(host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span></div>`;
};
const head = items.slice(0, 12).map(item).join("");
const rest = items.slice(12);
$("wire").innerHTML = head + (rest.length
? `<details class="more" style="padding-top:6px"><summary>${rest.length} more headlines…</summary>
<div class="wire">${rest.map(item).join("")}</div></details>` : "");
}
/* ── polling ──────────────────────────────────────────────────── */
async function fetchJSON(name) {
const res = await fetch(`${name}?t=${Date.now()}`, {cache: "no-store"});
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
/* The Date header is stamped at RESPONSE time — snapshot.server_time_epoch
is stamped at publish time, minutes stale between candle closes. */
const dateHdr = res.headers.get("Date");
const data = await res.json();
return {data, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null};
}
let refetchTimer = null;
async function poll() {
try {
const [snap, nw] = await Promise.all([
fetchJSON("snapshot.json"),
fetchJSON("news.json").catch(() => null), // news outage ≠ page failure
]);
snapshot = snap.data;
news = (nw && nw.data) || news;
clockOffset = (snap.serverNow ?? snap.data.server_time_epoch) - Date.now() / 1000;
lastPollOk = new Date();
$("banner-fail").style.display = "none";
renderHero(); renderContext(); renderCards(); renderCalendar(); renderWire();
tick(); // countdowns appear immediately, not after the first 1 s tick
/* one-shot refetch shortly after the next expected publish, so a
fresh scan appears within seconds of the candle close */
const nextScan = Math.min(...TF_ORDER.map(tf =>
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
if (isFinite(nextScan)) {
clearTimeout(refetchTimer);
const delay = (nextScan - nowServer() + 6) * 1000;
if (delay > 0 && delay < POLL_MS) 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.style.display = "block";
}
}
/* 1 s countdown ticker (server-clock anchored) */
function tick() {
if (!snapshot) return;
const now = nowServer();
document.querySelectorAll("[data-close]").forEach(el => {
let t = parseFloat(el.dataset.close);
if (!t) return;
const tf = TF_ORDER.find(k => snapshot.timeframes[k]?.next_close_epoch === parseFloat(el.dataset.close));
let secs = t - now;
if (secs < 0) { // boundary passed while waiting for next publish
const interval = {"5min": 300, "15min": 900, "1h": 3600, "4h": 14400}[tf] || 300;
secs = ((secs % interval) + interval) % interval;
}
el.textContent = fmtCountdown(secs);
});
const m5 = snapshot.timeframes["5min"]?.next_close_epoch;
if (m5) {
let secs = m5 - now;
if (secs < 0) secs = ((secs % 300) + 300) % 300;
$("hero-cd").textContent = fmtCountdown(secs);
}
if (lastPollOk) $("hero-updated").textContent = `updated ${fmtAge(lastPollOk.toISOString())}`;
}
setInterval(tick, 1000);
$("hi-only").addEventListener("change", renderCalendar);
poll();
setInterval(poll, POLL_MS);
</script>
</body>
</html>