b21c75a1f8
Owner-supplied page design replacing the dark-only v1: Bootstrap 5
colour tokens with a luma-inverted dark mode (toggle + system-pref +
localStorage), compact clickable TF cards opening a full-detail modal
(all-nine indicator table with value/conf/weight, price ladder of key
levels vs spot, per-confirmation-TF drilldowns, provenance), newswire
aggregate sentiment box, Inter/JetBrains Mono webfonts.
Re-applied the c70f58a sync fixes the source copy predated: 5 s
conditional-GET polling and the +2 s boundary refetch sniper. Data
logic otherwise unchanged; all fields verified against
report_to_webapp_dict output.
Claude-Session: https://claude.ai/code/session_017mbXs9HRawZhD1uZ3ze7zQ
1205 lines
55 KiB
HTML
1205 lines
55 KiB
HTML
<!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); }
|
||
|
||
/* ── Timeframe detail dialog ──────────────────────────────────── */
|
||
#tf-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;
|
||
}
|
||
#tf-detail::backdrop { background: rgba(var(--ink-rgb),0.5); }
|
||
#tf-detail[open] { display: flex; flex-direction: column; }
|
||
#detail-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 {
|
||
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 { border-color: var(--accent); color: var(--accent-bright); }
|
||
#detail-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px 18px 20px; }
|
||
|
||
.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 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 .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-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>
|
||
|
||
<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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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>`);
|
||
}
|
||
|
||
/* 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" : ""}">
|
||
<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 {}
|
||
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) {
|
||
/* 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
|
||
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>
|