"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 ``; }).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 `
${esc(title)}
${html}
`; } 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 `
${rows}
IndicatorVoteValueConf WeightReading
`; } 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 `
${body}
LevelPriceDistanceSide
`; } 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 ? `
${esc(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 ? `
${takes}
PersonaChanceForAgainst
` : ""}`); } 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 ? `
${takes}
` : ""}`); } out += sec("Scan detail", `
Instrument${esc(r.display_name || r.symbol || "—")}
Candle${esc(r.timestamp || "—")}
Scanned${esc(entry.scanned_at || "—")} (${fmtAge(entry.scanned_at)}${entry.scan_seconds ? `, took ${entry.scan_seconds}s` : ""})
Status${esc(entry.status || "—")}${entry.error ? ` — ${esc(entry.error)} ${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}
`); 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.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}
`; }; 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); });