Files
ForexBot2/static/dashboard.html
T
Harwinder Sekhon 38c81251e8 cli+dashboard: --panel flag, panel dict keys, TF-dialog panel section
main.py: --panel runs the agent panel on demand (force bypasses
enabled/surface/quality toggles; daily budget + LLM cap still apply),
prints the verdict + persona takes, and report_to_dict carries the four
panel keys — which auto-propagates them into the dashboard's
snapshot.json via report_to_webapp_dict. dashboard_publisher attaches
in scan_tf gated by agent_panel.dashboard (default off); dashboard.html
renders an "AI panel (advisory)" section in the TF detail dialog.

Claude-Session: https://claude.ai/code/session_01SisdfkxnXNL9iZsXxN3T15
2026-08-07 22:34:30 -04:00

1422 lines
64 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="light dark">
<script>
(function () {
var t = localStorage.getItem("xau-theme");
if (!t && matchMedia("(prefers-color-scheme: dark)").matches) t = "dark";
if (t) document.documentElement.setAttribute("data-theme", t);
})();
</script>
<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='%230d6efd'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<style>
/* ── Gold Desk tokens ─────────────────────────────────────────── */
:root {
/* Bootstrap 5 palette — no colour outside this list is used anywhere */
--bs-blue: #0d6efd; /* primary */
--bs-green: #198754; /* success */
--bs-green-600:#146c43;
--bs-red: #dc3545; /* danger */
--bs-red-600: #b02a37;
--bs-yellow: #ffc107; /* warning */
--bs-yellow-700:#997404;
--bs-blue-700:#0a58ca;
--bs-white: #fff;
--bs-gray-100:#f8f9fa; /* light */
--bs-gray-200:#e9ecef;
--bs-gray-300:#dee2e6;
--bs-gray-400:#ced4da;
--bs-gray-500:#adb5bd;
--bs-gray-600:#6c757d; /* secondary */
--bs-gray-700:#495057;
--bs-gray-900:#212529; /* dark */
--bg: var(--bs-gray-100); /* bg-light page plane */
--card: var(--bs-white); /* card surface */
--card2: var(--bs-gray-200); /* nested surface (setup, grid cells) */
--line: var(--bs-gray-300);
--line-strong: var(--bs-gray-400);
--ink: var(--bs-gray-900); /* text-dark */
--ink2: var(--bs-gray-700);
--muted: var(--bs-gray-600);
--accent: var(--bs-blue); /* identity accent — never a data encoding */
--accent-bright: var(--bs-blue-700);
--accent-rgb: 13,110,253;
--accent-wash: rgba(var(--accent-rgb),0.10);
--bull: var(--bs-green); /* status good — always paired with arrow/text */
--bull-rgb: 25,135,84;
--bull-wash: rgba(var(--bull-rgb),0.10);
--bear: var(--bs-red); /* status critical */
--bear-text: var(--bs-red-600);/* darker step for small red text on light */
--bear-rgb: 220,53,69;
--bear-wash: rgba(var(--bear-rgb),0.10);
--amber: var(--bs-yellow-700); /* warnings / medium impact — readable on light */
--amber-rgb: 255,193,7;
--amber-wash: rgba(var(--amber-rgb),0.18);
--ink-rgb: 33,37,41;
--neutral-mid: var(--bs-gray-500); /* diverging midpoint */
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Consolas, Menlo, monospace;
--sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
}
/* ── Dark mode: every base colour with its LUMA (HSL lightness) exactly
inverted — L' = 100% − L, hue and saturation untouched. Applied by JS
(data-theme attribute), which resolves system preference + a saved
override; see the #theme-toggle script at the bottom of the page. ─── */
:root[data-theme="dark"] {
--bs-blue: #0263f2;
--bs-blue-700: #3583f5;
--bs-green: #78e6b3;
--bs-green-600:#93ebc2;
--bs-red: #ca2333;
--bs-red-600: #d54f5c;
--bs-yellow: #f8ba00;
--bs-yellow-700:#fbd666;
--bs-white: #000000;
--bs-gray-100: #050607;
--bs-gray-200: #101316;
--bs-gray-300: #191d21;
--bs-gray-400: #252b31;
--bs-gray-500: #424a52;
--bs-gray-600: #828b93;
--bs-gray-700: #a8afb6;
--bs-gray-900: #d6dade;
--accent-rgb: 2,99,242;
--bull-rgb: 120,230,179;
--bear-rgb: 202,35,51;
--amber-rgb: 248,186,0;
--ink-rgb: 214,218,222;
}
* { 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: 16px;
line-height: 1.5;
letter-spacing: -0.005em;
padding: 16px clamp(12px, 2.5vw, 30px) 44px;
max-width: 1560px;
margin: 0 auto;
-webkit-font-smoothing: antialiased;
}
.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* ── Eyebrow section labels ───────────────────────────────────── */
.eyebrow {
font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--muted); font-weight: 600; margin: 30px 0 12px;
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: 20px; flex-wrap: wrap;
padding: 16px 20px;
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: 17px;
letter-spacing: 0.08em; color: var(--accent);
}
#hero .price {
font-family: var(--mono); font-variant-numeric: tabular-nums;
font-size: 36px; font-weight: 600; color: var(--accent-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: 14px;
padding: 6px 10px; 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: 13px; 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; }
#theme-toggle {
font: inherit; font-size: 16px; line-height: 1; cursor: pointer;
width: 30px; height: 30px; border-radius: 6px;
background: var(--card2); border: 1px solid var(--line-strong); color: var(--ink2);
}
#theme-toggle:hover { border-color: var(--accent); color: var(--accent-bright); }
@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: 10px 15px; border-radius: 8px;
font-size: 14.5px; display: none;
}
#banner-closed { background: var(--amber-wash); border: 1px solid rgba(var(--amber-rgb),0.5); color: var(--amber); }
#banner-fail { background: var(--bear-wash); border: 1px solid rgba(var(--bear-rgb),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 400px; color: var(--ink2); font-size: 14.5px; }
#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: 13px; padding: 5px 10px; border-radius: 999px;
border: 1px solid var(--line-strong); color: var(--ink2);
white-space: nowrap;
}
.chip .num { font-size: 13px; }
.chip.pos { border-color: rgba(var(--bull-rgb),0.4); background: var(--bull-wash); color: var(--ink); }
.chip.neg { border-color: rgba(var(--bear-rgb),0.4); background: var(--bear-wash); color: var(--ink); }
.chip.risk-clear { border-color: rgba(var(--bull-rgb),0.4); color: var(--bull); }
.chip.risk-caution { border-color: rgba(var(--amber-rgb),0.65); background: var(--amber-wash); color: var(--amber); }
.chip.risk-danger { border-color: rgba(var(--bear-rgb),0.5); background: var(--bear-wash); color: var(--bear-text); }
/* ── Timeframe cards ──────────────────────────────────────────── */
#tf-grid {
display: grid; gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr));
}
.tf-card {
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 13px 15px 11px; display: flex; flex-direction: column; gap: 9px;
}
.tf-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.tf-name {
font-family: var(--mono); font-weight: 700; font-size: 17px;
color: var(--accent); letter-spacing: 0.05em; min-width: 46px;
}
.sig-badge {
font-weight: 700; font-size: 14px; letter-spacing: 0.04em;
padding: 3px 11px; border-radius: 6px; border: 1px solid var(--line-strong);
color: var(--ink2);
}
.sig-badge.buy { background: var(--bull-wash); border-color: rgba(var(--bull-rgb),0.45); color: var(--bull); }
.sig-badge.sell { background: var(--bear-wash); border-color: rgba(var(--bear-rgb),0.5); color: var(--bear-text); }
.q-chip {
font-family: var(--mono); font-weight: 700; font-size: 13px;
padding: 3px 9px; border-radius: 6px;
background: var(--card2); border: 1px solid var(--line-strong); color: var(--ink2);
}
.q-chip.hi { background: var(--accent-wash); border-color: rgba(var(--accent-rgb),0.4); color: var(--accent-bright); }
.regime { font-size: 12.5px; color: var(--muted); }
.tf-head .spacer { margin-left: auto; }
.err-badge {
font-size: 12px; font-weight: 700; padding: 2px 9px; border-radius: 6px;
background: var(--bear-wash); border: 1px solid rgba(var(--bear-rgb),0.45);
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: 9px; border-radius: 5px;
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: 58px; text-align: right; font-size: 14.5px; color: var(--ink); }
.scorebar .lbl { font-size: 12px; color: var(--muted); width: 44px; }
.atr-line { font-size: 13.5px; color: var(--muted); }
.atr-line .num { color: var(--ink2); }
.patterns { display: flex; gap: 6px; flex-wrap: wrap; }
.pat-tag {
font-size: 12px; font-family: var(--mono); padding: 2px 8px;
border-radius: 5px; background: var(--accent-wash); color: var(--accent-bright);
border: 1px solid rgba(var(--accent-rgb),0.3);
}
/* MTF row */
.mtf-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
font-size: 13.5px; 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: 13px; padding: 3px 8px;
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: 8px;
font-weight: 700; font-size: 14.5px;
}
.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: 12px; color: var(--muted); font-weight: 400; }
.setup table { border-collapse: collapse; width: 100%; }
.setup td { padding: 3px 0; font-size: 14px; }
.setup td:first-child { color: var(--muted); padding-right: 12px; white-space: nowrap; }
.setup td.num { color: var(--ink); }
/* reasons & warnings */
.reasons { font-size: 14px; display: flex; flex-direction: column; gap: 4px; }
.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: 13.5px; }
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: 9px; border-top: 1px solid var(--line);
font-size: 13px; color: var(--muted);
}
.tf-foot .cd { margin-left: auto; }
.tf-foot .cd .num { color: var(--ink2); }
.warming { color: var(--muted); font-size: 14.5px; padding: 20px 0; text-align: center; }
/* ── Clickable card affordance ────────────────────────────────── */
.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(--accent); box-shadow: 0 0 0 3px var(--accent-wash);
}
.open-hint {
font-size: 12px; font-weight: 600; color: var(--accent-bright);
white-space: nowrap; letter-spacing: 0.02em;
}
.tf-card:hover .open-hint, .tf-card:focus-visible .open-hint { text-decoration: underline; }
.rail-chip { cursor: pointer; }
.rail-chip:hover, .rail-chip:focus-visible { border-color: var(--accent); }
/* ── Detail dialogs (timeframe + news) ────────────────────────── */
#tf-detail, #news-detail {
/* the global `* { margin: 0 }` reset wipes the UA stylesheet's `margin: auto`,
which is what centres a modal dialog — restore it explicitly. */
position: fixed; inset: 0; margin: auto;
width: min(1060px, 94vw); height: fit-content; max-height: 90vh;
padding: 0; overflow: hidden;
border: 1px solid var(--line-strong); border-radius: 12px;
background: var(--card); color: var(--ink);
font-family: var(--sans); font-size: 16px;
}
#news-detail { width: min(680px, 94vw); }
#tf-detail::backdrop, #news-detail::backdrop { background: rgba(var(--ink-rgb),0.5); }
#tf-detail[open], #news-detail[open] { display: flex; flex-direction: column; }
#detail-head, #nd-head {
flex: 0 0 auto;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 14px 18px; background: var(--card2);
border-bottom: 1px solid var(--line-strong);
}
#detail-head .d-tf {
font-family: var(--mono); font-weight: 700; font-size: 20px; color: var(--accent-bright);
letter-spacing: 0.05em;
}
#detail-head .d-price { font-family: var(--mono); font-size: 19px; font-weight: 600; }
#detail-head .d-sub { font-size: 13px; color: var(--muted); }
#detail-close, #nd-close {
margin-left: auto; font: inherit; font-size: 15px; line-height: 1;
padding: 7px 13px; border-radius: 6px; cursor: pointer;
background: var(--card); color: var(--ink2); border: 1px solid var(--line-strong);
}
#detail-close:hover, #nd-close:hover { border-color: var(--accent); color: var(--accent-bright); }
#detail-body, #nd-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px 18px 20px; }
#nd-title {
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--muted); font-weight: 600;
}
.d-sec { margin-top: 20px; }
.d-sec > h3 {
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--muted); font-weight: 600; margin-bottom: 9px;
display: flex; align-items: center; gap: 10px;
}
.d-sec > h3::after { content: ""; flex: 1; height: 1px; background: var(--line); }
.d-action {
margin-top: 14px; padding: 11px 14px; border-radius: 8px;
background: var(--card2); border: 1px solid var(--line-strong);
font-size: 15px; font-weight: 600;
}
.d-action.buy { border-left: 4px solid var(--bull); }
.d-action.sell { border-left: 4px solid var(--bear); }
.d-action .why { display: block; font-weight: 400; font-size: 13.5px; color: var(--ink2); margin-top: 3px; }
.d-stats { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.d-stat {
background: var(--card2); border: 1px solid var(--line); border-radius: 7px; padding: 8px 11px;
}
.d-stat .k {
display: block; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--muted); font-weight: 600;
}
.d-stat .v { font-size: 17px; font-weight: 600; }
.d-stat .v.buy { color: var(--bull); }
.d-stat .v.sell { color: var(--bear-text); }
.d-stat .v.warn { color: var(--amber); }
.d-table { border-collapse: collapse; width: 100%; font-size: 14px; }
.d-table th {
text-align: left; font-size: 11px; letter-spacing: 0.09em; text-transform: uppercase;
color: var(--muted); font-weight: 600; padding: 4px 10px 6px 0;
border-bottom: 1px solid var(--line-strong); white-space: nowrap;
}
.d-table td { padding: 6px 10px 6px 0; border-bottom: 1px solid var(--line); vertical-align: top; }
.d-table tr:last-child td { border-bottom: none; }
.d-table .ind-k { font-family: var(--mono); font-weight: 600; white-space: nowrap; }
.d-table .desc { color: var(--ink2); font-size: 13.5px; }
.d-table td.num { font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
.d-table th.num { text-align: right; }
.d-table td.num.r { text-align: right; }
.sig-txt { font-weight: 700; white-space: nowrap; }
.sig-txt.buy { color: var(--bull); }
.sig-txt.sell { color: var(--bear-text); }
.sig-txt.neutral { color: var(--muted); }
.wbar { display: block; height: 4px; border-radius: 2px; background: var(--line-strong); margin-top: 3px; }
.wbar > i { display: block; height: 100%; border-radius: 2px; background: var(--accent); }
/* price ladder */
.ladder { border-collapse: collapse; width: 100%; font-size: 14px; }
.ladder th {
text-align: left; font-size: 11px; letter-spacing: 0.09em; text-transform: uppercase;
color: var(--muted); font-weight: 600; padding: 4px 10px 6px 0;
border-bottom: 1px solid var(--line-strong); white-space: nowrap;
}
.ladder th.num { text-align: right; }
.ladder td { padding: 4px 10px 4px 0; border-bottom: 1px solid var(--line); }
.ladder .lv-name { color: var(--ink2); }
.ladder .num { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
.ladder .dist { color: var(--muted); font-size: 13px; }
.ladder .role { font-size: 12.5px; color: var(--muted); white-space: nowrap; }
.ladder tr.above .role { color: var(--bear-text); }
.ladder tr.below .role { color: var(--bull); }
.ladder tr.here td {
background: var(--accent-wash); font-weight: 700; color: var(--accent-bright);
border-top: 1px solid var(--accent); border-bottom: 1px solid var(--accent);
}
.ladder tr.here .role { color: var(--accent-bright); }
.d-sec details {
border: 1px solid var(--line); border-radius: 7px; padding: 8px 11px; margin-bottom: 7px;
background: var(--card2);
}
.d-sec details summary { cursor: pointer; font-size: 14px; user-select: none; }
.d-sec details[open] summary { margin-bottom: 9px; padding-bottom: 7px; border-bottom: 1px solid var(--line); }
.d-sec details .d-table, .d-sec details .ladder { background: var(--card); }
.d-note { font-size: 13.5px; color: var(--ink2); }
.d-note b { color: var(--ink); }
.sub-h { font-size: 12px; color: var(--muted); font-weight: 600; margin: 11px 0 5px; }
/* news detail dialog */
.nd-headline { font-size: 18px; font-weight: 650; line-height: 1.35; margin-top: 14px; }
.nd-meta { font-size: 12.5px; color: var(--muted); margin-top: 5px; }
a.d-action { display: block; text-decoration: none; color: var(--accent-bright); }
a.d-action:hover { border-color: var(--accent); }
/* ── 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: 13px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--ink2); font-weight: 600;
}
.panel-head .note { font-size: 12px; color: var(--muted); }
.panel-head label {
margin-left: auto; font-size: 13px; color: var(--ink2);
display: inline-flex; gap: 6px; align-items: center; cursor: pointer;
}
.panel-head input[type="checkbox"] { accent-color: var(--accent); }
.table-scroll { overflow-x: auto; }
#cal-table { border-collapse: collapse; width: 100%; min-width: 600px; }
#cal-table th {
text-align: left; font-size: 11.5px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--muted); font-weight: 600;
padding: 4px 10px 7px 0; border-bottom: 1px solid var(--line-strong);
}
#cal-table td { padding: 6px 10px 6px 0; font-size: 14px; border-bottom: 1px solid var(--line); }
#cal-table tr.day-row td {
padding-top: 12px; font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--accent-bright); 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(--accent); padding-left: 7px; }
#cal-table tr[data-ev-key] { cursor: pointer; }
#cal-table tr[data-ev-key]:hover td { background: var(--accent-wash); }
#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: 11.5px; font-weight: 700; letter-spacing: 0.05em;
padding: 2px 8px; border-radius: 999px; white-space: nowrap;
}
.imp.high { background: var(--bear-wash); color: var(--bear-text); border: 1px solid rgba(var(--bear-rgb),0.4); }
.imp.medium { background: var(--amber-wash); color: var(--amber); border: 1px solid rgba(var(--amber-rgb),0.5); }
.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 aggregate score */
#wire-total {
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
padding: 9px 13px; margin-bottom: 10px; border-radius: 8px;
background: var(--card2); border: 1px solid var(--line);
}
#wire-total.pos { background: var(--bull-wash); border-color: rgba(var(--bull-rgb),0.4); }
#wire-total.neg { background: var(--bear-wash); border-color: rgba(var(--bear-rgb),0.4); }
.wt-k {
font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--muted); font-weight: 600;
}
.wt-v {
font-family: var(--mono); font-variant-numeric: tabular-nums;
font-size: 26px; font-weight: 700; line-height: 1;
}
.wt-v.pos { color: var(--bull); }
.wt-v.neg { color: var(--bear-text); }
.wt-v.zero { color: var(--muted); }
.wt-dir { font-size: 13px; font-weight: 700; letter-spacing: 0.04em; }
.wt-dir.pos { color: var(--bull); }
.wt-dir.neg { color: var(--bear-text); }
.wt-dir.zero { color: var(--muted); }
.wt-sub { margin-left: auto; text-align: right; font-size: 12.5px; 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-item[data-news-id] { cursor: pointer; padding: 7px 6px; margin: 0 -6px; border-radius: 6px; }
.wire-item[data-news-id]:hover { background: var(--accent-wash); }
.wire-score {
font-family: var(--mono); font-size: 13px; font-weight: 700;
padding: 2px 7px; 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: 14px; color: var(--ink2); flex: 1; }
.wire-meta { font-size: 12px; color: var(--muted); white-space: nowrap; }
.empty { color: var(--muted); font-size: 14px; padding: 9px 0; }
footer {
margin-top: 32px; font-size: 12.5px; color: var(--muted);
border-top: 1px solid var(--line); padding-top: 13px;
}
:focus-visible { outline: 2px solid var(--accent); 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>
<button id="theme-toggle" type="button" aria-label="Toggle dark mode">◐</button>
</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 <span style="text-transform:none;letter-spacing:0;font-weight:400">— select one for full detail</span></div>
<main id="tf-grid"></main>
<dialog id="tf-detail" aria-labelledby="detail-title">
<div id="detail-head">
<span class="d-tf" id="detail-title">—</span>
<span class="d-price num" id="detail-price">—</span>
<span class="d-sub" id="detail-sub"></span>
<button id="detail-close" type="button" aria-label="Close detail view">Close ✕</button>
</div>
<div id="detail-body"></div>
</dialog>
<dialog id="news-detail" aria-labelledby="nd-title">
<div id="nd-head">
<span id="nd-title">News detail</span>
<button id="nd-close" type="button" aria-label="Close news detail">Close ✕</button>
</div>
<div id="nd-body"></div>
</dialog>
<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 id="wire-total" hidden></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"];
/* 5 s polling is cheap because fetchJSON uses conditional GETs — Apache
answers unchanged files with 304, so most polls transfer no body. */
const POLL_MS = 5000;
let snapshot = null, news = null;
let clockOffset = 0; // serverEpoch - localEpoch
let lastPollOk = null; // local Date of last successful poll
const $ = id => document.getElementById(id);
/* ── theme (dark mode = every base colour with LUMA exactly inverted,
see the :root[data-theme="dark"] block in <style>) ─────────────── */
(function () {
const KEY = "xau-theme";
const sysDark = matchMedia("(prefers-color-scheme: dark)");
const stored = () => localStorage.getItem(KEY);
const apply = theme => {
document.documentElement.setAttribute("data-theme", theme);
$("theme-toggle").textContent = theme === "dark" ? "☀" : "☾";
$("theme-toggle").title = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
};
apply(stored() || (sysDark.matches ? "dark" : "light"));
sysDark.addEventListener("change", e => { if (!stored()) apply(e.matches ? "dark" : "light"); });
$("theme-toggle").addEventListener("click", () => {
const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark";
localStorage.setItem(KEY, next);
apply(next);
});
})();
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" data-tf="${tf}" tabindex="0" role="button" aria-haspopup="dialog"
title="${esc(sigLabel(sig))} (score ${esc(score)}) — click for full ${TF_LABEL[tf]} detail">
<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 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 renderCards() {
if (!snapshot) return;
$("tf-grid").innerHTML = TF_ORDER.map(tf => {
const entry = snapshot.timeframes[tf] || {};
const r = entry.report;
const open = `<article class="tf-card" data-tf="${tf}" tabindex="0" role="button"
aria-haspopup="dialog" aria-label="${TF_LABEL[tf]} timeframe — open full detail">`;
const foot = `<div class="tf-foot">
<span>${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""}</span>
<span class="cd">closes in
<span class="num" data-close="${entry.next_close_epoch || ""}">--:--</span></span></div>`;
if (!r) {
return `${open}<div class="tf-head"><span class="tf-name">${TF_LABEL[tf]}</span>
<span class="spacer"></span><span class="open-hint">details ›</span></div>
<div class="warming">${entry.status === "error"
? `<span class="err-badge" title="${esc(entry.error || "")}">SCAN ERROR</span>`
: "warming up…"}</div>${foot}</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="tf-head">
<span class="tf-name">${TF_LABEL[tf]}</span>
<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="spacer"></span>
${entry.status === "error" ? `<span class="err-badge" title="${esc(entry.error || "")} — showing last good scan">STALE</span>` : ""}
<span class="open-hint">details ›</span>
</div>
${scoreBar("score", pa ? pa.composite_score : r.score)}
<div class="regime">${esc(meta)}</div>
${foot}</article>`;
}).join("");
}
/* ── timeframe detail dialog ──────────────────────────────────── */
const LEVEL_LABEL = {
ema_fast: "EMA fast", ema_medium: "EMA medium", ema_slow: "EMA slow", ema_trend: "EMA trend",
bb_upper: "Bollinger upper", bb_mid: "Bollinger mid", bb_lower: "Bollinger lower",
kc_upper: "Keltner upper", kc_mid: "Keltner mid", kc_lower: "Keltner lower",
swing_high_1: "Swing high 1", swing_high_2: "Swing high 2", swing_high_3: "Swing high 3",
swing_low_1: "Swing low 1", swing_low_2: "Swing low 2", swing_low_3: "Swing low 3",
};
const dlg = $("tf-detail");
let detailTf = null;
const signed = (v, dp = 2) => v == null ? "—" : (v >= 0 ? "+" : "") + Number(v).toFixed(dp);
const tile = (k, v, cls = "") =>
`<div class="d-stat"><span class="k">${esc(k)}</span><span class="v ${cls}">${v}</span></div>`;
const sec = (title, html) => html ? `<section class="d-sec"><h3>${esc(title)}</h3>${html}</section>` : "";
/* full indicator table — value / confidence / weight / description, all nine */
function indTable(src) {
const rows = IND_ORDER.map(name => {
const i = src?.[name];
const k = `<td class="ind-k">${IND_SHORT[name]}</td>`;
if (!i) return `<tr>${k}<td class="sig-txt neutral">— absent</td>
<td class="num r">—</td><td class="num r">—</td><td class="num r">—</td>
<td class="desc">no signal this scan</td></tr>`;
const w = i.weight == null ? null : +i.weight;
return `<tr>${k}
<td class="sig-txt ${sigClass(i.signal)}">${esc(sigLabel(i.signal))}</td>
<td class="num r">${i.value != null ? (+i.value).toFixed(2) : "—"}</td>
<td class="num r">${i.confidence != null ? Math.round(i.confidence) : "—"}</td>
<td class="num r">${w != null ? w.toFixed(1) : "—"}${w != null
? `<i class="wbar"><i style="width:${Math.min(100, w / 2 * 100)}%"></i></i>` : ""}</td>
<td class="desc">${esc(i.description || "")}</td></tr>`;
}).join("");
return `<table class="d-table"><thead><tr>
<th>Indicator</th><th>Signal</th><th class="num">Value</th><th class="num">Conf</th>
<th class="num">Weight</th><th>Reading</th></tr></thead><tbody>${rows}</tbody></table>`;
}
/* price ladder — every level sorted high→low, spot slotted into its true position.
No up/down glyphs: position relative to the highlighted spot row says it all,
and each row is labelled resistance (above spot) or support (below). */
function ladder(levels, price) {
if (!levels) return "";
const atr = levels.atr;
const rows = Object.keys(LEVEL_LABEL)
.filter(k => levels[k] != null)
.map(k => ({label: LEVEL_LABEL[k], v: +levels[k]}));
if (!rows.length) return "";
if (price != null) rows.push({here: true, v: +price});
rows.sort((a, b) => b.v - a.v);
const body = rows.map(row => {
if (row.here) return `<tr class="here"><td class="lv-name">current price</td>
<td class="num">${fmt$(row.v)}</td><td class="num dist">—</td><td class="role">spot</td></tr>`;
const d = price != null ? row.v - price : null;
return `<tr class="${d == null ? "" : d > 0 ? "above" : "below"}">
<td class="lv-name">${esc(row.label)}</td>
<td class="num">${fmt$(row.v)}</td>
<td class="num dist">${d == null ? "" : signed(d) +
(atr ? ` · ${signed(d / atr, 2)} ATR` : "")}</td>
<td class="role">${d == null ? "" : d > 0 ? "resistance" : "support"}</td></tr>`;
}).join("");
return `<table class="ladder"><thead><tr><th>Level</th><th class="num">Price</th>
<th class="num">vs. spot</th><th>Role</th></tr></thead><tbody>${body}</tbody></table>`;
}
/* one analysis object (primary or a confirmation timeframe) rendered in full */
function analysisBlock(a, price) {
if (!a) return "";
const pats = a.pattern_signals?.length
? `<div class="patterns" style="margin-top:9px">${a.pattern_signals.map(p =>
`<span class="pat-tag">${esc(p)}</span>`).join("")}</div>` : "";
const atr = a.atr_context
? `<div class="atr-line" style="margin-top:9px">${esc(a.atr_context.description ||
"ATR " + (+a.atr_context.value).toFixed(2))}</div>` : "";
const lv = ladder(a.key_levels, price);
return indTable(a.indicators) + atr + pats +
(lv ? `<div class="sub-h">Key levels</div>${lv}` : "");
}
function listBlock(r) {
const group = (title, arr, cls) => arr?.length
? `<div class="sub-h">${title} (${arr.length})</div>
<div class="reasons">${arr.map(t => `<div class="${cls}">${esc(t)}</div>`).join("")}</div>` : "";
return group("Warnings", r.warnings, "r-warn") +
group("Bullish", r.bullish_reasons, "r-bull") +
group("Bearish", r.bearish_reasons, "r-bear");
}
function detailHTML(tf) {
const entry = snapshot?.timeframes?.[tf] || {};
const r = entry.report;
if (!r) return `<div class="warming">${entry.status === "error"
? `Scan error — ${esc(entry.error || "unknown")}`
: "Warming up — no completed scan for this timeframe yet."}</div>`;
const pa = r.primary_analysis;
const price = r.current_price;
const cls = sigClass(r.signal);
/* verdict */
let out = `<div class="d-action ${cls}">${esc(r.action || sigLabel(r.signal))}
${r.news_description || r.event_description
? `<span class="why">${esc([r.event_description, r.news_description].filter(Boolean).join(" · "))}</span>`
: ""}</div>`;
/* headline stats */
const ns = r.news_sentiment;
out += sec("At a glance", `<div class="d-stats">
${tile("Signal", esc(sigLabel(r.signal)), cls)}
${tile("Score", signed(r.score, 1), cls)}
${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
${tile("Quality", esc(r.quality || "—"))}
${tile("Regime", esc(r.market_regime || "—"))}
${tile("Volatility", esc(r.volatility_regime || "—"))}
${tile("Event risk", esc((r.event_risk || "—").toUpperCase()),
r.event_risk === "clear" ? "buy" : r.event_risk ? "warn" : "")}
${tile("News sentiment", ns != null ? signed(ns, 2) : "—",
ns > 0.05 ? "buy" : ns < -0.05 ? "sell" : "")}
${tile("MTF", r.mtf_agreement ? "aligned" : "mixed", r.mtf_agreement ? "buy" : "")}
${tile("Primary score", signed(r.primary_score, 1), sigClass(r.primary_signal))}
${tile("Exhaustion", esc(r.exhaustion_count ?? "—"), r.exhaustion_count ? "warn" : "")}
</div>`);
/* trade setup */
out += sec("Trade setup", r.trade_setup
? setupCard(r.trade_setup)
: `<div class="d-note">No setup published for this timeframe — the engine only emits
entry/stop/target once a signal clears its quality gate.</div>`);
/* reasoning */
out += sec("Why this signal", listBlock(r) ||
`<div class="d-note">No reasons recorded on this scan.</div>`);
/* primary analysis — fall back to the report-level indicators if absent */
out += sec(`${TF_LABEL[tf]} indicators (primary)`,
analysisBlock(pa, price) || indTable(r.indicators) +
(r.key_levels ? `<div class="sub-h">Key levels</div>${ladder(r.key_levels, price)}` : ""));
/* multi-timeframe */
const mtf = Object.entries(r.mtf_signals || {});
if (mtf.length) {
const chips = mtf.map(([k, sig]) => {
const s = r.mtf_scores?.[k];
return `<span class="mtf-chip">${esc(TF_LABEL[k] || k)}
<span class="sig-txt ${sigClass(sig)}">${esc(sigLabel(sig))}</span>
<span class="num">${s != null ? signed(s, 1) : ""}</span></span>`;
}).join("");
const confs = (r.confirmation_analyses || []).filter(Boolean).map(a => {
const lbl = TF_LABEL[a.timeframe] || a.timeframe;
return `<details data-dkey="conf-${esc(a.timeframe)}">
<summary><b>${esc(lbl)}</b>
<span class="sig-txt ${sigClass(a.composite_signal)}">${esc(sigLabel(a.composite_signal))}</span>
<span style="color:var(--muted)">${signed(a.composite_score, 1)} ·
conf ${a.confidence != null ? Math.round(a.confidence) : "—"} ·
${esc(a.market_regime || "")}</span></summary>
${analysisBlock(a, price)}</details>`;
}).join("");
out += sec("Multi-timeframe confirmation",
`<div class="mtf-row" style="margin-bottom:10px">${chips}
<span class="agree ${r.mtf_agreement ? "yes" : "no"}">${
r.mtf_agreement ? "✓ aligned" : "✗ mixed"}</span></div>${confs}`);
}
/* news */
if (r.news_summary || r.news_description) {
out += sec("News context", `<div class="d-note">
${r.news_description ? `<b>${esc(r.news_description)}</b>${ns != null
? ` (${signed(ns, 2)})` : ""}<br>` : ""}
${esc(r.news_summary || "")}</div>`);
}
/* AI agent panel (advisory) */
if (r.panel_verdict) {
const vcls = r.panel_verdict === "AGREE" ? "buy"
: r.panel_verdict === "OPPOSE" ? "sell" : "neutral";
const takes = (r.panel_takes || []).map(t =>
`<tr><td class="ind-k">${esc(t.persona)}</td>
<td>${esc(t.stance)}</td>
<td class="num">${t.confidence != null ? Math.round(t.confidence) : "—"}</td>
<td>${esc(t.note || "")}</td></tr>`).join("");
out += sec("AI panel (advisory)", `<div class="d-note" style="margin-bottom:8px">
<b class="sig-txt ${vcls}">${esc(r.panel_verdict)}</b> ·
conf ${Math.round(r.panel_confidence || 0)}%<br>
${esc(r.panel_rationale || "")}</div>
${takes ? `<table class="d-table"><tbody>${takes}</tbody></table>` : ""}`);
}
/* provenance */
out += sec("Scan detail", `<table class="d-table"><tbody>
<tr><td class="ind-k">Instrument</td><td colspan="5">${esc(r.display_name || r.symbol || "—")}</td></tr>
<tr><td class="ind-k">Candle</td><td colspan="5" class="num">${esc(r.timestamp || "—")}</td></tr>
<tr><td class="ind-k">Scanned</td><td colspan="5">${esc(entry.scanned_at || "—")}
(${fmtAge(entry.scanned_at)}${entry.scan_seconds ? `, took ${entry.scan_seconds}s` : ""})</td></tr>
<tr><td class="ind-k">Status</td><td colspan="5">${esc(entry.status || "—")}${entry.error
? ` — <span style="color:var(--bear-text)">${esc(entry.error)}</span>
${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}</td></tr>
</tbody></table>`);
return out;
}
function renderDetail() {
if (!detailTf || !dlg.open) return;
const entry = snapshot?.timeframes?.[detailTf] || {};
const r = entry.report;
const openKeys = new Set([...dlg.querySelectorAll("details[open][data-dkey]")]
.map(d => d.dataset.dkey));
const scroll = $("detail-body").scrollTop;
$("detail-title").textContent = TF_LABEL[detailTf] || detailTf;
$("detail-price").textContent = r?.current_price ? fmt$(r.current_price) : "—";
$("detail-sub").innerHTML = `${snapshot?.symbol ? esc(snapshot.symbol) + " · " : ""}closes in
<span class="num" data-close="${entry.next_close_epoch || ""}">--:--</span>`;
$("detail-body").innerHTML = detailHTML(detailTf);
dlg.querySelectorAll("details[data-dkey]").forEach(d => {
if (openKeys.has(d.dataset.dkey)) d.open = true;
});
$("detail-body").scrollTop = scroll;
tick();
}
function openDetail(tf) {
if (!TF_LABEL[tf]) return;
detailTf = tf;
if (!dlg.open) dlg.showModal();
renderDetail();
$("detail-body").scrollTop = 0;
$("detail-close").focus();
}
$("detail-close").addEventListener("click", () => dlg.close());
dlg.addEventListener("close", () => { detailTf = null; });
/* click on the backdrop (outside the dialog box) closes */
dlg.addEventListener("click", e => {
if (e.target !== dlg) return;
const b = dlg.getBoundingClientRect();
if (e.clientX < b.left || e.clientX > b.right || e.clientY < b.top || e.clientY > b.bottom) dlg.close();
});
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; // let inner controls keep their own keys
e.preventDefault();
openDetail(el.dataset.tf);
});
}
wireOpeners($("tf-grid"));
wireOpeners($("hero-rail"));
/* ── 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" : ""}"
data-ev-key="${esc(evKey(e))}" tabindex="0" role="button" aria-haspopup="dialog">
<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 ─────────────────────────────────────────────────── */
/* aggregate score across the WHOLE feed, not just the 40 rows rendered below */
function renderWireTotal() {
const box = $("wire-total");
const scored = (news?.headlines || []).filter(h => typeof h.score === "number");
if (!scored.length) { box.hidden = true; return; }
box.hidden = false;
const sum = scored.reduce((a, h) => a + h.score, 0);
const mean = sum / scored.length;
const up = scored.filter(h => h.score > 0.05).length;
const dn = scored.filter(h => h.score < -0.05).length;
const cls = mean > 0.05 ? "pos" : mean < -0.05 ? "neg" : "zero";
const dir = cls === "pos" ? "BULLISH" : cls === "neg" ? "BEARISH" : "NEUTRAL";
box.className = cls === "zero" ? "" : cls;
box.innerHTML = `<span class="wt-k">Total news score</span>
<span class="wt-v ${cls}">${signed(mean, 2)}</span>
<span class="wt-dir ${cls}">${dir}</span>
<span class="wt-sub">${scored.length} headlines · sum ${signed(sum, 1)}<br>
${up} bullish · ${dn} bearish · ${scored.length - up - dn} neutral</span>`;
box.title = `Mean sentiment of all ${scored.length} scored headlines (−1…+1). ` +
`Sum ${signed(sum, 1)}. Bullish/bearish counted at |score| > 0.05.`;
}
function renderWire() {
if (!news) return;
const items = (news.headlines || []).slice(0, 40);
$("wire-note").textContent = news.generated_at ? `updated ${fmtAge(news.generated_at)}` : "";
renderWireTotal();
if (!items.length) {
$("wire").innerHTML = `<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 {}
/* data-news-id only when the payload carries an id — after a daemon
restart the new page briefly meets a pre-upgrade news.json */
const open = h.id ? ` data-news-id="${h.id}" tabindex="0" role="button" aria-haspopup="dialog"` : "";
return `<div class="wire-item"${open}>
<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(h.publisher || 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>` : "");
}
/* ── news / calendar detail dialog ────────────────────────────── */
const ndlg = $("news-detail");
let ndState = null; // {kind:"wire", id, snap} | {kind:"cal", key, snap}
const aiMemo = new Map(); // headline id -> {summary, generated_at} (successes only)
const aiErr = new Map(); // headline id -> error code (cleared on reopen → retry)
const aiPending = new Set();
/* stable across polls — the array index is not (new headlines shift it) */
const evKey = e => `${e.time}|${e.currency}|${e.event}`;
const findHeadline = id => (news?.headlines || []).find(h => h.id === id);
const findEvent = key => (news?.events || []).find(e => evKey(e) === key);
const AI_ERR_MSG = {
unknown_id: "This headline has left the live feed — no summary available.",
cap_exceeded: "Daily AI budget reached — try again tomorrow.",
no_key: "AI summaries are not configured on this server.",
};
async function fetchAiSummary(id) {
aiPending.add(id);
try {
const res = await fetch("news_summary.php?id=" + encodeURIComponent(id));
const data = await res.json().catch(() => null);
if (res.ok && data?.summary) aiMemo.set(id, data);
else aiErr.set(id, data?.error || `http_${res.status}`);
} catch {
aiErr.set(id, "network");
} finally {
aiPending.delete(id);
if (ndState?.kind === "wire" && ndState.id === id) renderNewsDlg();
}
}
function scoreTiles(h) {
const cls = v => v > 0.05 ? "buy" : v < -0.05 ? "sell" : "";
let tiles = tile("Overall sentiment", signed(h.score, 2), cls(h.score));
const xau = h.score_by_currency?.XAU;
if (xau != null) tiles += tile("Gold (XAU) effect", signed(xau, 2), cls(xau));
for (const [cur, v] of Object.entries(h.score_by_currency || {})) {
if (cur !== "XAU") tiles += tile(cur, signed(v, 2), cls(v));
}
let out = `<div class="d-stats">${tiles}</div>`;
if (h.keywords?.length) {
out += `<div class="nd-meta">Matched keywords: ${esc(h.keywords.join(", "))}</div>`;
}
out += `<div class="nd-meta">Scores range −1 (bearish) to +1 (bullish); the gold
score flips monetary-policy keywords (hawkish = dollar-bullish, gold-bearish).</div>`;
return out;
}
function aiSection(id) {
if (aiMemo.has(id)) {
const m = aiMemo.get(id);
return `<div class="d-note">${esc(m.summary)}</div>` + (m.generated_at
? `<div class="nd-meta">generated ${fmtAge(m.generated_at)}</div>` : "");
}
if (aiPending.has(id)) return `<div class="d-note">Generating gold-impact summary…</div>`;
if (aiErr.has(id)) {
return `<div class="d-note">${esc(AI_ERR_MSG[aiErr.get(id)] || "AI summary unavailable right now.")}</div>`;
}
return `<div class="d-note">Generating gold-impact summary…</div>`;
}
function wireDlgHTML() {
const fresh = findHeadline(ndState.id);
const h = fresh || ndState.snap;
if (!h) return "";
let host = "";
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
const when = h.timestamp
? `${new Date(h.timestamp).toLocaleString(undefined,
{dateStyle: "medium", timeStyle: "short"})} · ${fmtAge(h.timestamp)}` : "";
let out = `<div class="nd-headline">${esc(h.headline)}</div>
<div class="nd-meta">${esc(h.publisher || host)}${when ? " · " + when : ""}</div>`;
if (!fresh) out += `<div class="nd-meta">No longer in the live feed — showing saved copy.</div>`;
out += sec("Score", scoreTiles(h));
if (h.description) out += sec("From the source", `<div class="d-note">${esc(h.description)}</div>`);
out += sec("AI gold-impact summary", aiSection(ndState.id));
if (h.link && /^https?:\/\//.test(h.link)) {
out += `<a class="d-action" href="${esc(h.link)}" target="_blank"
rel="noopener noreferrer">Read full article ↗</a>`;
}
return out;
}
function calDlgHTML() {
const fresh = findEvent(ndState.key);
const e = fresh || ndState.snap;
if (!e) return "";
const t = Date.parse(e.time) / 1000;
const when = new Date(e.time).toLocaleString(undefined, {dateStyle: "full", timeStyle: "short"});
const impact = `<span class="imp ${esc(e.impact)}">${e.impact === "medium" ? "Med"
: e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span>`;
let out = `<div class="nd-headline">${esc(e.event)}</div>
<div class="nd-meta">${esc(e.currency)} · ${when}</div>`;
/* live countdown only for FUTURE events — tick()'s modulo wrap on a
passed epoch would show a bogus restarting timer for released ones */
const timing = t > nowServer()
? `releases in <span class="num" data-close="${t}">--:--</span>`
: `released ${fmtAge(e.time)}`;
out += sec("Timing", `<div class="d-note">${impact} impact · ${timing}</div>`);
const surpTxt = {positive: "actual above forecast", negative: "actual below forecast",
inline: "actual matches forecast"}[e.surprise];
out += sec("Numbers", `<div class="d-stats">${
tile("Forecast", esc(e.forecast ?? "—")) +
tile("Previous", esc(e.previous ?? "—")) +
tile("Actual", esc(e.actual ?? "—"),
e.surprise === "positive" ? "buy" : e.surprise === "negative" ? "sell" : "")
}</div>` + (surpTxt
? `<div class="nd-meta">${surpTxt} (numeric only — not gold direction)</div>` : ""));
return out;
}
function renderNewsDlg() {
if (!ndState || !ndlg.open) return;
const scroll = $("nd-body").scrollTop;
$("nd-title").textContent = ndState.kind === "wire" ? "Newswire" : "Economic calendar";
$("nd-body").innerHTML = ndState.kind === "wire" ? wireDlgHTML() : calDlgHTML();
$("nd-body").scrollTop = scroll;
tick();
}
function openNewsDetail(id) {
const h = findHeadline(id);
if (!h) return;
ndState = {kind: "wire", id, snap: h};
aiErr.delete(id); // failures retry on reopen; successes are memoised
if (!ndlg.open) ndlg.showModal();
if (!aiMemo.has(id) && !aiPending.has(id)) fetchAiSummary(id);
renderNewsDlg();
$("nd-body").scrollTop = 0;
$("nd-close").focus();
}
function openCalDetail(key) {
const e = findEvent(key);
if (!e) return;
ndState = {kind: "cal", key, snap: e};
if (!ndlg.open) ndlg.showModal();
renderNewsDlg();
$("nd-body").scrollTop = 0;
$("nd-close").focus();
}
$("nd-close").addEventListener("click", () => ndlg.close());
ndlg.addEventListener("close", () => { ndState = null; });
ndlg.addEventListener("click", e => {
if (e.target !== ndlg) return;
const b = ndlg.getBoundingClientRect();
if (e.clientX < b.left || e.clientX > b.right || e.clientY < b.top || e.clientY > b.bottom) ndlg.close();
});
/* Delegated on the stable containers (renderWire/renderCalendar replace
innerHTML every poll). Deliberately NOT wireOpeners(): its details/
summary guard would swallow the 28 wire items nested in the
"more headlines" <details>. A click on that toggle itself finds no
[data-news-id] ancestor and falls through to the native behaviour. */
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; // let inner controls keep their own keys
e.preventDefault();
open(el.getAttribute(attr));
});
}
wireNewsOpeners($("wire"), "data-news-id", openNewsDetail);
wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail);
/* ── polling ──────────────────────────────────────────────────── */
async function fetchJSON(name) {
/* no-cache = revalidate with the server every time (If-Modified-Since);
unchanged files come back 304 with no body, so aggressive polling
stays nearly free. */
const res = await fetch(name, {cache: "no-cache"});
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();
renderDetail(); // keep an open detail view in sync with the new scan
renderNewsDlg(); // same for an open news/calendar dialog
tick(); // countdowns appear immediately, not after the first 1 s tick
/* one-shot refetch shortly after the next expected publish, so a
fast scan appears ~2 s after the publisher wakes instead of
waiting out the poll interval (the regular POLL_MS loop covers
slower scans) */
const nextScan = Math.min(...TF_ORDER.map(tf =>
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
if (isFinite(nextScan)) {
clearTimeout(refetchTimer);
const delay = (nextScan - nowServer() + 2) * 1000;
if (delay > 0) refetchTimer = setTimeout(poll, delay);
}
} catch (err) {
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
const b = $("banner-fail");
b.textContent = `Data feed unreachable (${err.message}).${last} Retrying…`;
b.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>