Files
ForexBot2/static/dashboard.js
Harwinder Sekhon 2cc5915b00 Daily-trend veto on M15 + lot-size / already-open lines (campaign 2026-09-30), and the 2026-10-01 whole-codebase bug review
Two bodies of work that touch the same files, so one commit.

Direction and setup-size campaign (2026-09-30 → 10-01, owner's RISK-FIRST rule,
written before the runs):
- SIGNAL: M15 daily-trend veto (factory override 7, xau_strategy.daily_trend_veto
  "both"). A finished setup against a clear daily trend (closed D1 bars, Sunday
  stubs dropped; close vs its 50-bar EMA AND vs the close 60 kept days earlier)
  is removed as the last step of the core. One definition
  (signal_aggregator.daily_trend_series) for live, the bar replay and the twin's
  host side; D1 is a required frame of the M15 scan; mirrored in
  cuda_signal_kernels.py K2. W3 824 / +205.12R / PF 1.73 / max DD 12.4R vs
  1105 / +195.79R / 1.49 / 20.9R; 2019 → 2026 1473 / +223.33R / 1.42 / 41.5R vs
  2091 / +192.53R / 1.24 / 64.8R; unseen holdout −4.62R vs −6.63R.
- FAILED, not shipped: the veto on M5 (W3 drawdown 10.1R → 15.2R) and on H1
  (−9.7R), buy-only and sell-only on M15, the wide-stop cap
  (xau_strategy.wide_stop_cap_atr stays 0.0, a research knob).
- New M15 pins: W2 dfc647ee…, OOS e87b6de1…, W3 ad3b0dbd…. M5 / H1 / H4 pins
  unchanged. --set xau_strategy.daily_trend_veto=off reproduces the previous M15
  pins byte for byte. Twin and reference script MD5-equal on every window;
  recorder and feature-export parity re-proved; base-rate table rebuilt.
- DISPLAY (books byte-identical): exposure.py — the lot size for
  trade_size.risk_per_trade (new config.json block, off by default, private to
  the owner) and the count of alerted same-direction plans still open
  (signals.alerted_at, read-only open_alerted_setups); the daily trend in words
  on Telegram, the CLI and the dashboard; signals.daily_trend logged.
- providers/oanda: a date window with no bars is an empty page, not an error
  (a D1 pad starting on a Saturday could not be fetched).
- Reports only, nothing shipped: seven context screens (no state nominated) and
  eight outside data sources (none adds a forecast to gold's own daily trend).
- Independent verification (report section 5.13): every figure reproduced on
  both scripts, scoring identical to the previous commit on all four
  timeframes, no look-ahead. Caveats recorded there: this is a risk filter, not
  a more accurate direction call; nine tenths of the 2019 → 2026 gain is 2022;
  the neighbour clause is thin (a simple 100-day average misses the R line by
  1.2R); "never adds a trade" is an observation, not a guarantee.

Bug review of the whole codebase (2026-10-01). Signal path clean. 17 defects
elsewhere, plus 4 found by an independent review of the fixes; all fixed, each
with a test; no trade book changed:
- telegram_bot: one _deliver_alert for the candle scan and the level break. The
  row is stamped alerted_at BEFORE the send, on a one-worker alert-db pool, and
  un-stamped when no chat received it or the task is cancelled mid-stamp, so
  two alerts at one candle close count each other and no SQLite write runs on
  the event loop. The 5-bar cooldown counts bars of the scan's own frame
  (== gpu_backtest._apply_cooldown; clock time released the 22:00 UTC bar after
  the settlement pause) and is restored from signals.db at start-up. The
  lot-size line goes to the owner's own chat only (a group the owner ran
  /autoscan in used to receive it). /status gains a news-model line and escapes
  failure details.
- signal_log: mark_alerted returns the stamp it wrote; unmark_alerted;
  last_alerted_bars (read-only).
- advisory_outcome: candle continuity is judged bar by bar inside the walk; a
  hole after a trade's exit no longer voids it. The stored-candle replay
  resolves 108 of 111 logged setups instead of 99, all equal to the live
  resolver's R.
- providers/oanda: an empty date window is accepted only when it is 5 days or
  shorter and no malformed candle was skipped; a longer one is retried, then
  refused, instead of being cached as history with a hole.
- news_engine: blank RSS titles skipped; one unreadable headline no longer
  costs the local model its whole batch (checked on the real bundle); model
  health recorded; no DeepSeek audit call in a process that does not archive;
  event-direction cache keyed per release; peek_week_events never fetches.
- llm_client: chat_json never raises and never logs the API key (requests
  quotes the Authorization value in InvalidHeader; error bodies are redacted).
- signal_aggregator: the scan's news block is guarded; the replay path
  (use_news=False) assigns exactly the values it did before.
- agent_panel: PANEL_PROMPT_VERSION v2.2 — same prompt text, corrected input
  (event timing over the cached calendar week, headlines newest first).
  scripts/panel_replay.py replays the logged payload per persona;
  scripts/label_scan_outcomes.py counts with an ISO cutoff.
- test_all.py: no ALTER TABLE … DROP COLUMN (the live box has SQLite 3.34);
  tests that write Parquet print a notice and return without pyarrow.

Verification after the last code change: suite 632 passed, 0 failed (also with
both Parquet engines blocked and FB2_BACKEND=cpu, the live box's view); the
five reference MD5s reproduce on both backtesters, 0 cache fallbacks; Module 37
green; static Python 3.9 / SQLite 3.34 check clean on every touched file.

Evidence: backtest/research/daily_trend_campaign_2026-09-30.md,
context_screens_2026-10.md, new_inputs_2026-10.md; CLAUDE.md sections 2, 4, 6,
8, 9, 10 and 11; pair_signals/xauusd.py docstring (M15 override 7).
2026-10-01 10:28:28 -04:00

1377 lines
60 KiB
JavaScript
Raw Permalink 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-plain d-none" data-f="plain"></p>
<p class="tf-plain d-none" data-f="trend"></p>
<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, r) {
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.";
// Lot size (present only when the owner shares it on this page) and the
// alerted plans still open in this direction. Both lines are written by
// exposure.py; the page prints them as they come, no arithmetic here.
const notes = [r && r.size_line, r && r.open_line].filter(Boolean)
.map(t => `<div class="plan-trail">${esc(t)}</div>`).join("");
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>${notes}
</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));
// Plain words for the two numbers below (from main.plain_words, same on every surface).
f("plain").toggleClass("d-none", !r.plain_words).text(r.plain_words || "");
// The daily trend in words (main.daily_trend_words). On M15 a plan against
// a clear daily trend is removed by the signal core; elsewhere this is context.
f("trend").toggleClass("d-none", !r.daily_trend_words).text(r.daily_trend_words || "");
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, r));
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("Daily trend", r.daily_trend == null ? "—" : r.daily_trend > 0 ? "rising" : r.daily_trend < 0 ? "falling" : "no clear direction",
r.daily_trend > 0 ? "up" : r.daily_trend < 0 ? "down" : "")}
${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, r));
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);
});