Files
ForexBot2/static/dashboard.html
T
Harwinder Sekhon 4e654b5edb 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
2026-08-03 17:10:29 -04:00

774 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>