Web dashboard v2: static publisher + Apache, with calendar & news panels
- dashboard_publisher.py: standalone candle-close daemon that atomically writes snapshot.json/news.json/dashboard.html to an Apache-served dir (DASHBOARD_OUT_DIR). Fully independent of telegram_bot/oanda_daemon — local confirmation map + boundary math, no imports either way, no new deps, no open port. Due-TF classification from the RAW wake time. - static/dashboard.html: self-contained dark page — hero rail, four TF cards (score bar, 3x3 indicator grid, MTF, setup, reasons), DeepSeek market context, week economic calendar, scored newswire. Clock offset from the HTTP Date header (publish-time epoch is stale between closes). - news_engine.get_week_events(): additive read-only accessor over the cached FF week; no signal path calls it. - deploy/: systemd unit + live-server (LAMP/SELinux) checklist. - test_all.py: Module 20 (9 tests) + get_week_events test; 296 green. Claude-Session: https://claude.ai/code/session_01Wc9ZoqKLYBT2QJcpRWKoGm
This commit is contained in:
@@ -0,0 +1,773 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Gold Desk — XAU/USD</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23d4a72c'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
/* ── Gold Desk tokens ─────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #0f0e0b; /* page plane, warm near-black */
|
||||
--card: #17150f; /* card surface */
|
||||
--card2: #1d1b14; /* nested surface (setup, grid cells) */
|
||||
--line: rgba(242,239,230,0.08);
|
||||
--line-strong: rgba(242,239,230,0.16);
|
||||
--ink: #f2efe6;
|
||||
--ink2: #b5ae9c;
|
||||
--muted: #7d7768;
|
||||
--gold: #d4a72c; /* identity accent — never a data encoding */
|
||||
--gold-bright: #e6c35c;
|
||||
--gold-wash: rgba(212,167,44,0.10);
|
||||
--bull: #0ca30c; /* status good — always paired with arrow/text */
|
||||
--bull-wash: rgba(12,163,12,0.12);
|
||||
--bear: #d03b3b; /* status critical */
|
||||
--bear-text: #e66767; /* lighter step for small red text */
|
||||
--bear-wash: rgba(208,59,59,0.12);
|
||||
--amber: #fab219; /* warnings / medium impact */
|
||||
--amber-wash: rgba(250,178,25,0.10);
|
||||
--neutral-mid: #383835; /* diverging midpoint */
|
||||
--mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, Menlo, monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { background: var(--bg); }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
padding: 14px clamp(10px, 2.5vw, 28px) 40px;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── Eyebrow section labels ───────────────────────────────────── */
|
||||
.eyebrow {
|
||||
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--muted); font-weight: 600; margin: 26px 0 10px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.eyebrow::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||||
|
||||
/* ── Hero consensus rail ──────────────────────────────────────── */
|
||||
#hero {
|
||||
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
|
||||
padding: 14px 18px;
|
||||
background: linear-gradient(180deg, var(--card) 0%, var(--bg) 140%);
|
||||
border: 1px solid var(--line); border-radius: 10px;
|
||||
}
|
||||
#hero .symbol {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 15px;
|
||||
letter-spacing: 0.08em; color: var(--gold);
|
||||
}
|
||||
#hero .price {
|
||||
font-family: var(--mono); font-variant-numeric: tabular-nums;
|
||||
font-size: 30px; font-weight: 600; color: var(--gold-bright);
|
||||
line-height: 1;
|
||||
}
|
||||
#hero .rail { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.rail-chip {
|
||||
display: inline-flex; align-items: baseline; gap: 6px;
|
||||
font-family: var(--mono); font-size: 12.5px;
|
||||
padding: 5px 9px; border-radius: 6px;
|
||||
background: var(--card2); border: 1px solid var(--line);
|
||||
color: var(--ink2);
|
||||
}
|
||||
.rail-chip .tfname { color: var(--muted); font-weight: 600; }
|
||||
#hero .meta { margin-left: auto; text-align: right; font-size: 12px; color: var(--ink2); }
|
||||
#hero .meta .num { color: var(--ink); }
|
||||
.live-dot {
|
||||
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--bull); margin-right: 5px;
|
||||
animation: pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
.live-dot.closed { background: var(--amber); animation: none; }
|
||||
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.live-dot { animation: none; }
|
||||
* { transition: none !important; }
|
||||
}
|
||||
|
||||
/* ── Banners / toast ──────────────────────────────────────────── */
|
||||
.banner {
|
||||
margin-top: 10px; padding: 9px 14px; border-radius: 8px;
|
||||
font-size: 13px; display: none;
|
||||
}
|
||||
#banner-closed { background: var(--amber-wash); border: 1px solid rgba(250,178,25,0.35); color: var(--amber); }
|
||||
#banner-fail { background: var(--bear-wash); border: 1px solid rgba(208,59,59,0.4); color: var(--bear-text); }
|
||||
|
||||
/* ── Market context strip ─────────────────────────────────────── */
|
||||
#context {
|
||||
margin-top: 14px; padding: 12px 16px;
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||||
display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap;
|
||||
}
|
||||
#context .blurb { flex: 1 1 380px; color: var(--ink2); font-size: 13px; }
|
||||
#context .blurb b { color: var(--ink); font-weight: 600; }
|
||||
#context .chips { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; padding: 4px 9px; border-radius: 999px;
|
||||
border: 1px solid var(--line-strong); color: var(--ink2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip .num { font-size: 12px; }
|
||||
.chip.pos { border-color: rgba(12,163,12,0.45); background: var(--bull-wash); color: var(--ink); }
|
||||
.chip.neg { border-color: rgba(208,59,59,0.45); background: var(--bear-wash); color: var(--ink); }
|
||||
.chip.risk-clear { border-color: rgba(12,163,12,0.45); color: var(--bull); }
|
||||
.chip.risk-caution { border-color: rgba(250,178,25,0.5); background: var(--amber-wash); color: var(--amber); }
|
||||
.chip.risk-danger { border-color: rgba(208,59,59,0.55); background: var(--bear-wash); color: var(--bear-text); }
|
||||
|
||||
/* ── Timeframe cards ──────────────────────────────────────────── */
|
||||
#tf-grid {
|
||||
display: grid; gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(430px, 100%), 1fr));
|
||||
}
|
||||
.tf-card {
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 14px 16px 12px; display: flex; flex-direction: column; gap: 11px;
|
||||
}
|
||||
.tf-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.tf-name {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 15px;
|
||||
color: var(--gold); letter-spacing: 0.05em; min-width: 42px;
|
||||
}
|
||||
.sig-badge {
|
||||
font-weight: 700; font-size: 12.5px; letter-spacing: 0.04em;
|
||||
padding: 3px 10px; border-radius: 6px; border: 1px solid var(--line-strong);
|
||||
color: var(--ink2);
|
||||
}
|
||||
.sig-badge.buy { background: var(--bull-wash); border-color: rgba(12,163,12,0.5); color: var(--bull); }
|
||||
.sig-badge.sell { background: var(--bear-wash); border-color: rgba(208,59,59,0.55); color: var(--bear-text); }
|
||||
.q-chip {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 12px;
|
||||
padding: 3px 8px; border-radius: 6px;
|
||||
background: var(--card2); border: 1px solid var(--line-strong); color: var(--ink2);
|
||||
}
|
||||
.q-chip.hi { background: var(--gold-wash); border-color: rgba(212,167,44,0.5); color: var(--gold-bright); }
|
||||
.regime { font-size: 11.5px; color: var(--muted); }
|
||||
.tf-head .spacer { margin-left: auto; }
|
||||
.err-badge {
|
||||
font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 6px;
|
||||
background: var(--bear-wash); border: 1px solid rgba(208,59,59,0.5);
|
||||
color: var(--bear-text); cursor: help;
|
||||
}
|
||||
|
||||
/* score bar: zero-centered diverging, neutral midpoint tick */
|
||||
.scorebar { display: flex; align-items: center; gap: 10px; }
|
||||
.scorebar .track {
|
||||
position: relative; flex: 1; height: 8px; border-radius: 4px;
|
||||
background: var(--card2); overflow: hidden;
|
||||
}
|
||||
.scorebar .track::after { /* midpoint tick */
|
||||
content: ""; position: absolute; left: 50%; top: 0; bottom: 0;
|
||||
width: 2px; background: var(--neutral-mid); transform: translateX(-1px);
|
||||
}
|
||||
.scorebar .fill {
|
||||
position: absolute; top: 0; bottom: 0; border-radius: 4px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.scorebar .fill.pos { left: 50%; background: var(--bull); }
|
||||
.scorebar .fill.neg { right: 50%; background: var(--bear); }
|
||||
.scorebar .val { width: 52px; text-align: right; font-size: 13px; color: var(--ink); }
|
||||
.scorebar .lbl { font-size: 11px; color: var(--muted); width: 38px; }
|
||||
|
||||
/* 3×3 indicator grid */
|
||||
.ind-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
|
||||
}
|
||||
.ind-cell {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 9px; border-radius: 6px;
|
||||
background: var(--card2); border: 1px solid transparent;
|
||||
font-family: var(--mono); font-size: 12.5px; cursor: help;
|
||||
}
|
||||
.ind-cell:hover, .ind-cell:focus-visible { border-color: var(--line-strong); }
|
||||
.ind-cell .k { color: var(--ink2); }
|
||||
.ind-cell .arrow { font-size: 14px; font-weight: 700; }
|
||||
.ind-cell.buy .arrow { color: var(--bull); }
|
||||
.ind-cell.sell .arrow { color: var(--bear-text); }
|
||||
.ind-cell.neutral .arrow { color: var(--muted); }
|
||||
.ind-cell.absent { opacity: 0.45; }
|
||||
.ind-cell.absent .arrow { color: var(--muted); }
|
||||
.atr-line { font-size: 12px; color: var(--muted); }
|
||||
.atr-line .num { color: var(--ink2); }
|
||||
.patterns { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.pat-tag {
|
||||
font-size: 11px; font-family: var(--mono); padding: 2px 7px;
|
||||
border-radius: 5px; background: var(--gold-wash); color: var(--gold);
|
||||
border: 1px solid rgba(212,167,44,0.3);
|
||||
}
|
||||
|
||||
/* MTF row */
|
||||
.mtf-row {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
font-size: 12px; color: var(--ink2);
|
||||
}
|
||||
.mtf-row .agree { font-weight: 700; }
|
||||
.mtf-row .agree.yes { color: var(--bull); }
|
||||
.mtf-row .agree.no { color: var(--muted); }
|
||||
.mtf-chip {
|
||||
font-family: var(--mono); font-size: 11.5px; padding: 2px 7px;
|
||||
border-radius: 5px; background: var(--card2); border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* trade setup sub-card */
|
||||
.setup {
|
||||
border: 1px solid var(--line-strong); border-radius: 8px;
|
||||
padding: 10px 12px; background: var(--card2);
|
||||
}
|
||||
.setup.buy { border-left: 3px solid var(--bull); }
|
||||
.setup.sell { border-left: 3px solid var(--bear); }
|
||||
.setup .s-head {
|
||||
display: flex; align-items: baseline; gap: 8px; margin-bottom: 7px;
|
||||
font-weight: 700; font-size: 13px;
|
||||
}
|
||||
.setup .s-head .dir.buy { color: var(--bull); }
|
||||
.setup .s-head .dir.sell { color: var(--bear-text); }
|
||||
.setup .s-head .strat { font-family: var(--mono); font-size: 11px; color: var(--muted); font-weight: 400; }
|
||||
.setup table { border-collapse: collapse; width: 100%; }
|
||||
.setup td { padding: 2px 0; font-size: 12.5px; }
|
||||
.setup td:first-child { color: var(--muted); padding-right: 12px; white-space: nowrap; }
|
||||
.setup td.num { color: var(--ink); }
|
||||
|
||||
/* reasons & warnings */
|
||||
.reasons { font-size: 12.5px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.reasons .r-bull::before { content: "▲ "; color: var(--bull); }
|
||||
.reasons .r-bear::before { content: "▼ "; color: var(--bear-text); }
|
||||
.reasons .r-warn { color: var(--amber); }
|
||||
.reasons .r-warn::before { content: "⚠ "; }
|
||||
.reasons .r-bull, .reasons .r-bear { color: var(--ink2); }
|
||||
details.more { font-size: 12px; }
|
||||
details.more summary { cursor: pointer; color: var(--muted); user-select: none; }
|
||||
details.more summary:hover { color: var(--ink2); }
|
||||
details.more .reasons { margin-top: 4px; }
|
||||
|
||||
.tf-foot {
|
||||
display: flex; align-items: center; gap: 10px; margin-top: auto;
|
||||
padding-top: 8px; border-top: 1px solid var(--line);
|
||||
font-size: 11.5px; color: var(--muted);
|
||||
}
|
||||
.tf-foot .cd { margin-left: auto; }
|
||||
.tf-foot .cd .num { color: var(--ink2); }
|
||||
.warming { color: var(--muted); font-size: 13px; padding: 18px 0; text-align: center; }
|
||||
|
||||
/* confirmations (collapsible extra) */
|
||||
details.confirms summary {
|
||||
cursor: pointer; font-size: 11.5px; color: var(--muted); user-select: none;
|
||||
}
|
||||
details.confirms summary:hover { color: var(--ink2); }
|
||||
.confirm-line {
|
||||
font-family: var(--mono); font-size: 12px; color: var(--ink2);
|
||||
padding: 4px 0 0 2px;
|
||||
}
|
||||
|
||||
/* ── News section ─────────────────────────────────────────────── */
|
||||
#news-wrap { display: grid; gap: 14px; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); }
|
||||
@media (max-width: 1100px) { #news-wrap { grid-template-columns: 1fr; } }
|
||||
.panel {
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.panel-head { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||
.panel-head h2 {
|
||||
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--ink2); font-weight: 600;
|
||||
}
|
||||
.panel-head .note { font-size: 11px; color: var(--muted); }
|
||||
.panel-head label {
|
||||
margin-left: auto; font-size: 12px; color: var(--ink2);
|
||||
display: inline-flex; gap: 6px; align-items: center; cursor: pointer;
|
||||
}
|
||||
.table-scroll { overflow-x: auto; }
|
||||
#cal-table { border-collapse: collapse; width: 100%; min-width: 560px; }
|
||||
#cal-table th {
|
||||
text-align: left; font-size: 10.5px; letter-spacing: 0.1em;
|
||||
text-transform: uppercase; color: var(--muted); font-weight: 600;
|
||||
padding: 4px 10px 6px 0; border-bottom: 1px solid var(--line-strong);
|
||||
}
|
||||
#cal-table td { padding: 5px 10px 5px 0; font-size: 12.5px; border-bottom: 1px solid var(--line); }
|
||||
#cal-table tr.day-row td {
|
||||
padding-top: 10px; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--gold); font-weight: 600; border-bottom: none;
|
||||
}
|
||||
#cal-table tr.past { opacity: 0.55; }
|
||||
#cal-table tr.next-up td:first-child { box-shadow: inset 3px 0 0 var(--gold); padding-left: 7px; }
|
||||
#cal-table .t { white-space: nowrap; color: var(--ink2); }
|
||||
#cal-table .cur { font-family: var(--mono); color: var(--muted); }
|
||||
#cal-table .ev { color: var(--ink); }
|
||||
.imp {
|
||||
font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em;
|
||||
padding: 1.5px 7px; border-radius: 999px; white-space: nowrap;
|
||||
}
|
||||
.imp.high { background: var(--bear-wash); color: var(--bear-text); border: 1px solid rgba(208,59,59,0.45); }
|
||||
.imp.medium { background: var(--amber-wash); color: var(--amber); border: 1px solid rgba(250,178,25,0.4); }
|
||||
.imp.low { background: var(--card2); color: var(--muted); border: 1px solid var(--line-strong); }
|
||||
.surp { font-weight: 700; cursor: help; }
|
||||
.surp.positive { color: var(--bull); }
|
||||
.surp.negative { color: var(--bear-text); }
|
||||
.surp.inline { color: var(--muted); }
|
||||
|
||||
/* newswire */
|
||||
.wire { display: flex; flex-direction: column; }
|
||||
.wire-item {
|
||||
display: flex; gap: 10px; align-items: baseline;
|
||||
padding: 7px 0; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.wire-item:last-child { border-bottom: none; }
|
||||
.wire-score {
|
||||
font-family: var(--mono); font-size: 11.5px; font-weight: 700;
|
||||
padding: 1px 6px; border-radius: 5px; white-space: nowrap;
|
||||
}
|
||||
.wire-score.pos { background: var(--bull-wash); color: var(--bull); }
|
||||
.wire-score.neg { background: var(--bear-wash); color: var(--bear-text); }
|
||||
.wire-score.zero { background: var(--card2); color: var(--muted); }
|
||||
.wire-h { font-size: 12.5px; color: var(--ink2); flex: 1; }
|
||||
.wire-meta { font-size: 11px; color: var(--muted); white-space: nowrap; }
|
||||
.empty { color: var(--muted); font-size: 12.5px; padding: 8px 0; }
|
||||
|
||||
footer {
|
||||
margin-top: 30px; font-size: 11px; color: var(--muted);
|
||||
border-top: 1px solid var(--line); padding-top: 12px;
|
||||
}
|
||||
:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header id="hero">
|
||||
<div>
|
||||
<div class="symbol">XAU/USD</div>
|
||||
<div class="price num" id="hero-price">—</div>
|
||||
</div>
|
||||
<div class="rail" id="hero-rail" aria-label="Signal by timeframe"></div>
|
||||
<div class="meta">
|
||||
<div id="hero-status"><span class="live-dot"></span>…</div>
|
||||
<div>M5 close in <span class="num" id="hero-cd">--:--</span></div>
|
||||
<div id="hero-updated">updated —</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="banner" id="banner-closed">Market closed (weekend) — showing Friday's last state. Calendar and news stay live.</div>
|
||||
<div class="banner" id="banner-fail"></div>
|
||||
|
||||
<section id="context" hidden>
|
||||
<div class="blurb" id="ctx-blurb"></div>
|
||||
<div class="chips" id="ctx-chips"></div>
|
||||
</section>
|
||||
|
||||
<div class="eyebrow">Timeframes</div>
|
||||
<main id="tf-grid"></main>
|
||||
|
||||
<div class="eyebrow">Fundamentals</div>
|
||||
<div id="news-wrap">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Economic calendar</h2>
|
||||
<span class="note">times local · USD events</span>
|
||||
<label><input type="checkbox" id="hi-only"> high impact only</label>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table id="cal-table">
|
||||
<thead><tr><th>Time</th><th>Cur</th><th>Impact</th><th>Event</th>
|
||||
<th class="num">Fcst</th><th class="num">Prev</th><th class="num">Actual</th></tr></thead>
|
||||
<tbody id="cal-body"><tr><td colspan="7" class="empty">Loading calendar…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>Newswire</h2><span class="note" id="wire-note"></span></div>
|
||||
<div class="wire" id="wire"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Signals are generated on closed candles only and refresh at candle boundaries
|
||||
(M5/M15/H1/H4). Not financial advice — educational and research use only.
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
/* Mirrors telegram_bot._IND_SHORT / _IND_ARROW — kept as local constants
|
||||
by design: this page must stay independent of the bot. */
|
||||
const IND_ORDER = ["ema_trend","rsi","macd","bollinger","adx","roc","keltner","divergence","candlestick"];
|
||||
const IND_SHORT = {ema_trend:"EMA", rsi:"RSI", macd:"MACD", bollinger:"BB",
|
||||
adx:"ADX", roc:"ROC", keltner:"KC", divergence:"Div", candlestick:"Cndl"};
|
||||
const ARROW = {STRONG_BUY:"⇈", BUY:"↑", NEUTRAL:"→", SELL:"↓", STRONG_SELL:"⇊"};
|
||||
const TF_LABEL = {"5min":"M5", "15min":"M15", "1h":"H1", "4h":"H4"};
|
||||
const TF_ORDER = ["5min","15min","1h","4h"];
|
||||
const POLL_MS = 20000;
|
||||
|
||||
let snapshot = null, news = null;
|
||||
let clockOffset = 0; // serverEpoch - localEpoch
|
||||
let lastPollOk = null; // local Date of last successful poll
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s ?? "").replace(/[&<>"']/g,
|
||||
c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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>
|
||||
Reference in New Issue
Block a user