"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"
};
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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
}]
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
layout: { padding: { right: 4 } },
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: both draw
// no bar, so the dimmed label is what tells them apart.
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) return "No reading 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) =>
` ${esc(text)} `;
$("#tone-legend").html(
key(C.up, `${up} bullish`) + key(C.flat, `${flat} neutral`) + key(C.down, `${down} bearish`) +
`${n} scored headlines `);
}
/* ── 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 `
${TF_LABEL[tf]}
${esc(score)}
`;
}).join(""));
const closed = snapshot.market_closed;
$("#bar-market").html(` ${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(
`
Average direction
${signed(mean, 0)} of 100
Trade plans live
${plans} of ${n} scans
Event risk
${esc(evWord)}
`);
$("#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 += `
${cur === "XAU" ? "Gold" : "Dollar"} news ${signed(v, 2)} `;
}
$("#ctx-chips").html(chips);
}
/* ── timeframe cards ──────────────────────────────────────────── */
function cardShell(tf) {
return `
${TF_LABEL[tf]}
${TF_LONG[tf]} candles
scan failed
--:--
Warming up
Direction score
—
Confidence
—
What is driving it
Loading the latest scan.
`;
}
function buildCards() {
$("#tf-grid").html(TF_ORDER.map(cardShell).join(""));
TF_ORDER.forEach(tf => indChart(tf));
}
function planHTML(ts) {
if (!ts) {
return `
No trade plan. The signal did not clear the gate that releases an entry, stop and target.
`;
}
const buy = ts.direction === "BUY";
const trail = (ts.trail_arm_r != null && ts.trail_dist_r != null)
? `Stop starts trailing at ${ts.trail_arm_r}R of profit and follows ${ts.trail_dist_r}R behind.`
: "Trailing uses the platform default.";
return `
${buy ? "Buy plan" : "Sell plan"}
${esc(ts.strategy || "")}
${(+ts.risk_reward).toFixed(1)} to 1
Entry
${fmt$(ts.entry)}
Stop
${fmt$(ts.stop_loss)}
Target 1
${fmt$(ts.take_profit_1)}
Target 2
${fmt$(ts.take_profit_2)}
${esc(trail)}
`;
}
function flagsHTML(r, limit) {
let out = "";
if (r.watch_line) {
out += ` ${esc(r.watch_line)}
`;
}
const warns = (r.warnings || []).slice(0, limit);
warns.forEach(w => {
out += ` ${esc(w)}
`;
});
const extra = (r.warnings || []).length - warns.length;
if (extra > 0) {
out += `${extra} more note${extra > 1 ? "s" : ""} in the full analysis.
`;
}
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"
? `
Scan unavailable. The card fills in again as soon as a scan succeeds.`
: `
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) =>
` ${esc(text)} `;
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) =>
`${esc(k)} ${v}
`;
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 ``;
}
function indTable(src) {
const rows = IND_ORDER.map(name => {
const i = src && src[name];
const k = `${IND_SHORT[name]} `;
if (!i) {
return `${k}no reading —
— —
this indicator had nothing to say on this scan `;
}
const w = i.weight == null ? null : +i.weight;
return `${k}
${esc(sigWord(i.signal))}
${i.value != null ? (+i.value).toFixed(2) : "—"}
${i.confidence != null ? Math.round(i.confidence) : "—"}
${w != null ? w.toFixed(1) : "—"}${w != null
? ` ` : ""}
${esc(i.description || "")} `;
}).join("");
return ``;
}
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 ` price now
${fmt$(row.v)} — spot `;
}
// 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 `
${esc(row.label)}
${fmt$(row.v)}
${d == null ? "" : signed(d) + (atr ? ` (${signed(d / atr, 2)} ATR)` : "")}
${d == null ? "" : (d > 0 ? "above price" : "below price")} `;
}).join("");
return ``;
}
function analysisBlock(a, price) {
if (!a) return "";
const pats = a.pattern_signals && a.pattern_signals.length
? `${a.pattern_signals.map(p => `${esc(p)} `).join("")}
`
: "";
const atr = a.atr_context
? `${esc(a.atr_context.description ||
"ATR " + (+a.atr_context.value).toFixed(2))}
` : "";
const lv = ladder(a.key_levels, price);
return indTable(a.indicators) + atr + pats +
(lv ? `Levels around the price ${lv}` : "");
}
function reasonsBlock(r) {
const group = (title, arr, cls, icon) => arr && arr.length
? `${title} (${arr.length})
${arr.map(t =>
`
${esc(t)}
`).join("")}
`
: "";
const watch = r.watch_line
? `` : "";
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 `${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."}
`;
}
const pa = r.primary_analysis;
const price = r.current_price;
const cls = toneClass(r.signal);
const ns = r.news_sentiment;
let out = `
${esc(sigWord(r.signal))}
${esc(actionTail(r.action))}
${r.news_description || r.event_description
? `
${esc([r.event_description, r.news_description].filter(Boolean).join(" — "))}
`
: ""}
`;
out += sec("At a glance", `
${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" : "")}
`);
out += sec("Trade plan", planHTML(r.trade_setup));
out += sec("Why this signal", reasonsBlock(r) || `Nothing was recorded on this scan.
`);
out += sec(`${TF_LABEL[tf]} indicators`,
analysisBlock(pa, price) || indTable(r.indicators) +
(r.key_levels ? `Levels around the price ${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 `
${esc(TF_LABEL[pair[0]] || pair[0])} ${esc(sigWord(pair[1]))}
${s != null ? signed(s, 1) : ""} `;
}).join("");
const confs = (r.confirmation_analyses || []).filter(Boolean).map(a => {
const lbl = TF_LABEL[a.timeframe] || a.timeframe;
return `
${esc(lbl)}
${esc(sigWord(a.composite_signal))}
${signed(a.composite_score, 1)}
confidence ${a.confidence != null ? Math.round(a.confidence) : "—"}, ${esc(a.market_regime || "")}
${analysisBlock(a, price)} `;
}).join("");
out += sec("Agreement across timeframes",
`${chips}
${r.mtf_agreement ? "enough agreement" : "not enough agreement"}
${confs}`);
}
if (r.news_summary || r.news_description) {
out += sec("News context", `
${r.news_description ? `${esc(r.news_description)} ${ns != null ? ` (${signed(ns, 2)})` : ""} ` : ""}
${esc(r.news_summary || "")}
`);
}
if (r.panel_verdict && r.panel_verdict_source === "mean") {
// 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.
const base = (r.panel_context && r.panel_context.base_rate) || {};
const usual = base.win_rate != null
? ` Setups like this one have paid off about ${Math.round(base.win_rate)}% of the time.` : "";
const takes = (r.panel_takes || []).map(t =>
`${esc(t.persona)}
${t.p_win != null ? Math.round(t.p_win) + "%" : "—"}
${esc(t.reason_for || "")}
${esc(t.reason_against || "")} `).join("");
out += sec("AI estimate", `
About a ${Math.round(r.panel_probability || 0)}% chance this trade pays off.${esc(usual)}
It is advisory and still being tested — nothing in the signal reads it.
${esc(r.panel_rationale || "")}
${takes ? `` : ""}`);
} else if (r.panel_verdict) {
const takes = (r.panel_takes || []).map(t =>
`${esc(t.persona)} ${esc(t.stance)}
${t.confidence != null ? Math.round(t.confidence) : "—"}
${esc(t.note || "")} `).join("");
out += sec("AI panel", `
${esc(r.panel_verdict)} , confidence ${Math.round(r.panel_confidence || 0)}%
${esc(r.panel_rationale || "")}
${takes ? `` : ""}`);
}
out += sec("Scan detail", ``);
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
--:-- `);
$("#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(`${news.calendar_available
? "Nothing matches this filter." : "The calendar source could not be reached."} `);
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 += `${esc(day)} `;
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
? ` `
: "";
rows += `
${esc(time)}
${esc(e.currency)}
${esc(impact)}
${esc(e.event)}
${esc(e.forecast == null ? "—" : e.forecast)}
${esc(e.previous == null ? "—" : e.previous)}
${esc(e.actual == null ? "—" : e.actual)} ${surp} `;
}
$("#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(`No gold or dollar headlines in the last four hours.
`);
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 = `${scoreTxt}
${esc(h.headline)}
${esc(h.publisher || host)}${h.timestamp ? ", " + fmtAge(h.timestamp) : ""} `;
return h.id
? `${body} `
: `${body}
`;
};
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
? `
${rest.length} older headlines
${rest.map(item).join("")}
` : ""));
}
/* ── 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 = `${tiles}
`;
if (h.keywords && h.keywords.length) {
out += `Matched words: ${esc(h.keywords.join(", "))}
`;
}
out += `Scores run from −1 (bearish for gold) to +1 (bullish).
Rate talk is flipped: hawkish is good for the dollar and bad for gold.
`;
return out;
}
function aiSection(id) {
if (aiMemo.has(id)) {
const m = aiMemo.get(id);
return `${esc(m.summary)}
` +
(m.generated_at ? `written ${fmtAge(m.generated_at)}
` : "");
}
if (aiErr.has(id)) {
return `${esc(AI_ERR_MSG[aiErr.get(id)] || "The AI summary is unavailable right now.")}
`;
}
return ` Writing a gold-impact summary…
`;
}
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 = `${esc(h.headline)}
${esc(h.publisher || host)}${when ? ", " + when : ""}
`;
if (!fresh) out += `This headline has dropped out of the live feed — showing the saved copy.
`;
out += sec("Score", scoreTiles(h));
if (h.description) out += sec("From the source", `${esc(h.description)}
`);
out += sec("AI gold-impact summary", aiSection(ndState.id));
if (h.link && /^https?:\/\//.test(h.link)) {
out += `
Read the full article `;
}
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 = `${esc(e.event)}
${esc(e.currency)}, ${esc(when)}
`;
out += sec("Timing", `
${esc(impact)} impact
${t > nowServer()
? ` releases in --:-- `
: ` released ${fmtAge(e.time)}`}
`);
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", `
${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" : "")}
${surpTxt ? `${surpTxt} That is about the number itself, not gold's direction.
` : ""}`);
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);
});