Files
ForexBot2/static/dashboard.js
T
Harwinder Sekhon 4e0a083955 Quant review 2026-09-26: alert cooldown, plain-language alerts, M15 grade-A SELL block, 1R printed targets, panel/breach fixes
Live signals.db review (2026-09-04 → 09-25, 100 unique setups replayed on the
scans' stored candles): the stop-hunt claim is refuted (71% of stop-outs ran
≥ 1R past the stop, the same as the books' 70–72%); −25.1R of the −37.8R raw
live book were consecutive-bar re-entries the backtester's 5-bar cooldown never
takes; the cooldown view sits at the 3rd–5th percentile of the books' three-week
windows.

Delivery (book-neutral):
- telegram_bot: 5-bar alert cooldown per timeframe (_ALERT_COOLDOWN_BARS =
  backtest.cooldown_bars); setup block prints the trail first as the tested
  exit, TP1/TP2 as guides with their R, "Planned TP1 RR", and one validated-book
  line per setup class (agent_panel_base_rates.json); the informational 100-pip
  TP line is removed (owner request).
- AI block rewritten for a new trader: "AI check: the odds look better than /
  worse than / about usual", the chance "in 100" against the usual odds, one
  reason for and one against, no verdict word or confidence figure; rendered
  whenever the verdict carries a probability. agent_panel: persona prompts ban
  trading jargon (PANEL_PROMPT_VERSION v2.1-2026-09-26); the macro-source
  verdict now carries its persona's p_win (every live v2 row had
  panel_probability NULL). Dashboard and guides mirror the wording.
- telegram_bot: level-breach rescans skip the settlement-pause window (the
  20:45 M15 bar was re-logged at 22:01/22:06/22:11 every evening).

Signal (pre-registered rule-1 campaign, CUDA twin paired with gpu_backtest.py):
- M15 grade-A SELLs blocked (sell_min_quality ["A+","B"], M15 override 6):
  W2 813/+154.15R PF 1.53 vs 931/+146.14R PF 1.43, OOS +68.5 vs +60.67,
  W3 max DD 20.9 vs 19.3R. PASSED a–f.
- Printed target 1 floored at 1R in every generator (signals.tp_print_floor_rr),
  applied AFTER the min-RR and spread gates so no trade changes; mirrored in
  cuda_signal_kernels.py K2. Label-only re-pin of all four timeframes (only
  tp1/rr_planned columns move). A floor applied before the gate cost −24.6R and
  was rejected.
- FAILED: M15 min_rr_ratio 1.0 (trade-identical, −2.0R). Owner's call: M5
  tp_floor_rr 1.0 (+48.9R W2, max DD 10.1 → 15.9R), not shipped.

New pins: M5 W2 e599c8a8…, M15 W2 5b65ecf6…, M15 OOS 09b8a8e7…, H1 W3
a44fd19b…, H4 W3 f3587bae…; twin and reference MD5s equal on every window.
Base-rate table rebuilt from the new pins. Suite 558 passed.

Evidence: backtest/research/rr_and_sell_campaign_2026-09-26.md (plus the
2026-09-18 loss-anatomy and wrong-direction reports it builds on).
2026-09-26 21:06:05 -04:00

