SPB Git

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%
25.0 KB · 420 lines javascript
Raw Blame History
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 &lt; −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