spb/anomaly-atlas Public License
Systematic discovery & rigorous validation of statistical anomalies in open HF market data (hfmarketdata.io) — pre-registered, artifact-null-driven, fully reproducible. Live atlas: www.anomaly-atlas.io
Python 61.4%
JavaScript 28.7%
CSS 8.6%
Shell 0.7%
Makefile 0.5%
1// ============================================================================2// Project : anomaly-atlas3// File : web/lib/charts.js4// Purpose : Server-rendered SVG figures built from experiment results.json5// Author : Simon-Pierre Boucher6// Contact : contact@spboucher.ai7// Data src : hfmarketdata.io (sole data source)8// Created : 2026-08-129// Modified : 2026-08-1210// Platform : macOS / Apple Silicon (arm64) — Node.js (deployed on MacLustr)11// License : All rights reserved (research code)12// ============================================================================13"use strict";1415/* Figures follow the dataviz method: form by job, validated palette16 * (slots: blue #2a78d6, orange #eb6834 — all-pairs PASS on #fffdf9; gray17 * #898781 is de-emphasis ink, not a series), thin marks with 2px surface18 * rings, hairline grid, text in ink tokens (never series color), native19 * <title> tooltips, selective direct labels. Every figure is generated from20 * the latest committed results.json — never hand-typed numbers. */2122const path = require("path");2324const BLUE = "#2a78d6";25const ORANGE = "#eb6834";26const GRAY = "#898781";27const SURFACE = "#fffdf9";28const GRID = "#e5e1d6";29const INK = "#1a1c20";30const MUTED = "#5d6167";3132function esc(s) {33 return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));34}3536function latestResults(C, experiment) {37 const run = C.listResultRuns().find((r) => r.experiment === experiment);38 if (!run) return null;39 const data = C.readJson(path.posix.join(run.rel, "results.json"));40 return data ? { data, run: run.timestamp } : null;41}4243function fig(svg, caption) {44 return `<figure class="chart-fig">${svg}<figcaption>${caption}</figcaption></figure>`;45}4647const AXIS_TXT = `font-size="11" fill="${MUTED}"`;4849// ------------------------------------------------------------------- expB50function expBScatterPanel(rows, key, title, W, H, xLabelBottom) {51 const ML = 46, MR = 14, MT = 26, MB = 34;52 const iw = W - ML - MR, ih = H - MT - MB;53 const ys = rows.map((r) => r[key]);54 const ymin = Math.min(...ys, 0), ymax = Math.max(...ys, 0);55 const pad = (ymax - ymin) * 0.12 || 0.01;56 const y0 = ymin - pad, y1 = ymax + pad;57 const xp = (v) => ML + v * iw;58 const yp = (v) => MT + ih - ((v - y0) / (y1 - y0)) * ih;5960 let g = `<text x="${ML}" y="${MT - 10}" font-size="12" font-weight="600" fill="${INK}">${esc(title)}</text>`;61 for (const t of [y0 + pad, 0, y1 - pad]) {62 const v = Math.round(t * 1000) / 1000;63 g += `<line x1="${ML}" y1="${yp(v)}" x2="${ML + iw}" y2="${yp(v)}" stroke="${GRID}" stroke-width="1"/>`;64 // skip an extreme tick label that would collide with the zero label65 if (v === 0 || Math.abs(yp(v) - yp(0)) > 14) {66 g += `<text x="${ML - 6}" y="${yp(v) + 4}" text-anchor="end" ${AXIS_TXT}>${v}</text>`;67 }68 }69 g += `<line x1="${ML}" y1="${yp(0)}" x2="${ML + iw}" y2="${yp(0)}" stroke="#c9c4b6" stroke-width="1"/>`;70 for (const t of [0, 0.5, 1]) {71 g += `<text x="${xp(t)}" y="${MT + ih + 16}" text-anchor="middle" ${AXIS_TXT}>${t}</text>`;72 }73 if (xLabelBottom) g += `<text x="${ML + iw / 2}" y="${H - 4}" text-anchor="middle" ${AXIS_TXT}>staleness (share of RTH minutes without a fresh print)</text>`;74 const extreme = rows.reduce((a, b) => (Math.abs(b[key]) > Math.abs(a[key]) ? b : a), rows[0]);75 for (const r of rows) {76 g += `<circle cx="${xp(r.staleness).toFixed(1)}" cy="${yp(r[key]).toFixed(1)}" r="4.5" fill="${BLUE}" stroke="${SURFACE}" stroke-width="2"><title>${esc(r.ticker)} — staleness ${r.staleness}, ${esc(title)} ${r[key]}</title></circle>`;77 }78 for (const r of [extreme]) {79 g += `<text x="${Math.min(xp(r.staleness) + 8, W - 44)}" y="${yp(r[key]) + 4}" font-size="10.5" fill="${MUTED}">${esc(r.ticker)}</text>`;80 }81 return g;82}8384function expBFigure(C) {85 const res = latestResults(C, "expB_artifact_baselines");86 if (!res) return "";87 const rows = Object.entries(res.data.per_ticker || {})88 .map(([ticker, m]) => ({ ticker, ...m }))89 .filter((r) => Number.isFinite(r.staleness));90 if (rows.length < 5) return "";91 const W = 760, H = 250;92 const half = 372;93 const svg = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Measured artifact levels versus staleness">94<g>${expBScatterPanel(rows.filter((r) => Number.isFinite(r.ac1)), "ac1", "1min return AC1", half, H, true)}</g>95<g transform="translate(${W - half},0)">${expBScatterPanel(rows.filter((r) => Number.isFinite(r["spy_leads_+1"])), "spy_leads_+1", "SPY leads +1 min (LOCF join)", half, H, true)}</g>96</svg>`;97 return fig(svg, `expB — artifact null levels, one dot per ticker (Q1 2024, RTH 1min). ` +98 `Bounce pushes AC1 negative and LOCF joins make SPY spuriously lead, both in proportion to staleness. ` +99 `Run ${esc(res.run)}, regenerated from results.json.`);100}101102// ------------------------------------------------------------------- expC103function expCFigure(C) {104 const res = latestResults(C, "expC_reversion_scan");105 if (!res) return "";106 const cells = (res.data.cells || []).filter((c) => Number.isFinite(c.vr30) && Number.isFinite(c.ac1));107 if (!cells.length) return "";108 for (const c of cells) c.vr30_excess = c.vr30 - (1 + 2 * c.ac1 * (1 - 1 / 30));109 const rowsDef = [];110 for (const tf of ["1day", "30min", "5min", "1min"]) {111 for (const per of ["2000-2007", "2008-2015", "2014-2015"]) {112 const cs = cells.filter((c) => c.timeframe === tf && c.period === per);113 if (cs.length) rowsDef.push({ label: `${tf} · ${per}`, cells: cs });114 }115 }116 const W = 760, ML = 150, MR = 16, MT = 30, RH = 30, MB = 46;117 const H = MT + rowsDef.length * RH + MB;118 const xmin = -0.32, xmax = 0.16;119 const iw = W - ML - MR;120 const xp = (v) => ML + ((Math.max(xmin, Math.min(xmax, v)) - xmin) / (xmax - xmin)) * iw;121 let g = "";122 for (const t of [-0.3, -0.2, -0.1, 0, 0.1]) {123 g += `<line x1="${xp(t)}" y1="${MT - 6}" x2="${xp(t)}" y2="${MT + rowsDef.length * RH}" stroke="${t === 0 ? "#c9c4b6" : GRID}" stroke-width="1"/>124<text x="${xp(t)}" y="${MT + rowsDef.length * RH + 16}" text-anchor="middle" ${AXIS_TXT}>${t}</text>`;125 }126 g += `<text x="${ML + iw / 2}" y="${H - 6}" text-anchor="middle" ${AXIS_TXT}>VR(30) excess over the MA(1)-consistent null · negative = multi-lag reversion beyond any lag-1 effect</text>`;127 const labeled = new Set(128 cells.filter((c) => c.fdr_vr30 && c.vr30_excess < -0.05)129 .sort((a, b) => a.vr30_excess - b.vr30_excess).slice(0, 3).map((c) => c.ticker + c.period + c.timeframe));130 rowsDef.forEach((row, i) => {131 const y = MT + i * RH + RH / 2;132 g += `<text x="${ML - 10}" y="${y + 4}" text-anchor="end" font-size="11.5" fill="${INK}">${esc(row.label)}</text>`;133 for (const c of row.cells) {134 const surv = c.fdr_vr30 && c.vr30_excess < -0.05;135 const tip = `${c.ticker} ${c.timeframe} ${c.period}: VR30 ${c.vr30}, excess ${c.vr30_excess.toFixed(3)}${surv ? " (FDR survivor)" : ""}`;136 g += surv137 ? `<circle cx="${xp(c.vr30_excess).toFixed(1)}" cy="${y}" r="5" fill="${BLUE}" stroke="${SURFACE}" stroke-width="2"><title>${esc(tip)}</title></circle>`138 : `<circle cx="${xp(c.vr30_excess).toFixed(1)}" cy="${y}" r="3.5" fill="${SURFACE}" stroke="${GRAY}" stroke-width="1.4" opacity=".8"><title>${esc(tip)}</title></circle>`;139 if (labeled.has(c.ticker + c.period + c.timeframe)) {140 g += `<text x="${xp(c.vr30_excess) - 8}" y="${y - 8}" text-anchor="middle" font-size="10.5" fill="${MUTED}">${esc(c.ticker)}</text>`;141 }142 }143 });144 const legend = `<g font-size="11" fill="${MUTED}">145<circle cx="${ML}" cy="${MT - 16}" r="5" fill="${BLUE}" stroke="${SURFACE}" stroke-width="2"/><text x="${ML + 10}" y="${MT - 12}" fill="${INK}">FDR survivor (excess < −0.05)</text>146<circle cx="${ML + 210}" cy="${MT - 16}" r="3.5" fill="${SURFACE}" stroke="${GRAY}" stroke-width="1.4"/><text x="${ML + 220}" y="${MT - 12}">other scan cells (Level 0)</text></g>`;147 const svg = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="expC variance-ratio excess by timeframe and period">${legend}${g}</svg>`;148 return fig(svg, `expC — multi-lag reversion triage on TRAIN. One dot per ticker-cell; ` +149 `the MA(1)-consistent null absorbs all lag-1 effects (bounce included); values beyond the axis range ` +150 `pile at its edge. Run ${esc(res.run)}, regenerated from results.json.`);151}152153// ------------------------------------------------------------------- expD154function expDFigure(C) {155 const res = latestResults(C, "expD_leadlag_scan");156 if (!res) return "";157 const out = [];158 for (const window of ["2014-2015", "2006-2007"]) {159 const rows = (res.data.pairs || [])160 .filter((p) => p.window === window && p.raw_xcorr && p.fresh_xcorr &&161 Number.isFinite(p.raw_xcorr["1"]) && Number.isFinite(p.fresh_xcorr["1"]))162 .sort((a, b) => (a.bucket + "").localeCompare(b.bucket + "") || b.raw_xcorr["1"] - a.raw_xcorr["1"]);163 if (!rows.length) continue;164 const W = 760, ML = 170, MR = 16, MT = 34, RH = 19, MB = 44;165 const H = MT + rows.length * RH + MB;166 const vals = rows.flatMap((p) => [p.raw_xcorr["1"], p.fresh_xcorr["1"]]);167 const xmin = Math.min(...vals, 0) - 0.01, xmax = Math.max(...vals, 0) + 0.01;168 const iw = W - ML - MR;169 const xp = (v) => ML + ((v - xmin) / (xmax - xmin)) * iw;170 let g = "";171 const ticks = [0, 0.05, 0.1].filter((t) => t >= xmin && t <= xmax);172 for (const t of ticks) {173 g += `<line x1="${xp(t)}" y1="${MT - 6}" x2="${xp(t)}" y2="${MT + rows.length * RH}" stroke="${t === 0 ? "#c9c4b6" : GRID}" stroke-width="1"/>174<text x="${xp(t)}" y="${MT + rows.length * RH + 16}" text-anchor="middle" ${AXIS_TXT}>${t}</text>`;175 }176 g += `<text x="${ML + iw / 2}" y="${H - 5}" text-anchor="middle" ${AXIS_TXT}>cross-correlation at +1 min (leader → follower)</text>`;177 let lastBucket = "";178 rows.forEach((p, i) => {179 const y = MT + i * RH + RH / 2;180 if (p.bucket !== lastBucket) {181 lastBucket = p.bucket;182 g += `<text x="${ML - 160}" y="${y + 4}" font-size="10" font-weight="700" fill="${MUTED}" letter-spacing=".08em">${esc(p.bucket.toUpperCase())}</text>`;183 }184 const raw = p.raw_xcorr["1"], fresh = p.fresh_xcorr["1"];185 const surv = p.fdr_fresh_p1 || p["fdr_fresh_+1"];186 g += `<text x="${ML - 10}" y="${y + 4}" text-anchor="end" font-size="11" fill="${INK}">${esc(p.pair)}</text>187<line x1="${xp(raw)}" y1="${y}" x2="${xp(fresh)}" y2="${y}" stroke="${GRID}" stroke-width="2"/>188<circle cx="${xp(raw).toFixed(1)}" cy="${y}" r="4.5" fill="${ORANGE}" stroke="${SURFACE}" stroke-width="2"><title>${esc(p.pair)} raw LOCF join: ${raw}</title></circle>189<circle cx="${xp(fresh).toFixed(1)}" cy="${y}" r="4.5" fill="${BLUE}" stroke="${SURFACE}" stroke-width="2"><title>${esc(p.pair)} both-fresh: ${fresh}${surv ? " (FDR survivor)" : ""}</title></circle>`;190 });191 const legend = `<g font-size="11">192<circle cx="${ML}" cy="${MT - 18}" r="4.5" fill="${ORANGE}" stroke="${SURFACE}" stroke-width="2"/><text x="${ML + 9}" y="${MT - 14}" fill="${INK}">raw LOCF join (artifact included)</text>193<circle cx="${ML + 230}" cy="${MT - 18}" r="4.5" fill="${BLUE}" stroke="${SURFACE}" stroke-width="2"/><text x="${ML + 239}" y="${MT - 14}" fill="${INK}">both-fresh (synchronized)</text></g>`;194 out.push(fig(195 `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="expD lead-lag: raw versus synchronized cross-correlation, ${window}">${legend}${g}</svg>`,196 `expD — lead at +1 min per pair, ${window}: the gap between the raw join and the both-fresh ` +197 `subsample is the non-synchronicity artifact (T3), measured. Run ${esc(res.run)}, regenerated from results.json.`198 ));199 }200 return out.join("");201}202203// ------------------------------------------------------------------- expE204function expEFigure(C) {205 const res = latestResults(C, "expE_calendar_scan");206 if (!res) return "";207 const t = res.data.calendar_tests || {};208 const obs = t.observed_bp || {}, band = t.perm_band95_bp || {};209 const names = Object.keys(obs);210 if (!names.length) return "";211 let out = "";212213 // Panel 1 — observed effect vs permuted-calendar 95% band (interval + dot)214 {215 const W = 760, ML = 130, MR = 20, MT = 30, RH = 30, MB = 44;216 const H = MT + names.length * RH + MB;217 const vals = names.flatMap((n) => [obs[n], ...(band[n] || [])]);218 const xmin = Math.min(...vals) - 2, xmax = Math.max(...vals) + 2;219 const iw = W - ML - MR;220 const xp = (v) => ML + ((v - xmin) / (xmax - xmin)) * iw;221 let g = "";222 for (const tick of [-20, -10, 0, 10, 20].filter((v) => v > xmin && v < xmax)) {223 g += `<line x1="${xp(tick)}" y1="${MT - 6}" x2="${xp(tick)}" y2="${MT + names.length * RH}" stroke="${tick === 0 ? "#c9c4b6" : GRID}" stroke-width="1"/>224<text x="${xp(tick)}" y="${MT + names.length * RH + 16}" text-anchor="middle" ${AXIS_TXT}>${tick}</text>`;225 }226 g += `<text x="${ML + iw / 2}" y="${H - 5}" text-anchor="middle" ${AXIS_TXT}>mean daily SPY return in class minus overall mean (bp) · gray bar = permuted-calendar 95% band</text>`;227 names.forEach((n, i) => {228 const y = MT + i * RH + RH / 2;229 const [lo, hi] = band[n] || [0, 0];230 g += `<text x="${ML - 10}" y="${y + 4}" text-anchor="end" font-size="11.5" fill="${INK}">${esc(n.replace(/_/g, " "))}</text>231<rect x="${xp(lo)}" y="${y - 5}" width="${Math.max(1, xp(hi) - xp(lo))}" height="10" rx="4" fill="${GRID}"/>232<circle cx="${xp(obs[n]).toFixed(1)}" cy="${y}" r="5" fill="${BLUE}" stroke="${SURFACE}" stroke-width="2"><title>${esc(n)}: observed ${obs[n]} bp — marginal p ${t.p_marginal?.[n]}, family-wise p ${t.p_familywise?.[n]}</title></circle>`;233 });234 out += fig(235 `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="expE calendar effects vs permuted-calendar null">${g}</svg>`,236 `expE — all 8 pre-declared calendar tests on SPY (train 2000–2016): every observed effect (dot) sits ` +237 `inside its permuted-calendar 95% band (bar). Nothing survives; the last-survivor turn-of-month included. ` +238 `Run ${esc(res.run)}, regenerated from results.json.`239 );240 }241242 // Panel 2 — H20 intraday profile: |return| and EDGE spread by half-hour243 const prof = (res.data.h20_intraday_profile || {}).profile || [];244 if (prof.length) {245 const W = 760, ML = 52, MR = 16, MT = 34, MB = 46, H = 260;246 const iw = W - ML - MR, ih = H - MT - MB;247 const ys = prof.flatMap((p) => [p.median_abs_1min_ret_bp, p.median_edge_spread_bp]).filter(Number.isFinite);248 const ymax = Math.max(...ys) * 1.12;249 const xp = (i) => ML + (i / (prof.length - 1)) * iw;250 const yp = (v) => MT + ih - (v / ymax) * ih;251 let g = "";252 for (const tick of [0, 2, 4, 6].filter((v) => v <= ymax)) {253 g += `<line x1="${ML}" y1="${yp(tick)}" x2="${ML + iw}" y2="${yp(tick)}" stroke="${GRID}" stroke-width="1"/>254<text x="${ML - 6}" y="${yp(tick) + 4}" text-anchor="end" ${AXIS_TXT}>${tick}</text>`;255 }256 prof.forEach((p, i) => {257 if (i % 2 === 0) g += `<text x="${xp(i)}" y="${MT + ih + 16}" text-anchor="middle" ${AXIS_TXT}>${esc(p.bucket)}</text>`;258 });259 const series = [260 ["median_abs_1min_ret_bp", BLUE, "median |1min return|"],261 ["median_edge_spread_bp", ORANGE, "median EDGE spread"],262 ];263 for (const [key, color, label] of series) {264 const pts = prof.map((p, i) => [xp(i), yp(p[key])]).filter((q) => Number.isFinite(q[1]));265 g += `<path d="${pts.map((q, i) => `${i ? "L" : "M"}${q[0].toFixed(1)},${q[1].toFixed(1)}`).join(" ")}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round"/>`;266 prof.forEach((p, i) => {267 if (Number.isFinite(p[key])) g += `<circle cx="${xp(i).toFixed(1)}" cy="${yp(p[key]).toFixed(1)}" r="4" fill="${color}" stroke="${SURFACE}" stroke-width="2"><title>${esc(p.bucket)} — ${esc(label)}: ${p[key]} bp</title></circle>`;268 });269 }270 g += `<g font-size="11" fill="${INK}">271<circle cx="${ML}" cy="${MT - 16}" r="4" fill="${BLUE}" stroke="${SURFACE}" stroke-width="2"/><text x="${ML + 9}" y="${MT - 12}">median |1min return| (bp)</text>272<circle cx="${ML + 210}" cy="${MT - 16}" r="4" fill="${ORANGE}" stroke="${SURFACE}" stroke-width="2"/><text x="${ML + 219}" y="${MT - 12}">median EDGE spread (bp)</text></g>273<text x="${ML + iw / 2}" y="${H - 5}" text-anchor="middle" ${AXIS_TXT}>half-hour bucket (RTH) · liquid 12, 1min, 2014–2015</text>`;274 out += fig(275 `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="expE intraday profile of volatility and spread">${g}</svg>`,276 `expE / H20 — the intraday artifact profile (taxonomy input): volatility is U-shaped (6.6 bp at the open, ` +277 `2.4 midday, 2.9 at the close); the spread declines monotonically (2.8 → 1.2 bp). Any "first-30-minutes" return ` +278 `claim faces 2–3× the midday artifact level. Run ${esc(res.run)}.`279 );280 }281 return out;282}283284// ------------------------------------------------------------------- expF285function expFFigure(C) {286 const res = latestResults(C, "expF_multiple_testing");287 if (!res) return "";288 const f = res.data.funnel || {};289 const stages = [290 ["searched universe", f.universe_rules, "all scanned cells/pairs/classes (×2 signs)"],291 ["naive |t| > 1.96", f.naive_t196, "uncorrected in-sample t-test"],292 ["BH-FDR 5%", f.fdr_survivors, "false-discovery-rate correction"],293 ["Hansen SPA step-1", (f.spa_step1_survivors || []).length, "data-snooping correction"],294 ].filter((s) => Number.isFinite(s[1]));295 if (stages.length < 3) return "";296 const W = 760, ML = 235, MR = 90, MT = 26, RH = 46, MB = 40;297 const H = MT + stages.length * RH + MB;298 const iw = W - ML - MR;299 const max = stages[0][1];300 let g = "";301 stages.forEach(([label, n, sub], i) => {302 const y = MT + i * RH;303 const w = Math.max(2, (n / max) * iw);304 g += `<text x="${ML - 10}" y="${y + 17}" text-anchor="end" font-size="11.5" fill="${INK}" font-weight="600">${esc(label)}</text>305<text x="${ML - 10}" y="${y + 31}" text-anchor="end" font-size="9.5" fill="${MUTED}">${esc(sub)}</text>306<rect x="${ML}" y="${y + 6}" width="${w.toFixed(1)}" height="22" rx="4" fill="${i === stages.length - 1 ? ORANGE : BLUE}"><title>${esc(label)}: ${n} rules (${Math.round((n / max) * 100)}%)</title></rect>307<text x="${ML + w + 8}" y="${y + 21}" font-size="12" fill="${INK}" font-weight="640">${n} <tspan fill="${MUTED}" font-weight="400" font-size="10.5">(${Math.round((n / max) * 100)}%)</tspan></text>`;308 });309 g += `<text x="${ML}" y="${H - 10}" font-size="10.5" fill="${MUTED}">Survivors are gross and artifact-laden — costs (expG) are the next layer.</text>`;310 const svg = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="expF survival funnel across correction layers">${g}</svg>`;311 return fig(svg, `expF — the survival curve: what fraction of the searched rule universe survives each ` +312 `statistical-correction layer on TRAIN. Statistical correction fixes the search, not the mechanism. ` +313 `Run ${esc(res.run)}, regenerated from results.json.`);314}315316// ------------------------------------------------------------------- expG317function expGFigure(C) {318 const res = latestResults(C, "expG_cost_frontier");319 if (!res) return "";320 const rows = (res.data.items || [])321 .filter((i) => Number.isFinite(i.kappa_star) && i.kappa_star > 0)322 .sort((a, b) => b.kappa_star - a.kappa_star);323 if (rows.length < 5) return "";324 const W = 760, ML = 235, MR = 20, MT = 40, RH = 19, MB = 46;325 const H = MT + rows.length * RH + MB;326 const iw = W - ML - MR;327 const xmin = Math.log10(0.001), xmax = Math.log10(2);328 const xp = (v) => ML + ((Math.log10(Math.max(v, 0.001)) - xmin) / (xmax - xmin)) * iw;329 let g = "";330 for (const [t, label] of [[0.001, "0.001"], [0.01, "0.01"], [0.1, "0.1"], [1, "1"]]) {331 g += `<line x1="${xp(t)}" y1="${MT - 6}" x2="${xp(t)}" y2="${MT + rows.length * RH}" stroke="${GRID}" stroke-width="1"/>332<text x="${xp(t)}" y="${MT + rows.length * RH + 16}" text-anchor="middle" ${AXIS_TXT}>${label}</text>`;333 }334 for (const [t, label] of [[0.25, "patient execution"], [1.0, "full half-spread"]]) {335 g += `<line x1="${xp(t)}" y1="${MT - 14}" x2="${xp(t)}" y2="${MT + rows.length * RH}" stroke="${ORANGE}" stroke-width="1.4" stroke-dasharray="4 3"/>336<text x="${xp(t)}" y="${MT - 18}" text-anchor="middle" font-size="10" fill="${ORANGE}">${label}</text>`;337 }338 g += `<text x="${ML + iw / 2}" y="${H - 5}" text-anchor="middle" ${AXIS_TXT}>breakeven cost multiplier κ* (× half-spread paid per trade, log scale) — right of a line = survives that cost level</text>`;339 rows.forEach((r, i) => {340 const y = MT + i * RH + RH / 2;341 g += `<text x="${ML - 10}" y="${y + 4}" text-anchor="end" font-size="10.5" fill="${INK}">${esc(r.rule)}</text>342<line x1="${xp(0.001)}" y1="${y}" x2="${xp(r.kappa_star)}" y2="${y}" stroke="${GRID}" stroke-width="2"/>343<circle cx="${xp(r.kappa_star).toFixed(1)}" cy="${y}" r="4.5" fill="${BLUE}" stroke="${SURFACE}" stroke-width="2"><title>${esc(r.rule)} — κ* = ${r.kappa_star} · gross ${r.gross_mean_daily_bp} bp/day · turnover ${r.turnover_per_day}/day · half-spread ${r.half_spread_bp} bp</title></circle>`;344 });345 const svg = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="expG breakeven cost multiplier per rule, log scale">${g}</svg>`;346 return fig(svg, `expG — the cost frontier: every rule that beat the artifact nulls AND the search correction ` +347 `dies when it must pay a fraction of its own half-spread (median κ* = ${res.data.summary?.kappa_star_median}). ` +348 `Run ${esc(res.run)}, regenerated from results.json.`);349}350351// ------------------------------------------------------------------- expH352function expHFigure(C) {353 const res = latestResults(C, "expH_oos_stability");354 if (!res) return "";355 const s = res.data.summary || {};356 const fam = s.daily_family_median_vr30_excess || {};357 const rows = [358 ["daily reversal family (median VR30 excess)", fam.train_2008_2015, fam.validation, "alpha"],359 ["ES→SPY cross-serial corr (fresh, −1 min)", -0.032, s["es_spy_val_fresh_-1"], "alpha"],360 ["SPX→SPY staleness lead (+1 min)", 0.132, s["spx_spy_val_fresh_-1"], "artifact"],361 ].filter((r) => Number.isFinite(r[1]) && Number.isFinite(r[2]));362 if (rows.length < 2) return "";363 const W = 760, ML = 300, MR = 30, MT = 40, RH = 52, MB = 20;364 const H = MT + rows.length * RH + MB;365 const iw = W - ML - MR;366 let g = `<g font-size="11" fill="${INK}">367<circle cx="${ML}" cy="${MT - 22}" r="4.5" fill="${ORANGE}" stroke="${SURFACE}" stroke-width="2"/><text x="${ML + 9}" y="${MT - 18}">train (2008–2015)</text>368<circle cx="${ML + 160}" cy="${MT - 22}" r="4.5" fill="${BLUE}" stroke="${SURFACE}" stroke-width="2"/><text x="${ML + 169}" y="${MT - 18}">validation (2016–2021)</text></g>`;369 rows.forEach(([label, tr, va, kind], i) => {370 const y = MT + i * RH + RH / 2;371 const lim = Math.max(Math.abs(tr), Math.abs(va)) * 1.25;372 const xp = (v) => ML + ((v + lim) / (2 * lim)) * iw;373 g += `<text x="${ML - 10}" y="${y - 4}" text-anchor="end" font-size="11.5" fill="${INK}" font-weight="600">${esc(label)}</text>374<text x="${ML - 10}" y="${y + 12}" text-anchor="end" font-size="9.5" fill="${kind === "artifact" ? "#8a5a1e" : MUTED}">${kind === "artifact" ? "known artifact — persists OOS" : "candidate 'alpha' — collapses OOS"}</text>375<line x1="${xp(0)}" y1="${y - 14}" x2="${xp(0)}" y2="${y + 14}" stroke="#c9c4b6" stroke-width="1"/>376<line x1="${xp(tr)}" y1="${y}" x2="${xp(va)}" y2="${y}" stroke="${GRID}" stroke-width="2"/>377<circle cx="${xp(tr).toFixed(1)}" cy="${y}" r="5" fill="${ORANGE}" stroke="${SURFACE}" stroke-width="2"><title>${esc(label)} — train: ${tr}</title></circle>378<circle cx="${xp(va).toFixed(1)}" cy="${y}" r="5" fill="${BLUE}" stroke="${SURFACE}" stroke-width="2"><title>${esc(label)} — validation: ${va}</title></circle>379<text x="${xp(tr) + (tr < va ? -8 : 8)}" y="${y - 9}" text-anchor="${tr < va ? "end" : "start"}" font-size="10" fill="${MUTED}">${tr}</text>380<text x="${xp(va) + (tr < va ? 8 : -8)}" y="${y - 9}" text-anchor="${tr < va ? "start" : "end"}" font-size="10" fill="${MUTED}">${va}</text>`;381 });382 const svg = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="expH: train versus validation — alphas collapse, the artifact persists">${g}</svg>`;383 return fig(svg, `expH — the validation split, opened once: every candidate "alpha" collapses toward zero ` +384 `out-of-sample while the known staleness artifact persists. Vertical tick = zero (each row has its own ` +385 `scale). Run ${esc(res.run)}, regenerated from results.json.`);386}387388const BUILDERS = {389 expB_artifact_baselines: expBFigure,390 expC_reversion_scan: expCFigure,391 expD_leadlag_scan: expDFigure,392 expE_calendar_scan: expEFigure,393 expF_multiple_testing: expFFigure,394 expG_cost_frontier: expGFigure,395 expH_oos_stability: expHFigure,396};397398/** Figures for an experiment page ("" when none apply). */399function figuresFor(experiment, C) {400 const builder = BUILDERS[experiment];401 try {402 return builder ? builder(C) : "";403 } catch {404 return "";405 }406}407408/** The most recent experiment figure, for the home page. */409function homeFigure(C) {410 for (const exp of ["expH_oos_stability", "expG_cost_frontier", "expF_multiple_testing",411 "expE_calendar_scan", "expD_leadlag_scan", "expC_reversion_scan",412 "expB_artifact_baselines"]) {413 const html = figuresFor(exp, C);414 if (html) return { experiment: exp, html: html.split("</figure>")[0] + "</figure>" };415 }416 return null;417}418419module.exports = { figuresFor, homeFigure };420