1363 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/* Gold Desk — XAU/USD dashboard frontend
*
* Reads snapshot.json + news.json published by dashboard_publisher.py and
* renders the desk: a ticker bar, a direction board, one Bootstrap card per
* timeframe (M5, M15, H1, H4) in a responsive column grid, and the news and
* calendar context below it.
*
* Stack: Bootstrap 5 for layout and components, jQuery for DOM work and
* polling, Chart.js for the direction charts, Font Awesome for icons. No
* build step — this file is served as-is.
*
* Refresh: one coordinated loop. A poll is scheduled for the next candle
* boundary the snapshot announces, with a 60 s fallback and a 15 s retry, and
* only the parts whose data changed are redrawn (charts are updated in place,
* never rebuilt, so nothing flashes). It mirrors a few constants from the
* Python signal pipeline so the page never depends on telegram_bot.
*/
/* ── constants ────────────────────────────────────────────────── */
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 IND_NAME = {
ema_trend: "EMA trend", rsi: "RSI", macd: "MACD", bollinger: "Bollinger bands",
adx: "ADX", roc: "Rate of change", keltner: "Keltner channel",
divergence: "Divergence", candlestick: "Candlestick pattern"
};
// Divergence and candlestick only exist on a scan that found a pattern
// (engine.py); for them an absent entry means "no pattern", not a failure.
const PATTERN_IND = new Set(["divergence", "candlestick"]);
const absentWord = name => PATTERN_IND.has(name) ? "no pattern" : "no reading";
const VOTE = { STRONG_BUY: 2, BUY: 1, NEUTRAL: 0, SELL: -1, STRONG_SELL: -2 };
const SIG_ICON = {
STRONG_BUY: "fa-angles-up", BUY: "fa-arrow-trend-up", NEUTRAL: "fa-arrows-left-right",
SELL: "fa-arrow-trend-down", STRONG_SELL: "fa-angles-down"
};
const TF_LABEL = { "5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4" };
const TF_LONG = { "5min": "5-minute", "15min": "15-minute", "1h": "1-hour", "4h": "4-hour" };
const TF_ORDER = ["5min", "15min", "1h", "4h"];
const TF_SECONDS = { "5min": 300, "15min": 900, "1h": 3600, "4h": 14400 };
const FALLBACK_POLL_MS = 60000;
const RETRY_MS = 15000;
const HIGH_IMPACT_PREF = "goldDeskHighImpactOnly";
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"
};
/* ── state ────────────────────────────────────────────────────── */
let snapshot = null, news = null;
let clockOffset = 0;
let lastPollOk = null;
let lastSnapText = null, lastNewsText = null;
let refetchTimer = null;
let fallbackTimer = null;
let pollInFlight = false;
let pollQueued = false;
let detailTf = null;
let ndState = null;
const aiMemo = new Map();
const aiErr = new Map();
const aiPending = new Set();
let tfModal = null, newsModal = null;
let tfModalOpen = false, newsModalOpen = false;
const charts = { direction: null, tone: null, ind: {} };
const lastCells = {};
let C = {};
const REDUCED_MOTION = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* ── helpers ──────────────────────────────────────────────────── */
const esc = s => String(s ?? "").replace(/[&<>"']/g,
c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const fmt$ = v => v == null ? "—" : Number(v).toLocaleString("en-US",
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
const signed = (v, dp = 2) => v == null ? "—" : (v >= 0 ? "+" : "") + Number(v).toFixed(dp);
const sigClass = s => s && s.includes("BUY") ? "buy" : s && s.includes("SELL") ? "sell" : "neutral";
const sigWord = s => ({ STRONG_BUY: "Strong buy", BUY: "Buy", NEUTRAL: "No signal", SELL: "Sell", STRONG_SELL: "Strong sell" }[s] || "No signal");
const dirClass = s => "sig-" + sigClass(s);
const toneClass = s => sigClass(s) === "buy" ? "up" : sigClass(s) === "sell" ? "down" : "flat";
const nowServer = () => Date.now() / 1000 + clockOffset;
const cssVar = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
function localGet(key) {
try { return window.localStorage.getItem(key); } catch (e) { return null; }
}
function localSet(key, value) {
try { window.localStorage.setItem(key, value); } catch (e) { /* storage is optional */ }
}
function announce(message) {
$("#dashboard-live").text(message);
}
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`;
}
const COUNT_WORD = ["No", "One", "Two", "Three", "Four"];
// The engine writes "STRONG BUY — High-quality setup, consider entry". The
// card already shouts the signal, so keep only the part after the dash.
function actionTail(action) {
if (!action) return "";
const cut = action.indexOf("—");
return cut === -1 ? action : action.slice(cut + 1).trim();
}
/* Dark only (owner decision 2026-08-16). */
$("html").attr("data-bs-theme", "dark");
/* ── chart layer ──────────────────────────────────────────────── */
function loadColors() {
C = {
up: cssVar("--up"), down: cssVar("--down"), flat: cssVar("--flat"),
grid: cssVar("--grid"), zero: cssVar("--zero"), axis: cssVar("--axis"),
panel: cssVar("--panel"), gold: cssVar("--gold")
};
}
const markColor = v => v > 0 ? C.up : v < 0 ? C.down : C.flat;
const endRadius = v => v < 0
? { topLeft: 4, bottomLeft: 4, topRight: 0, bottomRight: 0 }
: { topRight: 4, bottomRight: 4, topLeft: 0, bottomLeft: 0 };
function gridFor(zeroWidth) {
return {
color: ctx => ctx.tick.value === 0 ? C.zero : C.grid,
lineWidth: ctx => ctx.tick.value === 0 ? zeroWidth : 1,
drawTicks: false
};
}
function buildCharts() {
Chart.defaults.font.family = '"IBM Plex Sans", system-ui, sans-serif';
Chart.defaults.font.size = 11;
Chart.defaults.color = C.axis;
// Mutate the duration; replacing defaults.animation wholesale drops the
// interpolator Chart.js attaches to it and every draw throws.
if (REDUCED_MOTION) Chart.defaults.animation = false;
else Chart.defaults.animation.duration = 260;
Chart.defaults.plugins.tooltip.backgroundColor = "#1d242e";
Chart.defaults.plugins.tooltip.borderColor = "rgba(255,255,255,.16)";
Chart.defaults.plugins.tooltip.borderWidth = 1;
Chart.defaults.plugins.tooltip.titleColor = "#fff";
Chart.defaults.plugins.tooltip.bodyColor = "rgba(255,255,255,.9)";
Chart.defaults.plugins.tooltip.padding = 9;
Chart.defaults.plugins.tooltip.displayColors = false;
charts.direction = new Chart($("#chart-direction")[0].getContext("2d"), {
type: "bar",
data: {
labels: TF_ORDER.map(tf => TF_LABEL[tf]),
datasets: [{
data: [null, null, null, null],
backgroundColor: C.flat,
borderRadius: 4,
borderSkipped: false,
barThickness: 20,
maxBarThickness: 24
}]
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
layout: { padding: { top: 2, bottom: 2 } },
scales: {
x: {
min: -100, max: 100,
grid: gridFor(1.5),
border: { display: false },
ticks: {
stepSize: 50, color: C.axis, font: { family: '"IBM Plex Mono", monospace' },
callback: v => v > 0 ? "+" + v : String(v)
}
},
y: {
grid: { display: false },
border: { display: false },
ticks: { color: "#fff", font: { family: '"IBM Plex Mono", monospace', size: 13, weight: "600" } }
}
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: items => TF_LONG[TF_ORDER[items[0].dataIndex]] + " scan",
label: item => {
const r = snapshot && snapshot.timeframes[TF_ORDER[item.dataIndex]] &&
snapshot.timeframes[TF_ORDER[item.dataIndex]].report;
const v = item.parsed.x;
return `${sigWord(r && r.signal)}, score ${signed(v, 1)}`;
}
}
}
}
}
});
charts.tone = new Chart($("#chart-tone")[0].getContext("2d"), {
type: "bar",
data: {
labels: [""],
datasets: [
{ label: "Neutral", data: [0], backgroundColor: C.flat },
{ label: "Bearish for gold", data: [0], backgroundColor: C.down },
{ label: "Neutral ", data: [0], backgroundColor: C.flat },
{ label: "Bullish for gold", data: [0], backgroundColor: C.up }
].map(d => Object.assign(d, {
borderColor: C.panel, borderWidth: 2, borderSkipped: false,
barThickness: 16, borderRadius: 3
}))
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true, min: -100, max: 100,
grid: gridFor(1.5),
border: { display: false },
ticks: { stepSize: 50, color: C.axis, callback: v => Math.abs(v) + "%" }
},
y: { stacked: true, grid: { display: false }, border: { display: false }, ticks: { display: false } }
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: () => "Headline tone",
label: item => `${item.dataset.label.trim()}: ${Math.abs(item.parsed.x).toFixed(0)}%`
}
}
}
}
});
}
function indChart(tf) {
if (charts.ind[tf]) return charts.ind[tf];
const el = document.getElementById("ind-" + tf);
if (!el) return null;
charts.ind[tf] = new Chart(el.getContext("2d"), {
type: "bar",
data: {
labels: IND_ORDER.map(n => IND_SHORT[n]),
datasets: [{
data: IND_ORDER.map(() => 0),
backgroundColor: C.flat,
borderRadius: 3,
borderSkipped: false,
barThickness: 7
}]
},
// A neutral vote and a missing reading both have no bar; this marks
// them on the zero line (grey dot = neutral, faint tag = nothing found).
plugins: [{
id: "indMarkers",
afterDatasetsDraw(chart) {
const cells = lastCells[tf];
if (!cells) return;
const { ctx, scales } = chart;
const x0 = scales.x.getPixelForValue(0);
ctx.save();
cells.forEach((cell, i) => {
const y = scales.y.getPixelForValue(i);
if (cell.present && cell.v === 0) {
ctx.fillStyle = C.flat;
ctx.beginPath();
ctx.arc(x0, y, 3, 0, Math.PI * 2);
ctx.fill();
} else if (!cell.present) {
ctx.fillStyle = "rgba(255,255,255,.32)";
ctx.font = '9px "IBM Plex Mono", monospace';
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(absentWord(IND_ORDER[i]), x0 + 6, y);
}
});
ctx.restore();
}
}],
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
layout: { padding: { right: 4 } },
// Hover the whole row: a zero-length bar cannot be intersected.
interaction: { mode: "index", axis: "y", intersect: false },
scales: {
x: {
min: -2, max: 2,
grid: gridFor(1.5),
border: { display: false },
ticks: { display: false }
},
y: {
grid: { display: false },
border: { display: false },
ticks: {
// An indicator with no reading is not a neutral vote: the label
// dims and the indMarkers plugin tags the row instead of a dot.
color: ctx => (lastCells[tf] && lastCells[tf][ctx.index] && !lastCells[tf][ctx.index].present)
? "rgba(255,255,255,.32)" : C.axis,
font: { family: '"IBM Plex Mono", monospace', size: 10 },
padding: 0
}
}
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: items => IND_NAME[IND_ORDER[items[0].dataIndex]],
label: item => {
const cell = (lastCells[tf] || indCells(currentReport(tf)))[item.dataIndex];
if (!cell || !cell.present) {
const w = absentWord(IND_ORDER[item.dataIndex]);
return `${w[0].toUpperCase()}${w.slice(1)} on this scan`;
}
return `${sigWord(cell.sig)}, confidence ${Math.round(cell.conf)}%`;
}
}
}
}
}
});
return charts.ind[tf];
}
const currentReport = tf => (snapshot && snapshot.timeframes[tf] && snapshot.timeframes[tf].report) || null;
function indCells(r) {
const src = (r && r.primary_analysis && r.primary_analysis.indicators) || (r && r.indicators) || {};
return IND_ORDER.map(name => {
const i = src[name];
if (!i) return { v: 0, present: false, sig: null, conf: null };
const conf = i.confidence == null ? 100 : i.confidence;
return { v: (VOTE[i.signal] || 0) * conf / 100, present: true, sig: i.signal, conf: conf };
});
}
function paint(chart, values, colors) {
if (!chart) return;
chart.data.datasets[0].data = values;
chart.data.datasets[0].backgroundColor = colors;
chart.data.datasets[0].borderRadius = values.map(endRadius);
// A canvas that was display:none when the chart was built measured 0×0
// and keeps drawing at that size; re-measure before every paint.
chart.resize();
chart.update(REDUCED_MOTION ? "none" : undefined);
}
function updateDirectionChart() {
const values = TF_ORDER.map(tf => {
const r = currentReport(tf);
return r && r.score != null ? r.score : null;
});
paint(charts.direction, values, values.map(markColor));
}
function updateIndChart(tf) {
const cells = indCells(currentReport(tf));
lastCells[tf] = cells;
paint(indChart(tf), cells.map(c => c.v), cells.map(c => markColor(c.v)));
}
function updateToneChart() {
const scored = ((news && news.headlines) || []).filter(h => typeof h.score === "number");
const box = $("#wire-tone");
if (!scored.length) { box.addClass("d-none"); return; }
box.removeClass("d-none");
const n = scored.length;
const up = scored.filter(h => h.score > 0.05).length;
const down = scored.filter(h => h.score < -0.05).length;
const flat = n - up - down;
const pct = k => k / n * 100;
const half = pct(flat) / 2;
charts.tone.data.datasets[0].data = [-half];
charts.tone.data.datasets[1].data = [-pct(down)];
charts.tone.data.datasets[2].data = [half];
charts.tone.data.datasets[3].data = [pct(up)];
charts.tone.resize();
charts.tone.update(REDUCED_MOTION ? "none" : undefined);
const key = (color, text) =>
`<span><i class="tone-key" style="background:${color}"></i>${esc(text)}</span>`;
$("#tone-legend").html(
key(C.up, `${up} bullish`) + key(C.flat, `${flat} neutral`) + key(C.down, `${down} bearish`) +
`<span class="ms-auto">${n} scored headlines</span>`);
}
/* ── ticker bar ───────────────────────────────────────────────── */
function renderBar() {
if (!snapshot) return;
let price = null;
for (const tf of TF_ORDER) {
const r = currentReport(tf);
if (r && r.current_price) { price = r.current_price; break; }
}
$("#bar-price").text(price ? fmt$(price) : "—");
$("#bar-rail").html(TF_ORDER.map(tf => {
const r = currentReport(tf);
const sig = r ? r.signal : null;
const icon = sig ? SIG_ICON[sig] : "fa-hourglass-half";
const score = r && r.score != null ? signed(r.score, 0) : "–";
return `<button type="button" class="rail-chip ${dirClass(sig)}" data-goto-tf="${tf}"
title="${esc(TF_LONG[tf])} scan: ${esc(sigWord(sig))}. Jump to the card.">
<span class="rail-tf">${TF_LABEL[tf]}</span>
<i class="fa-solid ${icon} rail-score" aria-hidden="true"></i>
<span class="rail-score">${esc(score)}</span>
</button>`;
}).join(""));
const closed = snapshot.market_closed;
$("#bar-market").html(`<span class="live-dot${closed ? " closed" : ""}"></span>${closed ? "Closed" : "Open"}`);
$("#banner-closed").toggleClass("d-none", !closed);
}
/* ── direction board ──────────────────────────────────────────── */
function eventRisk() {
let risk = "clear", desc = "Nothing scheduled that usually moves gold.";
if (snapshot) {
for (const tf of TF_ORDER) {
const r = currentReport(tf);
if (r && r.event_risk && r.event_risk !== "clear") {
risk = r.event_risk;
desc = r.event_description || desc;
break;
}
if (r && r.event_description) desc = r.event_description;
}
}
if (news && news.high_impact_warning) {
risk = "danger";
desc = news.warning_message || desc;
}
return { risk: risk, desc: desc };
}
function renderBoard() {
if (!snapshot) return;
const reads = TF_ORDER.map(tf => ({ tf: tf, r: currentReport(tf) })).filter(x => x.r);
if (!reads.length) {
$("#board-heading").text("Warming up");
$("#board-sub").text("No timeframe has finished a scan yet. The first reading lands at the next candle close.");
$("#board-stats").empty();
return;
}
const buys = reads.filter(x => sigClass(x.r.signal) === "buy");
const sells = reads.filter(x => sigClass(x.r.signal) === "sell");
const n = reads.length;
let headline;
if (buys.length && sells.length) headline = "The timeframes disagree";
else if (buys.length === n) headline = `All ${COUNT_WORD[n].toLowerCase()} timeframes read buy`;
else if (sells.length === n) headline = `All ${COUNT_WORD[n].toLowerCase()} timeframes read sell`;
else if (buys.length) headline = `${COUNT_WORD[buys.length]} of ${COUNT_WORD[n].toLowerCase()} timeframes read buy`;
else if (sells.length) headline = `${COUNT_WORD[sells.length]} of ${COUNT_WORD[n].toLowerCase()} timeframes read sell`;
else headline = "No clear direction";
$("#board-heading").text(headline);
const strongest = reads.slice().sort((a, b) => Math.abs(b.r.score || 0) - Math.abs(a.r.score || 0))[0];
const plans = reads.filter(x => x.r.trade_setup).length;
const mean = reads.reduce((a, x) => a + (x.r.score || 0), 0) / n;
const planLine = plans
? (plans === 1 ? "One timeframe is carrying a trade plan." : `${COUNT_WORD[plans]} timeframes are carrying a trade plan.`)
: "No timeframe is carrying a trade plan right now.";
$("#board-sub").text(
`The ${TF_LONG[strongest.tf]} scan is the strongest read at ${signed(strongest.r.score, 0)}. ${planLine}`);
const ev = eventRisk();
const evWord = { clear: "Clear", elevated: "Elevated", danger: "High", high: "High" }[ev.risk] ||
ev.risk.charAt(0).toUpperCase() + ev.risk.slice(1);
const meanCls = mean > 5 ? "up" : mean < -5 ? "down" : "flat";
$("#board-stats").html(
`<div><span class="k">Average direction</span>
<span class="v num ${meanCls}">${signed(mean, 0)}</span> <small>of 100</small></div>
<div><span class="k">Trade plans live</span>
<span class="v num">${plans}</span> <small>of ${n} scans</small></div>
<div><span class="k">Event risk</span>
<span class="v ${ev.risk === "clear" ? "" : "text-warning"}">${esc(evWord)}</span></div>`);
$("#board-stats > div:last-child").attr("title", ev.desc);
}
/* ── the news voice on the board ──────────────────────────────── */
function renderContext() {
if (!news) return;
$("#context").removeClass("d-none");
$("#ctx-blurb").text(news.llm_summary || "No news summary for this hour.");
let chips = "";
for (const cur of ["XAU", "USD"]) {
const v = news.sentiment && news.sentiment[cur];
if (v == null) continue;
const cls = v > 0.05 ? "chip-up" : v < -0.05 ? "chip-down" : "";
chips += `<span class="chip ${cls}" title="Average tone of the ${cur} headlines, from −1 to +1. Context only — news never changes a signal.">
${cur === "XAU" ? "Gold" : "Dollar"} news <span class="num">${signed(v, 2)}</span></span>`;
}
$("#ctx-chips").html(chips);
}
/* ── timeframe cards ──────────────────────────────────────────── */
function cardShell(tf) {
return `<div class="col">
<article class="card tf-card sig-neutral" id="card-${tf}" aria-labelledby="name-${tf}">
<div class="tf-edge"></div>
<div class="card-body">
<div class="tf-head">
<h3 class="tf-name" id="name-${tf}">${TF_LABEL[tf]}</h3>
<span class="tf-long">${TF_LONG[tf]} candles</span>
<span class="chip chip-warn d-none" data-f="stale">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>scan failed</span>
<span class="tf-clock ms-auto"><i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>
<span class="num" data-f="countdown" data-close="" data-interval="${TF_SECONDS[tf]}">--:--</span></span>
</div>
<div class="d-none" data-f="live">
<div class="tf-signal">
<i class="fa-solid fa-hourglass-half" data-f="icon" aria-hidden="true"></i>
<span data-f="signal">Warming up</span>
<span class="grade d-none" data-f="grade"></span>
</div>
<p class="tf-action" data-f="action"></p>
<div class="tf-readouts">
<div>
<span class="readout-k">Direction score</span>
<span class="readout-v num" data-f="score">—</span>
<span class="meter" aria-hidden="true"><i data-f="score-fill"></i></span>
</div>
<div>
<span class="readout-k">Confidence</span>
<span class="readout-v num" data-f="confidence">—</span>
<span class="meter meter-linear" aria-hidden="true"><i data-f="conf-fill"></i></span>
</div>
</div>
<div class="tf-chips" data-f="chips"></div>
<h4 class="tf-sub">What is driving it</h4>
<div class="chart-wrap chart-ind">
<canvas id="ind-${tf}" role="img"
aria-label="How each indicator voted on the ${TF_LONG[tf]} scan. The table in the full analysis carries the same numbers."></canvas>
</div>
<div data-f="plan"></div>
<div class="tf-flags" data-f="flags"></div>
</div>
<div class="tf-empty" data-f="empty">
<i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>Loading the latest scan.</div>
</div>
<div class="card-footer">
<button type="button" class="btn btn-sm btn-desk" data-open-detail="${tf}">
<i class="fa-solid fa-magnifying-glass-chart" aria-hidden="true"></i>Full analysis
</button>
<span class="tf-scanned" data-f="scanned"></span>
</div>
</article>
</div>`;
}
function buildCards() {
$("#tf-grid").html(TF_ORDER.map(cardShell).join(""));
TF_ORDER.forEach(tf => indChart(tf));
}
function planHTML(ts) {
if (!ts) {
return `<div class="plan plan-empty">
<i class="fa-regular fa-circle-xmark me-1" aria-hidden="true"></i>
No trade plan. The signal did not clear the gate that releases an entry, stop and target.</div>`;
}
const buy = ts.direction === "BUY";
// The trail is the tested exit (the backtester never exits at target 1);
// the risk-reward shown is the PLANNED target-1 ratio, a guide only.
const trail = (ts.trail_arm_r != null && ts.trail_dist_r != null)
? `Exit rule (tested): once the trade is ${ts.trail_arm_r}R in profit, the stop trails ${ts.trail_dist_r}R behind the best price, moved at candle close. Until then the stop is the exit; targets are guides.`
: "Exit rule: the trailing stop uses the platform default; targets are guides.";
return `<div class="plan ${buy ? "sig-buy" : "sig-sell"}">
<div class="plan-head">
<i class="fa-solid ${buy ? "fa-arrow-trend-up" : "fa-arrow-trend-down"}" aria-hidden="true"></i>
${buy ? "Buy plan" : "Sell plan"}
<span class="plan-strategy">${esc(ts.strategy || "")}</span>
<span class="plan-rr" title="Planned target-1 distance divided by the stop distance. A guide only: the tested exit is the trailing stop.">planned ${(+ts.risk_reward).toFixed(1)} to 1</span>
</div>
<dl class="plan-grid">
<div><dt><i class="fa-solid fa-right-to-bracket" aria-hidden="true"></i>Entry</dt>
<dd>${fmt$(ts.entry)}</dd></div>
<div><dt><i class="fa-solid fa-shield-halved" aria-hidden="true"></i>Stop</dt>
<dd class="down">${fmt$(ts.stop_loss)}</dd></div>
<div><dt><i class="fa-solid fa-bullseye" aria-hidden="true"></i>Target 1 (guide)</dt>
<dd class="up">${fmt$(ts.take_profit_1)}</dd></div>
<div><dt><i class="fa-solid fa-flag-checkered" aria-hidden="true"></i>Target 2</dt>
<dd class="up">${fmt$(ts.take_profit_2)}</dd></div>
</dl>
<div class="plan-trail">${esc(trail)}</div>
</div>`;
}
function flagsHTML(r, limit) {
let out = "";
if (r.watch_line) {
out += `<div class="flag-watch"><i class="fa-solid fa-bolt" aria-hidden="true"></i>${esc(r.watch_line)}</div>`;
}
const warns = (r.warnings || []).slice(0, limit);
warns.forEach(w => {
out += `<div class="flag-warn"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>${esc(w)}</div>`;
});
const extra = (r.warnings || []).length - warns.length;
if (extra > 0) {
out += `<div class="flat">${extra} more note${extra > 1 ? "s" : ""} in the full analysis.</div>`;
}
return out;
}
function renderCard(tf) {
const entry = (snapshot && snapshot.timeframes[tf]) || {};
const r = entry.report;
const $card = $("#card-" + tf);
const f = name => $card.find(`[data-f="${name}"]`);
f("countdown").attr("data-close", entry.next_close_epoch || "");
f("scanned").text(entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : "");
f("stale").toggleClass("d-none", !(entry.status === "error" && r));
if (!r) {
$card.removeClass("sig-buy sig-sell").addClass("sig-neutral");
f("live").addClass("d-none");
f("empty").removeClass("d-none").html(entry.status === "error"
? `<i class="fa-solid fa-plug-circle-exclamation" aria-hidden="true"></i>
Scan unavailable. The card fills in again as soon as a scan succeeds.`
: `<i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>
Warming up. The first reading lands at the next candle close.`);
return;
}
f("live").removeClass("d-none");
f("empty").addClass("d-none");
$card.removeClass("sig-buy sig-sell sig-neutral").addClass(dirClass(r.signal));
f("icon").attr("class", "fa-solid " + (SIG_ICON[r.signal] || "fa-arrows-left-right"));
f("signal").text(sigWord(r.signal));
const highGrade = r.quality === "A+" || r.quality === "A";
f("grade").toggleClass("d-none", !r.quality).toggleClass("grade-high", !!highGrade)
.text(r.quality ? "grade " + r.quality : "");
f("action").text(entry.status === "error"
? "Last good reading. The newest scan failed."
: actionTail(r.action));
const score = r.score == null ? 0 : r.score;
f("score").text(signed(r.score, 1))
.removeClass("up down flat").addClass(toneClass(r.signal));
f("score-fill").attr("class", score >= 0 ? "pos" : "neg")
.css("width", Math.min(50, Math.abs(score) / 2) + "%");
f("confidence").text(r.confidence == null ? "—" : Math.round(r.confidence) + "%");
f("conf-fill").css("width", Math.max(0, Math.min(100, r.confidence || 0)) + "%");
const chip = (icon, text, cls) =>
`<span class="chip ${cls || ""}"><i class="fa-solid ${icon}" aria-hidden="true"></i>${esc(text)}</span>`;
f("chips").html(
chip("fa-compass", r.market_regime || "regime unknown") +
chip("fa-wave-square", (r.volatility_regime || "unknown") + " volatility") +
chip(r.mtf_agreement ? "fa-layer-group" : "fa-shuffle",
r.mtf_agreement ? "timeframes agree" : "timeframes mixed",
r.mtf_agreement ? "chip-up" : "") +
(r.exhaustion_count ? chip("fa-battery-quarter", `${r.exhaustion_count} exhaustion signs`, "chip-warn") : ""));
f("plan").html(planHTML(r.trade_setup));
f("flags").html(flagsHTML(r, 2));
updateIndChart(tf);
}
/* ── full analysis dialog ─────────────────────────────────────── */
const tile = (k, v, cls) =>
`<div class="d-tile"><span class="k">${esc(k)}</span><span class="v ${cls || ""}">${v}</span></div>`;
const SEC_ICON = {
"At a glance": "fa-gauge-high",
"Trade plan": "fa-bullseye",
"Why this signal": "fa-lightbulb",
"Agreement across timeframes": "fa-layer-group",
"News context": "fa-newspaper",
"AI estimate": "fa-robot",
"AI panel": "fa-robot",
"Scan detail": "fa-clock-rotate-left",
"Score": "fa-chart-simple",
"From the source": "fa-quote-left",
"AI gold-impact summary": "fa-wand-magic-sparkles",
"Timing": "fa-stopwatch",
"Numbers": "fa-calculator"
};
function sec(title, html) {
if (!html) return "";
const icon = SEC_ICON[title] || "fa-circle-dot";
return `<section class="d-sec"><h6><i class="fa-solid ${icon}" aria-hidden="true"></i>
<span>${esc(title)}</span><span class="d-rule"></span></h6>${html}</section>`;
}
function indTable(src) {
const rows = IND_ORDER.map(name => {
const i = src && src[name];
const k = `<td class="ind-k">${IND_SHORT[name]}</td>`;
if (!i) {
const why = PATTERN_IND.has(name)
? "no pattern was found on this scan"
: "this indicator had nothing to say on this scan";
return `<tr>${k}<td class="flat">${absentWord(name)}</td><td class="num text-end">—</td>
<td class="num text-end">—</td><td class="num text-end">—</td>
<td class="flat">${why}</td></tr>`;
}
const w = i.weight == null ? null : +i.weight;
return `<tr>${k}
<td class="fw-semibold ${toneClass(i.signal)}">${esc(sigWord(i.signal))}</td>
<td class="num text-end">${i.value != null ? (+i.value).toFixed(2) : "—"}</td>
<td class="num text-end">${i.confidence != null ? Math.round(i.confidence) : "—"}</td>
<td class="num text-end">${w != null ? w.toFixed(1) : "—"}${w != null
? `<i class="wbar"><i style="width:${Math.min(100, w / 2 * 100)}%"></i></i>` : ""}</td>
<td class="flat">${esc(i.description || "")}</td></tr>`;
}).join("");
return `<div class="detail-scroll"><table class="table table-sm d-table"><thead><tr>
<th>Indicator</th><th>Vote</th><th class="text-end">Value</th><th class="text-end">Conf</th>
<th class="text-end">Weight</th><th>Reading</th></tr></thead><tbody>${rows}</tbody></table></div>`;
}
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="fw-semibold"><td><i class="fa-solid fa-location-dot me-1" aria-hidden="true"></i>price now</td>
<td class="num text-end">${fmt$(row.v)}</td><td class="num text-end">—</td><td class="flat">spot</td></tr>`;
}
// No red/green here: a level is not a direction, and spending the
// signal colours on it would make them mean two different things.
const d = price != null ? row.v - price : null;
return `<tr>
<td class="flat">${esc(row.label)}</td>
<td class="num text-end">${fmt$(row.v)}</td>
<td class="num text-end flat">${d == null ? "" : signed(d) + (atr ? ` (${signed(d / atr, 2)} ATR)` : "")}</td>
<td class="flat">${d == null ? "" : (d > 0 ? "above price" : "below price")}</td></tr>`;
}).join("");
return `<div class="detail-scroll"><table class="table table-sm d-table"><thead><tr>
<th>Level</th><th class="text-end">Price</th><th class="text-end">Distance</th><th>Side</th>
</tr></thead><tbody>${body}</tbody></table></div>`;
}
function analysisBlock(a, price) {
if (!a) return "";
const pats = a.pattern_signals && a.pattern_signals.length
? `<div class="tf-chips mt-2">${a.pattern_signals.map(p => `<span class="chip">${esc(p)}</span>`).join("")}</div>`
: "";
const atr = a.atr_context
? `<div class="flat small mt-2">${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 ? `<h6 class="tf-sub mt-3">Levels around the price</h6>${lv}` : "");
}
function reasonsBlock(r) {
const group = (title, arr, cls, icon) => arr && arr.length
? `<div class="reason-group">${title} (${arr.length})</div><div class="reasons">${arr.map(t =>
`<div class="${cls}"><i class="fa-solid ${icon}" aria-hidden="true"></i>${esc(t)}</div>`).join("")}</div>`
: "";
const watch = r.watch_line
? `<div class="reasons"><div class="flag-watch">
<i class="fa-solid fa-bolt" aria-hidden="true"></i>${esc(r.watch_line)}</div></div>` : "";
return watch
+ group("Warnings", r.warnings, "flag-warn", "fa-triangle-exclamation")
+ group("Bullish", r.bullish_reasons, "up", "fa-caret-up")
+ group("Bearish", r.bearish_reasons, "down", "fa-caret-down");
}
function detailHTML(tf) {
const entry = (snapshot && snapshot.timeframes[tf]) || {};
const r = entry.report;
if (!r) {
return `<div class="tf-empty">${entry.status === "error"
? "Scan unavailable. The page keeps the last good reading until a scan succeeds."
: "Warming up. No completed scan for this timeframe yet."}</div>`;
}
const pa = r.primary_analysis;
const price = r.current_price;
const cls = toneClass(r.signal);
const ns = r.news_sentiment;
let out = `<div class="plan ${dirClass(r.signal)} mb-4">
<div class="plan-head"><i class="fa-solid ${SIG_ICON[r.signal] || "fa-arrows-left-right"}" aria-hidden="true"></i>
${esc(sigWord(r.signal))}</div>
<div class="flat">${esc(actionTail(r.action))}</div>
${r.news_description || r.event_description
? `<div class="plan-trail">${esc([r.event_description, r.news_description].filter(Boolean).join(" — "))}</div>`
: ""}
</div>`;
out += sec("At a glance", `<div class="d-tiles">
${tile("Signal", esc(sigWord(r.signal)), cls)}
${tile("Score", signed(r.score, 1), cls)}
${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
${tile("Grade", esc(r.quality || "—"))}
${tile("Regime", esc(r.market_regime || "—"))}
${tile("Volatility", esc(r.volatility_regime || "—"))}
${tile("Event risk", esc(r.event_risk || "—"), r.event_risk === "clear" ? "up" : r.event_risk ? "text-warning" : "")}
${tile("News tone", ns != null ? signed(ns, 2) : "—", ns > 0.05 ? "up" : ns < -0.05 ? "down" : "")}
${tile("Timeframes", r.mtf_agreement ? "agree" : "mixed", r.mtf_agreement ? "up" : "")}
${tile("This timeframe alone", signed(r.primary_score, 1), toneClass(r.primary_signal))}
${tile("Exhaustion signs", esc(r.exhaustion_count == null ? "—" : r.exhaustion_count), r.exhaustion_count ? "text-warning" : "")}
</div>`);
out += sec("Trade plan", planHTML(r.trade_setup));
out += sec("Why this signal", reasonsBlock(r) || `<div class="flat">Nothing was recorded on this scan.</div>`);
out += sec(`${TF_LABEL[tf]} indicators`,
analysisBlock(pa, price) || indTable(r.indicators) +
(r.key_levels ? `<h6 class="tf-sub mt-3">Levels around the price</h6>${ladder(r.key_levels, price)}` : ""));
const mtf = Object.entries(r.mtf_signals || {});
if (mtf.length) {
const chips = mtf.map(pair => {
const s = r.mtf_scores && r.mtf_scores[pair[0]];
return `<span class="chip ${sigClass(pair[1]) === "buy" ? "chip-up" : sigClass(pair[1]) === "sell" ? "chip-down" : ""}">
${esc(TF_LABEL[pair[0]] || pair[0])} ${esc(sigWord(pair[1]))}
<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><strong>${esc(lbl)}</strong>
<span class="${toneClass(a.composite_signal)}">${esc(sigWord(a.composite_signal))}</span>
<span class="flat num">${signed(a.composite_score, 1)}</span>
<span class="flat">confidence ${a.confidence != null ? Math.round(a.confidence) : "—"}, ${esc(a.market_regime || "")}</span>
</summary>${analysisBlock(a, price)}</details>`;
}).join("");
out += sec("Agreement across timeframes",
`<div class="tf-chips">${chips}
<span class="chip ${r.mtf_agreement ? "chip-up" : ""}">
<i class="fa-solid ${r.mtf_agreement ? "fa-check" : "fa-xmark"}" aria-hidden="true"></i>
${r.mtf_agreement ? "enough agreement" : "not enough agreement"}</span></div>${confs}`);
}
if (r.news_summary || r.news_description) {
out += sec("News context", `<div class="flat">
${r.news_description ? `<strong>${esc(r.news_description)}</strong>${ns != null ? ` (${signed(ns, 2)})` : ""}<br>` : ""}
${esc(r.news_summary || "")}</div>`);
}
if (r.panel_verdict && (r.panel_probability || 0) > 0) {
// v2 panel: a probability anchored to the base rate of this kind of
// setup. Advisory only — no gate reads it, so it gets no verdict colour.
// Same plain wording as the Telegram alert (2026-09-26).
const base = (r.panel_context && r.panel_context.base_rate) || {};
const usual = base.win_rate != null
? ` Usual for this kind of setup: about ${Math.round(base.win_rate)} in 100.` : "";
const takes = (r.panel_takes || []).map(t =>
`<tr><td class="ind-k">${esc(t.persona)}</td>
<td class="num text-end">${t.p_win != null ? Math.round(t.p_win) + "%" : "—"}</td>
<td class="flat">${esc(t.reason_for || "")}</td>
<td class="flat">${esc(t.reason_against || "")}</td></tr>`).join("");
out += sec("AI estimate", `<div class="flat mb-2">
Chance it pays off: about <strong>${Math.round(r.panel_probability || 0)} in 100</strong>.${esc(usual)}
${esc(r.panel_rationale || "")}<br>
This check is an experiment. Do not trade on it alone.</div>
${takes ? `<div class="detail-scroll"><table class="table table-sm d-table">
<thead><tr><th>Persona</th><th class="text-end">Chance</th><th>For</th><th>Against</th></tr></thead>
<tbody>${takes}</tbody></table></div>` : ""}`);
} else if (r.panel_verdict) {
const takes = (r.panel_takes || []).map(t =>
`<tr><td class="ind-k">${esc(t.persona)}</td><td>${esc(t.stance)}</td>
<td class="num text-end">${t.confidence != null ? Math.round(t.confidence) : "—"}</td>
<td class="flat">${esc(t.note || "")}</td></tr>`).join("");
out += sec("AI panel", `<div class="flat mb-2">
<strong>${esc(r.panel_verdict)}</strong>, confidence ${Math.round(r.panel_confidence || 0)}%<br>
${esc(r.panel_rationale || "")}</div>
${takes ? `<div class="detail-scroll"><table class="table table-sm d-table"><tbody>${takes}</tbody></table></div>` : ""}`);
}
out += sec("Scan detail", `<div class="detail-scroll"><table class="table table-sm d-table"><tbody>
<tr><td class="ind-k">Instrument</td><td>${esc(r.display_name || r.symbol || "—")}</td></tr>
<tr><td class="ind-k">Candle</td><td class="num">${esc(r.timestamp || "—")}</td></tr>
<tr><td class="ind-k">Scanned</td><td>${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>${esc(entry.status || "—")}${entry.error
? ` — <span class="down">${esc(entry.error)}</span>
${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}</td></tr>
</tbody></table></div>`);
return out;
}
function renderDetail() {
if (!detailTf || !tfModalOpen) return;
const entry = (snapshot && snapshot.timeframes[detailTf]) || {};
const r = entry.report;
const openKeys = $("#detail-body details[open][data-dkey]").map((i, el) => el.dataset.dkey).get();
const scroll = $("#detail-body").scrollTop();
$("#detail-title").text(`${TF_LABEL[detailTf]} — ${TF_LONG[detailTf]} candles`);
$("#detail-price").text(r && r.current_price ? fmt$(r.current_price) : "—");
$("#detail-sub").html(`${snapshot && snapshot.symbol ? esc(snapshot.symbol) + ", " : ""}next close in
<span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[detailTf]}">--:--</span>`);
$("#detail-body").html(detailHTML(detailTf));
$("#detail-body details[data-dkey]").each((i, el) => {
if (openKeys.indexOf(el.dataset.dkey) !== -1) el.open = true;
});
$("#detail-body").scrollTop(scroll);
tick();
}
function openDetail(tf) {
if (!TF_LABEL[tf]) return;
detailTf = tf;
// Fill it before the fade finishes; shown.bs.modal renders again.
tfModalOpen = true;
tfModal.show();
renderDetail();
$("#detail-body").scrollTop(0);
$("#detail-close").trigger("focus");
}
/* ── calendar ─────────────────────────────────────────────────── */
const evKey = e => `${e.time}|${e.currency}|${e.event}`;
const findHeadline = id => ((news && news.headlines) || []).find(h => h.id === id);
const findEvent = key => ((news && news.events) || []).find(e => evKey(e) === key);
function renderCalendar() {
if (!news) return;
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local time";
$("#calendar-note").text(`your time (${zone})` +
(news.generated_at ? `, updated ${fmtAge(news.generated_at)}` : ""));
let events = news.events || [];
if ($("#hi-only").prop("checked")) events = events.filter(e => e.impact === "high");
if (!events.length) {
$("#cal-body").html(`<tr><td colspan="7" class="empty">${news.calendar_available
? "Nothing matches this filter." : "The calendar source could not be reached."}</td></tr>`);
return;
}
const now = nowServer();
let nextMarked = 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: "long", month: "short", day: "numeric" });
if (day !== lastDay) {
rows += `<tr class="cal-day"><td colspan="7">${esc(day)}</td></tr>`;
lastDay = day;
}
const past = t < now;
const next = !past && !nextMarked && (nextMarked = true);
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
const impact = e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1);
const surp = e.surprise
? `<i class="fa-solid ${e.surprise === "positive" ? "fa-caret-up up" : e.surprise === "negative" ? "fa-caret-down down" : "fa-minus flat"}"
title="The number came in ${e.surprise === "positive" ? "above" : e.surprise === "negative" ? "below" : "in line with"} forecast. That is the number, not gold's direction."></i>`
: "";
rows += `<tr class="${past ? "cal-past" : ""}${next ? " cal-next" : ""}">
<td class="num text-nowrap">${esc(time)}</td>
<td class="flat">${esc(e.currency)}</td>
<td><span class="impact impact-${esc(e.impact)}">${esc(impact)}</span></td>
<td><button type="button" class="cal-event" data-ev-key="${esc(evKey(e))}" aria-haspopup="dialog">${esc(e.event)}</button></td>
<td class="num text-end">${esc(e.forecast == null ? "—" : e.forecast)}</td>
<td class="num text-end">${esc(e.previous == null ? "—" : e.previous)}</td>
<td class="num text-end">${esc(e.actual == null ? "—" : e.actual)} ${surp}</td></tr>`;
}
$("#cal-body").html(rows);
}
/* ── newswire ─────────────────────────────────────────────────── */
function renderWire() {
if (!news) return;
const items = (news.headlines || []).slice(0, 40);
$("#wire-note").text(news.generated_at ? `updated ${fmtAge(news.generated_at)}` : "");
updateToneChart();
if (!items.length) {
$("#wire").html(`<div class="flat small py-2">No gold or dollar headlines in the last four hours.</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 ? "·" : signed(s, 1);
let host = "";
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch (e) { /* not a url */ }
const body = `<span class="wire-score ${cls}"
title="Tone for gold, from −1 to +1${h.currencies && h.currencies.length ? ". Mentions " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
<span class="wire-text">${esc(h.headline)}</span>
<span class="wire-src">${esc(h.publisher || host)}${h.timestamp ? ", " + fmtAge(h.timestamp) : ""}</span>`;
return h.id
? `<button type="button" class="wire-item" data-news-id="${esc(h.id)}" aria-haspopup="dialog">${body}</button>`
: `<div class="wire-item">${body}</div>`;
};
const head = items.slice(0, 12).map(item).join("");
const rest = items.slice(12);
const moreOpen = $("#wire details.more").prop("open") || false;
$("#wire").html(head + (rest.length
? `<details class="more"${moreOpen ? " open" : ""}>
<summary class="wire-more">${rest.length} older headlines</summary>
<div>${rest.map(item).join("")}</div></details>` : ""));
}
/* ── news and event dialog ────────────────────────────────────── */
const AI_ERR_MSG = {
unknown_id: "This headline has left the live feed, so there is no summary for it.",
cap_exceeded: "The daily AI budget is used up. Try again tomorrow.",
no_key: "AI summaries are not configured on this server."
};
function fetchAiSummary(id) {
aiPending.add(id);
$.ajax({ url: "news_summary.php", data: { id: id }, dataType: "json" })
.done(data => {
if (data && data.summary) aiMemo.set(id, data);
else aiErr.set(id, (data && data.error) || "empty");
})
.fail(jqXHR => {
const data = jqXHR.responseJSON;
aiErr.set(id, (data && data.error) || (jqXHR.status ? "http_" + jqXHR.status : "network"));
})
.always(() => {
aiPending.delete(id);
if (ndState && ndState.kind === "wire" && ndState.id === id) renderNewsDlg();
});
}
function scoreTiles(h) {
const cls = v => v > 0.05 ? "up" : v < -0.05 ? "down" : "";
let tiles = tile("Overall tone", signed(h.score, 2), cls(h.score));
const xau = h.score_by_currency && h.score_by_currency.XAU;
if (xau != null) tiles += tile("Effect on gold", signed(xau, 2), cls(xau));
for (const cur of Object.keys(h.score_by_currency || {})) {
if (cur !== "XAU") tiles += tile(cur, signed(h.score_by_currency[cur], 2), cls(h.score_by_currency[cur]));
}
let out = `<div class="d-tiles">${tiles}</div>`;
if (h.keywords && h.keywords.length) {
out += `<div class="flat small mt-2">Matched words: ${esc(h.keywords.join(", "))}</div>`;
}
out += `<div class="flat small mt-1">Scores run from −1 (bearish for gold) to +1 (bullish).
Rate talk is flipped: hawkish is good for the dollar and bad for gold.</div>`;
return out;
}
function aiSection(id) {
if (aiMemo.has(id)) {
const m = aiMemo.get(id);
return `<div class="flat">${esc(m.summary)}</div>` +
(m.generated_at ? `<div class="flat small mt-1">written ${fmtAge(m.generated_at)}</div>` : "");
}
if (aiErr.has(id)) {
return `<div class="flat">${esc(AI_ERR_MSG[aiErr.get(id)] || "The AI summary is unavailable right now.")}</div>`;
}
return `<div class="flat"><i class="fa-solid fa-spinner fa-spin-pulse me-2" aria-hidden="true"></i>Writing a gold-impact summary…</div>`;
}
function wireDlgHTML() {
const fresh = findHeadline(ndState.id);
const h = fresh || ndState.snap;
if (!h) return "";
let host = "";
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch (e) { /* not a url */ }
const when = h.timestamp
? `${new Date(h.timestamp).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}, ${fmtAge(h.timestamp)}`
: "";
let out = `<h4 class="h5">${esc(h.headline)}</h4>
<div class="flat small mb-3">${esc(h.publisher || host)}${when ? ", " + when : ""}</div>`;
if (!fresh) out += `<div class="flat small mb-2">This headline has dropped out of the live feed — showing the saved copy.</div>`;
out += sec("Score", scoreTiles(h));
if (h.description) out += sec("From the source", `<div class="flat">${esc(h.description)}</div>`);
out += sec("AI gold-impact summary", aiSection(ndState.id));
if (h.link && /^https?:\/\//.test(h.link)) {
out += `<a class="btn btn-sm btn-desk" href="${esc(h.link)}" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>Read the full article</a>`;
}
return out;
}
function calDlgHTML() {
const fresh = findEvent(ndState.key);
const e = fresh || ndState.snap;
if (!e) return "";
const t = Date.parse(e.time) / 1000;
const when = new Date(e.time).toLocaleString(undefined, { dateStyle: "full", timeStyle: "short" });
const impact = e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1);
let out = `<h4 class="h5">${esc(e.event)}</h4>
<div class="flat small mb-3">${esc(e.currency)}, ${esc(when)}</div>`;
out += sec("Timing", `<div class="flat">
<span class="impact impact-${esc(e.impact)}">${esc(impact)} impact</span>
${t > nowServer()
? ` releases in <span class="num" data-close="${t}">--:--</span>`
: ` released ${fmtAge(e.time)}`}</div>`);
const surpTxt = {
positive: "The number came in above forecast.",
negative: "The number came in below forecast.",
inline: "The number matched the forecast."
}[e.surprise];
out += sec("Numbers", `<div class="d-tiles">
${tile("Forecast", esc(e.forecast == null ? "—" : e.forecast))}
${tile("Previous", esc(e.previous == null ? "—" : e.previous))}
${tile("Actual", esc(e.actual == null ? "—" : e.actual),
e.surprise === "positive" ? "up" : e.surprise === "negative" ? "down" : "")}
</div>${surpTxt ? `<div class="flat small mt-2">${surpTxt} That is about the number itself, not gold's direction.</div>` : ""}`);
return out;
}
function renderNewsDlg() {
if (!ndState || !newsModalOpen) return;
const scroll = $("#nd-body").scrollTop();
$("#nd-title").text(ndState.kind === "wire" ? "Headline" : "Calendar event");
$("#nd-body").html(ndState.kind === "wire" ? wireDlgHTML() : calDlgHTML());
$("#nd-body").scrollTop(scroll);
tick();
}
function openNewsDetail(id) {
const h = findHeadline(id);
if (!h) return;
ndState = { kind: "wire", id: id, snap: h };
aiErr.delete(id);
newsModalOpen = true;
newsModal.show();
if (!aiMemo.has(id) && !aiPending.has(id)) fetchAiSummary(id);
renderNewsDlg();
$("#nd-body").scrollTop(0);
$("#nd-close").trigger("focus");
}
function openCalDetail(key) {
const e = findEvent(key);
if (!e) return;
ndState = { kind: "cal", key: key, snap: e };
newsModalOpen = true;
newsModal.show();
renderNewsDlg();
$("#nd-body").scrollTop(0);
$("#nd-close").trigger("focus");
}
/* ── polling ──────────────────────────────────────────────────── */
function fetchText(name) {
return $.ajax({ url: name, dataType: "text", cache: false })
.then((text, status, jqXHR) => {
const header = jqXHR.getResponseHeader("Date");
const parsed = header ? Date.parse(header) : NaN;
return { text: text, serverNow: isFinite(parsed) ? parsed / 1000 : null };
});
}
function tfFingerprint(entry) {
if (!entry) return "";
return JSON.stringify({
status: entry.status,
scanned_at: entry.scanned_at,
error: entry.error,
error_at: entry.error_at,
report: entry.report
});
}
function scheduleRefresh(delay) {
clearTimeout(refetchTimer);
refetchTimer = setTimeout(() => {
refetchTimer = null;
poll();
}, Math.max(0, delay));
}
function scheduleFallbackRefresh() {
clearTimeout(fallbackTimer);
fallbackTimer = setTimeout(() => {
fallbackTimer = null;
poll();
scheduleFallbackRefresh();
}, FALLBACK_POLL_MS);
}
function scheduleBoundaryRefresh() {
if (!snapshot) return;
const nextScan = Math.min.apply(null, TF_ORDER.map(tf =>
(snapshot.timeframes[tf] && snapshot.timeframes[tf].next_scan_epoch) || Infinity));
if (!isFinite(nextScan)) return;
const delay = (nextScan - nowServer() + 2) * 1000;
scheduleRefresh(delay > 0 ? delay : RETRY_MS);
}
async function poll() {
if (pollInFlight) {
pollQueued = true;
return;
}
pollInFlight = true;
try {
const results = await Promise.all([
fetchText("snapshot.json"),
fetchText("news.json").catch(() => null)
]);
const snap = results[0], nw = results[1];
const prior = snapshot;
let snapChanged = false;
const changedTfs = [];
let detailChanged = false;
if (snap.text !== lastSnapText) {
const incoming = JSON.parse(snap.text);
if (!prior || incoming.server_time_epoch >= prior.server_time_epoch) {
snapChanged = true;
TF_ORDER.forEach(tf => {
if (tfFingerprint(prior && prior.timeframes[tf]) !== tfFingerprint(incoming.timeframes[tf])) {
changedTfs.push(tf);
}
});
detailChanged = detailTf != null && changedTfs.indexOf(detailTf) !== -1;
snapshot = incoming;
lastSnapText = snap.text;
}
}
const newsChanged = nw != null && nw.text !== lastNewsText;
if (newsChanged) { news = JSON.parse(nw.text); lastNewsText = nw.text; }
clockOffset = (snap.serverNow == null ? snapshot.server_time_epoch : snap.serverNow) - Date.now() / 1000;
lastPollOk = new Date();
$("#banner-fail").addClass("d-none");
if (snapChanged) {
renderBar();
if (changedTfs.length) {
changedTfs.forEach(renderCard);
updateDirectionChart();
const spoken = changedTfs
.map(tf => currentReport(tf) && `${TF_LABEL[tf]} now reads ${sigWord(currentReport(tf).signal).toLowerCase()}`)
.filter(Boolean);
if (spoken.length) announce(spoken.join(". ") + ".");
}
if (detailChanged) renderDetail();
}
if (newsChanged) {
renderCalendar();
renderWire();
renderNewsDlg();
}
if (snapChanged || newsChanged) {
renderContext();
renderBoard();
}
tick();
scheduleBoundaryRefresh();
} catch (err) {
const last = lastPollOk ? ` The last update was at ${lastPollOk.toLocaleTimeString()}.` : "";
$("#banner-fail")
.text(`Can't reach the dashboard data.${last} Retrying every ${RETRY_MS / 1000} seconds.`)
.removeClass("d-none");
scheduleRefresh(RETRY_MS);
} finally {
pollInFlight = false;
if (pollQueued) {
pollQueued = false;
poll();
}
}
}
/* ── one-second clock ─────────────────────────────────────────── */
function tick() {
if (!snapshot) return;
const now = nowServer();
$("[data-close]").each((i, el) => {
const t = parseFloat(el.dataset.close);
if (!t) return;
let secs = t - now;
if (secs < 0) {
const interval = parseFloat(el.dataset.interval);
secs = interval > 0 ? ((secs % interval) + interval) % interval : 0;
}
el.textContent = fmtCountdown(secs);
});
const m5 = snapshot.timeframes["5min"] && snapshot.timeframes["5min"].next_close_epoch;
if (m5) {
let secs = m5 - now;
if (secs < 0) secs = ((secs % TF_SECONDS["5min"]) + TF_SECONDS["5min"]) % TF_SECONDS["5min"];
$("#bar-cd").text(fmtCountdown(secs));
}
const pubEpoch = snapshot.server_time_epoch;
if (pubEpoch) {
const stale = !snapshot.market_closed && (now - pubEpoch) > 900;
$("#bar-updated")
.text((stale ? "stale, published " : "published ") + fmtAge(new Date(pubEpoch * 1000).toISOString()))
.toggleClass("is-stale", stale);
}
}
/* ── boot ─────────────────────────────────────────────────────── */
$(function () {
loadColors();
buildCharts();
buildCards();
tfModal = new bootstrap.Modal(document.getElementById("tf-detail"));
newsModal = new bootstrap.Modal(document.getElementById("news-detail"));
$("#hi-only").prop("checked", localGet(HIGH_IMPACT_PREF) === "true");
$("#tf-detail").on("shown.bs.modal", () => { tfModalOpen = true; renderDetail(); })
.on("hidden.bs.modal", () => { tfModalOpen = false; detailTf = null; });
$("#news-detail").on("shown.bs.modal", () => { newsModalOpen = true; renderNewsDlg(); })
.on("hidden.bs.modal", () => { newsModalOpen = false; ndState = null; });
$("#detail-close").on("click", () => tfModal.hide());
$("#nd-close").on("click", () => newsModal.hide());
$(document)
.on("click", "[data-goto-tf]", function () {
// .attr, not .data: jQuery's data() coerces values that look numeric.
const tf = $(this).attr("data-goto-tf");
const $card = $("#card-" + tf);
if (!$card.length) return;
$card[0].scrollIntoView({ behavior: REDUCED_MOTION ? "auto" : "smooth", block: "center" });
$card.addClass("is-target");
setTimeout(() => $card.removeClass("is-target"), 1400);
})
.on("click", "[data-open-detail]", function () { openDetail($(this).attr("data-open-detail")); })
.on("click", "[data-news-id]", function () { openNewsDetail($(this).attr("data-news-id")); })
.on("click", "[data-ev-key]", function () { openCalDetail($(this).attr("data-ev-key")); });
$("#hi-only").on("change", function () {
localSet(HIGH_IMPACT_PREF, String($(this).prop("checked")));
renderCalendar();
});
poll();
scheduleFallbackRefresh();
setInterval(tick, 1000);
});