Repository Public Research preview
1"""Figure generators for WP46 (docs/EXHIBITS_MANDATE.md, figures 2-14; figure 1 = scripts/make_genealogy_figure.py).23Every function reads machine outputs (results/), draws nothing by hand, writes a vector PDF to paper/figures/ at its final4print width (plotstyle.W1 column / W2 text width, so that font sizes are the printed ones) and records a row in5results/manifests/figure_audit.csv through `_audit` (figure, sources, n, note, size). Captions live in LaTeX; titles are6used only where a figure belongs to a set (tier, horizon). Degenerate values never dictate an axis: colour scales are7clipped and the clipped cells carry a glyph, Pareto/regime/class exhibits are trimmed with a note, full numbers are in the8tables (mandate §6)."""9from __future__ import annotations1011import csv12import json13from pathlib import Path1415import matplotlib.pyplot as plt16import matplotlib.transforms as mtrans17import numpy as np18import pandas as pd19import yaml20from matplotlib.lines import Line2D21from matplotlib.patches import FancyBboxPatch, Patch2223from . import plotstyle as ps2425ROOT = Path(__file__).resolve().parents[3]26FIG = ROOT / "paper" / "figures"27REG = yaml.safe_load(open(ROOT / "configs" / "model_registry.yaml"))["models"]28INST = yaml.safe_load(open(ROOT / "configs" / "instruments.yaml"))29CLASS_OF = {s: c for c, syms in (INST.get("universe") or INST["candidates"]).items() for s in syms}30CLASS_ORDER = ["etf", "stock", "futures", "fx", "crypto"]31CLASS_LABEL = {"etf": "ETFs", "stock": "Stocks", "futures": "Futures", "fx": "FX", "crypto": "Crypto"}32AUDIT = ROOT / "results" / "manifests" / "figure_audit.csv"33EXPLOSIVE = 2.0 # a per-instrument ratio >= 2 (or <= 1/2) is catastrophic/degenerate for display purposes34RATIO_RANGE = (0.8, 1.25) # symmetric on the log scale; cells beyond saturate and carry a glyph35ps.apply()363738# ----------------------------------------------------------------------------- audit (one row per figure, rewritten by build_paper)39def reset_audit() -> None:40 AUDIT.parent.mkdir(parents=True, exist_ok=True)41 with open(AUDIT, "w", newline="") as fh:42 csv.writer(fh).writerow(["figure", "sources", "n", "note", "size_in", "checks"])434445def _audit(fig_name: str, sources: list[str], n: int, note: str = "", size: tuple[float, float] | None = None) -> None:46 rows = []47 if AUDIT.exists():48 with open(AUDIT, newline="") as fh:49 rows = [r for r in csv.reader(fh)][1:]50 rows = [r for r in rows if r and r[0] != fig_name]51 rows.append([fig_name, ";".join(sources), n, note, f"{size[0]:.2f}x{size[1]:.2f}" if size else "",52 "serif Times/newtx; fonts>=7pt at print width; validated palette + markers/hatches; rendered pdftoppm -r110 colour+gray"])53 with open(AUDIT, "w", newline="") as fh:54 w = csv.writer(fh); w.writerow(["figure", "sources", "n", "note", "size_in", "checks"]); w.writerows(rows)555657def _save(fig, path: Path, sources: list[str], n: int, note: str = "") -> Path:58 size = tuple(fig.get_size_inches())59 ps.save(fig, path); _audit(path.name, sources, n, note, size)60 return path616263# ----------------------------------------------------------------------------- labels and ordering64def spec_label(spec_id: str) -> str:65 """Table label (unchanged: used by build_paper for tables and macros)."""66 mid, dist, _ = spec_id.split("__")67 lab = REG.get(mid, {}).get("label", mid).split(" (")[0]68 d = {"normal": "N", "t": "t", "ged": "GED", "skewt": "skt", "empirical": ""}[dist]69 return f"{lab}-{d}" if d else lab707172SHORT_MODEL = {"histvar22": "Hist. variance (22 d)", "histvar66": "Hist. variance (66 d)", "histvar252": "Hist. variance (252 d)",73 "constvar": "Constant variance", "ewma094": "EWMA (0.94)", "rm2006": "RiskMetrics 2006", "rm_rw": "RV random walk",74 "har_rv": "HAR-RV", "loghar_rv": "log-HAR-RV", "garchx_rv11": "GARCH-X(1,1)-RV", "realgarch11": "Realized GARCH(1,1)",75 "realegarch11": "Realized EGARCH(1,1)", "gas_t11": "GAS-t(1,1)", "stgarch11": "ST-GARCH(1,1)", "betategarch11": "Beta-t-EGARCH",76 "garch_midas": "GARCH-MIDAS", "cgarch11": "Component GARCH", "msgarch2": "MS-GARCH(2)"}777879def fig_label(spec_id: str) -> str:80 """Figure label: same model names as the tables, long names abbreviated, no meaningless law suffix on the baselines."""81 mid, dist, _ = spec_id.split("__")82 lab = SHORT_MODEL.get(mid, REG.get(mid, {}).get("label", mid).split(" (")[0])83 d = {"normal": "N", "t": "t", "ged": "GED", "skewt": "skt", "empirical": ""}[dist]84 if fam(spec_id) == "baseline" or not d:85 return lab86 return f"{lab}-{d}"878889def fam(spec_id: str) -> str:90 return REG.get(spec_id.split("__")[0], {}).get("family", "baseline")919293def order_specs(specs) -> list[str]:94 """Family blocks in the fixed order, then by label inside a family (baselines first, realized last)."""95 return sorted(set(specs), key=lambda s: (ps.FAMILY_ORDER.index(fam(s)) if fam(s) in ps.FAMILY_ORDER else 99, fig_label(s)))969798def _syms(per_symbol: dict) -> list[str]:99 return sorted(per_symbol, key=lambda s: (CLASS_ORDER.index(CLASS_OF[s]), s))100101102def _hz(r: dict, h: int) -> dict:103 return r["horizons"][str(h)] if str(h) in r["horizons"] else r["horizons"][h]104105106# ----------------------------------------------------------------------------- matrix scaffold (specs x instruments)107def _matrix(M: np.ndarray, specs: list[str], syms: list[str], cmap, vmin: float, vmax: float, title: str = "", row_in: float | None = None, tag: str = ""):108 """Image with specification rows (family blocks, names at left, family tags at right) and instrument columns (class blocks with109 headers at the top). Returns fig, ax, im. Heights scale with the number of rows so that row labels stay >= 7 pt. `tag` = grey110 exploratory tag drawn under the colour bar."""111 nr, nc = M.shape112 rp = row_in or (0.138 if nr <= 40 else 0.100) # 7 pt labels need >= 0.097 in per row113 W = ps.W2 + 0.45 # margins hold the labels; the trimmed content is ~W2 wide114 extra = 0.22 if tag else 0.0115 left, right, top, bottom = 1.45, 0.78, 0.62 + (0.2 if title else 0), 0.72 + extra116 H = nr * rp + top + bottom117 fig = plt.figure(figsize=(W, H))118 ax = fig.add_axes([left / W, bottom / H, (W - left - right) / W, (nr * rp) / H])119 fig._wp46_bar = fig.add_axes([(left + 0.62 * (W - left - right)) / W, (0.40 + extra) / H, 0.38 * (W - left - right) / W, 0.09 / H]) # colour bar slot120 fig._wp46_legend_y = 0.36 + extra121 if tag:122 fig.text(left / W, 0.06 / H, tag, fontsize=ps.MIN_FONT, color=ps.INK2, va="bottom", ha="left", bbox=dict(boxstyle="round,pad=0.25", fc=ps.BAND, ec="none"))123 im = ax.imshow(np.ma.masked_invalid(M), cmap=cmap, vmin=vmin, vmax=vmax, aspect="auto", interpolation="nearest")124 ax.grid(False)125 for s in ax.spines.values():126 s.set_visible(True); s.set_edgecolor(ps.INK3); s.set_linewidth(0.5)127 # missing cells: light hatch128 for i, j in zip(*np.where(~np.isfinite(M))):129 ax.add_patch(plt.Rectangle((j - 0.5, i - 0.5), 1, 1, fc="#ffffff", ec="none", hatch="////", lw=0))130 # rows131 ax.set_yticks(range(nr)); ax.set_yticklabels([fig_label(s) for s in specs], fontsize=ps.MIN_FONT)132 ax.tick_params(axis="y", length=0, pad=2)133 fams = [fam(s) for s in specs]134 for k in range(1, nr):135 if fams[k] != fams[k - 1]:136 ax.axhline(k - 0.5, color=ps.INK, lw=0.7)137 k0 = 0138 for k in range(1, nr + 1):139 if k == nr or fams[k] != fams[k0]:140 ax.text(nc - 0.5 + 0.25, (k0 + k - 1) / 2, ps.FAMILY_SHORT.get(fams[k0], fams[k0]), fontsize=ps.MIN_FONT, color=ps.INK2,141 va="center", ha="left", clip_on=False)142 k0 = k143 # columns (instruments at the top, class headers above them)144 ax.set_xticks(range(nc)); ax.set_xticklabels(syms, rotation=90, fontsize=ps.MIN_FONT)145 ax.xaxis.tick_top(); ax.tick_params(axis="x", length=0, pad=2)146 cls = [CLASS_OF[s] for s in syms]147 for k in range(1, nc):148 if cls[k] != cls[k - 1]:149 ax.axvline(k - 0.5, color=ps.INK, lw=0.7)150 lab_pt = 7 * 0.62 * max(len(s) for s in syms) + 8151 tr = mtrans.offset_copy(ax.get_xaxis_transform(), fig=fig, x=0, y=lab_pt, units="points")152 k0 = 0153 for k in range(1, nc + 1):154 if k == nc or cls[k] != cls[k0]:155 ax.plot([k0 - 0.35, k - 1 + 0.35], [1, 1], transform=tr, color=ps.INK3, lw=0.5, clip_on=False)156 ax.annotate(f"{CLASS_LABEL[cls[k0]]} ({k - k0})", xy=((k0 + k - 1) / 2, 1), xycoords=("data", "axes fraction"), xytext=(0, lab_pt + 2),157 textcoords="offset points", ha="center", va="bottom", fontsize=ps.MIN_FONT, color=ps.INK)158 k0 = k159 if title:160 ax.set_title(title, loc="left", fontsize=8.5, y=1.0, pad=lab_pt + 16)161 fig._wp46_title_pt = lab_pt + 16162 return fig, ax, im163164165def _glyph(ax, j, i, kind: str, dark: bool) -> None:166 """Significance / membership / clipping glyphs drawn as markers (never font glyphs): ink on light cells, white on dark cells."""167 c = "#ffffff" if dark else ps.INK168 if kind == "filled":169 ax.scatter(j, i, s=4.5, marker="o", color=c, linewidths=0, zorder=4)170 elif kind == "open":171 ax.scatter(j, i, s=5.5, marker="o", facecolors="none", edgecolors=c, linewidths=0.5, zorder=4)172 elif kind == "cross":173 ax.scatter(j, i, s=9, marker="x", color=c, linewidths=0.6, zorder=4)174175176def _glyph_legend(fig, ax, items: list[tuple[str, str]], y_in: float) -> None:177 hs = []178 for kind, lab in items:179 if kind == "filled":180 hs.append(Line2D([], [], marker="o", color=ps.INK, ls="none", ms=2.4, label=lab))181 elif kind == "open":182 hs.append(Line2D([], [], marker="o", mfc="none", mec=ps.INK, ls="none", ms=2.8, mew=0.5, label=lab))183 elif kind == "cross":184 hs.append(Line2D([], [], marker="x", color=ps.INK, ls="none", ms=3.2, mew=0.6, label=lab))185 elif kind == "hatch":186 hs.append(Patch(fc="#ffffff", ec=ps.INK3, hatch="////", lw=0.4, label=lab))187 W, H = fig.get_size_inches()188 x0 = ax.get_position().x0 if ax is not None else 0.02189 fig.legend(handles=hs, loc="center left", bbox_to_anchor=(x0, y_in / H), ncol=1 if len(hs) > 2 and W < 5 else 2 if len(hs) > 2 else len(hs),190 fontsize=ps.MIN_FONT, handletextpad=0.4, columnspacing=1.0, labelspacing=0.25, frameon=False, borderaxespad=0)191192193# ----------------------------------------------------------------------------- 2. coverage map194def coverage_map(panels_manifest=ROOT / "results" / "manifests" / "panels_manifest.json", path=FIG / "fig_coverage.pdf") -> Path:195 m = json.load(open(panels_manifest))196 rows = sorted([v for v in m.values() if "first" in v], key=lambda v: (CLASS_ORDER.index(v["asset_class"]), v["symbol"]))197 n = len(rows); rp = 0.118198 fig, ax = plt.subplots(figsize=(ps.W2, n * rp + 0.95), layout="constrained")199 t_val, t_test, t_end = pd.Timestamp("2012-01-03"), pd.Timestamp("2016-01-04"), pd.Timestamp("2026-10-30")200 ax.axvspan(t_val, t_test, color="#f6f6f3", zorder=0, lw=0); ax.axvspan(t_test, t_end, color=ps.BAND, zorder=0, lw=0)201 for t in (t_val, t_test):202 ax.axvline(t, color=ps.INK2, lw=0.6, zorder=1)203 ax.text(t_val + (t_test - t_val) / 2, -0.9, "validation", fontsize=ps.MIN_FONT, color=ps.INK2, ha="center", va="center")204 ax.text(t_test + pd.Timedelta(days=60), -0.9, "test period (all confirmatory results)", fontsize=ps.MIN_FONT, color=ps.INK2, ha="left", va="center")205 for i, v in enumerate(rows):206 a, b = pd.Timestamp(v["first"]), pd.Timestamp(v["last"])207 c = ps.CLASS_COLOR[v["asset_class"]]208 ax.plot([a, b], [i, i], color=c, lw=3.0, solid_capstyle="butt", zorder=3)209 ax.text(t_end + pd.Timedelta(days=80), i, f"{100 * v['valid_rm_rate']:.0f}", va="center", ha="left", fontsize=ps.MIN_FONT, color=ps.INK2)210 ax.text(t_end + pd.Timedelta(days=80), -0.9, "valid RM, %", va="center", ha="left", fontsize=ps.MIN_FONT, color=ps.INK2)211 ax.set_yticks(range(n)); ax.set_yticklabels([v["symbol"] for v in rows], fontsize=ps.MIN_FONT); ax.tick_params(axis="y", length=0)212 # class blocks: separators + labels at the left of the tickers213 cls = [v["asset_class"] for v in rows]214 k0 = 0215 for k in range(1, n + 1):216 if k == n or cls[k] != cls[k0]:217 if k < n:218 ax.axhline(k - 0.5, color=ps.GRID, lw=0.6, zorder=1)219 ax.annotate(f"{CLASS_LABEL[cls[k0]]}\n({k - k0})", xy=(0, (k0 + k - 1) / 2), xycoords=("axes fraction", "data"), xytext=(-52, 0),220 textcoords="offset points", ha="center", va="center", fontsize=ps.MIN_FONT, color=ps.INK)221 k0 = k222 ax.set_ylim(n - 0.5, -1.6); ax.set_xlim(pd.Timestamp("1999-06-01"), pd.Timestamp("2028-06-01"))223 ax.grid(axis="y", visible=False); ax.grid(axis="x", visible=True)224 ax.set_xlabel("Session date (daily panels built from FirstRate one-minute bars)")225 for s in ("left", "top", "right"):226 ax.spines[s].set_visible(False)227 return _save(fig, path, [str(panels_manifest.relative_to(ROOT))], n, "bars = sample span by class; right column = share of sessions with valid realized measures")228229230# ----------------------------------------------------------------------------- 3. volatility series with episodes231def volatility_series(symbols: list[str], panels_dir: Path, path: Path) -> Path:232 prot = yaml.safe_load(open(ROOT / "configs" / "forecast_protocol.yaml"))233 ep_lab = {"covid": "COVID-19", "rate_shock_2022": "2022 rate shock", "tariff_shock_2025": "2025 tariff shock"}234 fig, axes = plt.subplots(len(symbols), 1, figsize=(ps.W2, 1.15 * len(symbols) + 0.6), sharex=True, layout="constrained")235 axes = np.atleast_1d(axes); n_obs = 0236 for k, (ax, s) in enumerate(zip(axes, symbols)):237 p = pd.read_parquet(panels_dir / f"{s}.parquet"); p["session"] = pd.to_datetime(p["session"])238 y = np.sqrt(252 * (p["rv_5m_ss"].where(p["valid_rm"]) + p["r_on"].fillna(0) ** 2)).rolling(22, min_periods=10).mean()239 n_obs += int(y.notna().sum())240 for e, (name, w) in enumerate(prot["regimes"]["calendar_episodes"].items()):241 a, b = pd.Timestamp(w["start"]), pd.Timestamp(w["end"])242 ax.axvspan(a, b, color=ps.BAND, zorder=0, lw=0)243 if k == 0: # episode names above the top panel, staggered on two lines so that they never collide244 ax.annotate(ep_lab.get(name, name), xy=(a + (b - a) / 2, 1), xycoords=("data", "axes fraction"), xytext=(0, 3 + 9 * (e % 2)),245 textcoords="offset points", ha="center", va="bottom", fontsize=ps.MIN_FONT, color=ps.INK2)246 ax.axvline(pd.Timestamp("2016-01-04"), color=ps.INK2, lw=0.6, zorder=1)247 if k == len(symbols) - 1:248 ax.text(pd.Timestamp("2016-03-01"), 0.95, "test period →", transform=mtrans.blended_transform_factory(ax.transData, ax.transAxes),249 ha="left", va="top", fontsize=ps.MIN_FONT, color=ps.INK2)250 c = ps.CLASS_COLOR[CLASS_OF[s]]251 ax.plot(p["session"], y, color=c, lw=0.7, zorder=3)252 ax.text(0.008, 0.95, f"{s} ({CLASS_LABEL[CLASS_OF[s]].rstrip('s') if CLASS_OF[s] != 'fx' else 'FX'})", transform=ax.transAxes, fontsize=8,253 fontweight="bold", ha="left", va="top")254 ax.set_ylabel("Ann. RV (%)", fontsize=ps.MIN_FONT); ax.set_ylim(0, None)255 ax.grid(axis="x", visible=False)256 axes[-1].set_xlim(pd.Timestamp("1999-06-01"), pd.Timestamp("2027-06-01")); axes[-1].set_xlabel("Session date")257 fig.get_layout_engine().set(hspace=0.04)258 return _save(fig, path, [f"panels/{s}.parquet" for s in symbols], n_obs, "22-day MA of annualized full-day RV; shaded = pre-registered episodes; line = test start")259260261# ----------------------------------------------------------------------------- 4. reproducibility / lineage flow262def lineage(path=FIG / "fig_lineage.pdf") -> Path:263 im = json.load(open(ROOT / "results" / "manifests" / "ingest_manifest.json"))264 pm = json.load(open(ROOT / "results" / "manifests" / "panels_manifest.json"))265 n_files = sum(len(v.get("minute", [])) + len(v.get("daily", [])) for v in im.values())266 n_rows = sum(r.get("row_count") or 0 for v in im.values() for r in v.get("minute", []))267 n_panels = sum(1 for v in pm.values() if "rows" in v)268 fdir = ROOT / "results" / "forecasts" / "main"269 n_jobs = sum(1 for f in fdir.glob("*/*.parquet") if not f.name.endswith(".fits.parquet")) if fdir.exists() else 0270 boxes = [("1 FinData OS", f"FirstRate Data, versioned snapshots\n{len(im)} instruments, {n_files} export files\nSHA-256 per file\n{n_rows / 1e6:.0f} M one-minute bars"),271 ("2 Daily panels", f"{n_panels} panels: returns (%),\novernight split, 7 realized\nmeasures, validity flags\n(panels_manifest.json)"),272 ("3 Pre-registered protocol", "universe, periods, models,\nlosses, bootstrap, regimes;\nfrozen before any test loss\n(git tag prereg-frozen)"),273 ("4 Rolling forecasts", f"{n_jobs} jobs on 6 MacLustr nodes\nper-job manifest: git SHA,\nversions, CPU / wall time,\npeak RSS, failure codes"),274 ("5 Losses and inference", "QLIKE / MSE / MAE, 7 proxies\nDM-HAC, MCS, SPA\njoint block bootstrap\nVaR / ES backtests"),275 ("6 Exhibits", "numbers.tex, tables/*.tex,\nfigures/*.pdf built by\nscripts/build_paper.py\n(figure_audit.csv)")]276 fig, ax = plt.subplots(figsize=(ps.W2, 2.75), layout="constrained"); ax.axis("off"); ax.set_xlim(0, 3); ax.set_ylim(0, 2)277 for i, (t, sub) in enumerate(boxes):278 r, c = divmod(i, 3)279 x0, y0 = c + 0.05, 1.08 - r * 1.03280 ax.add_patch(FancyBboxPatch((x0, y0), 0.9, 0.84, boxstyle="round,pad=0.015", fc="#f6f6f3", ec=ps.INK3, lw=0.6))281 ax.text(x0 + 0.06, y0 + 0.73, t, ha="left", va="center", fontsize=8, fontweight="bold")282 ax.text(x0 + 0.06, y0 + 0.33, sub, ha="left", va="center", fontsize=ps.MIN_FONT, color=ps.INK2, linespacing=1.3)283 arr = dict(arrowstyle="-|>,head_length=0.3,head_width=0.15", color=ps.INK, lw=0.8)284 for i in range(5):285 r, c = divmod(i, 3)286 if c < 2:287 ax.annotate("", xy=(c + 1.05, 1.5 - r * 1.03), xytext=(c + 0.95, 1.5 - r * 1.03), arrowprops=arr)288 else: # wrap from box 3 (top right) to box 4 (bottom left)289 ax.annotate("", xy=(0.5, 0.05 + 0.84 + 0.02), xytext=(2.5, 1.08 - 0.015), arrowprops=dict(**arr, connectionstyle="angle,angleA=-90,angleB=0,rad=0.05"))290 return _save(fig, path, ["results/manifests/ingest_manifest.json", "results/manifests/panels_manifest.json", "results/forecasts/main/_runs.jsonl"], n_jobs)291292293# ----------------------------------------------------------------------------- 5/6. cross-asset heatmaps (ratio to GARCH, DM glyphs)294def ratio_heatmap(per_symbol: dict, h: int, specs: list[str], path: Path, title: str = "", vmin=RATIO_RANGE[0], vmax=RATIO_RANGE[1]) -> Path:295 syms = _syms(per_symbol); specs = order_specs(specs)296 M = np.full((len(specs), len(syms)), np.nan); P = np.full_like(M, np.nan)297 for j, s in enumerate(syms):298 d = _hz(per_symbol[s], h)["specs"]299 for i, sp in enumerate(specs):300 if sp in d:301 M[i, j] = d[sp].get("ratio", np.nan); P[i, j] = d[sp].get("dm_p", np.nan)302 cmap = ps.div_cmap(vmin, vmax)303 fig, ax, im = _matrix(np.log(M), specs, syms, cmap, np.log(vmin), np.log(vmax), title)304 n_clip = 0305 for i in range(len(specs)):306 for j in range(len(syms)):307 v = M[i, j]308 if not np.isfinite(v):309 continue310 dark = ps.luminance(cmap((np.clip(np.log(v), np.log(vmin), np.log(vmax)) - np.log(vmin)) / (np.log(vmax) - np.log(vmin)))) < 0.45311 if v >= EXPLOSIVE or v <= 1 / EXPLOSIVE:312 _glyph(ax, j, i, "cross", dark); n_clip += 1313 elif np.isfinite(P[i, j]) and P[i, j] < 0.05:314 _glyph(ax, j, i, "filled", dark)315 elif np.isfinite(P[i, j]) and P[i, j] < 0.10:316 _glyph(ax, j, i, "open", dark)317 ps.ratio_colorbar(fig, im, ax, vmin, vmax, orientation="horizontal", cax=fig._wp46_bar)318 _glyph_legend(fig, ax, [("filled", "DM p < 0.05"), ("open", "DM p < 0.10"), ("cross", f"ratio ≥ {EXPLOSIVE:.0f} or ≤ {1 / EXPLOSIVE:.1f} (clipped)"), ("hatch", "not available")], fig._wp46_legend_y)319 note = f"cells clipped to [{vmin}, {vmax}]; {n_clip} degenerate cells marked x; DM = HAC Diebold–Mariano vs GARCH(1,1)-N"320 return _save(fig, path, ["results/metrics/analysis_*/per_symbol/*.json"], int(np.isfinite(M).sum()), note)321322323# ----------------------------------------------------------------------------- 9. MCS membership matrices324def mcs_matrix(per_symbol: dict, h: int, tier: str, path: Path, title: str = "", key: str = "mcs") -> Path:325 """key = 'mcs' (pre-registered, all specifications of the tier) or 'mcs_rep' (one specification per architecture, exploratory)."""326 syms = _syms(per_symbol)327 specs = order_specs({sp for s in syms for sp in (_hz(per_symbol[s], h).get(f"{key}_{tier}", {}) or {}) if sp != "error"})328 M = np.full((len(specs), len(syms)), np.nan)329 for j, s in enumerate(syms):330 d = _hz(per_symbol[s], h).get(f"{key}_{tier}", {}) or {}331 for i, sp in enumerate(specs):332 if sp in d and isinstance(d[sp], dict):333 M[i, j] = d[sp]["p"]334 fig, ax, im = _matrix(M, specs, syms, ps.SEQ, 0, 1, title,335 tag="exploratory: one specification per architecture (the pre-registered MCS covers all specifications)" if key == "mcs_rep" else "")336 n_in = 0337 for i in range(len(specs)):338 for j in range(len(syms)):339 if np.isfinite(M[i, j]) and M[i, j] >= 0.10:340 _glyph(ax, j, i, "filled", ps.luminance(ps.SEQ(M[i, j])) < 0.45); n_in += 1341 cb = fig.colorbar(im, cax=fig._wp46_bar, orientation="horizontal")342 cb.set_ticks([0, 0.1, 0.25, 0.5, 0.75, 1]); cb.set_ticklabels(["0", "0.10", "0.25", "0.50", "0.75", "1"]); cb.ax.axvline(0.10, color=ps.INK, lw=0.6)343 cb.ax.tick_params(labelsize=ps.MIN_FONT, length=2, width=0.5); cb.set_label("MCS p-value (line = 0.10 inclusion threshold)", fontsize=ps.MIN_FONT)344 cb.outline.set_edgecolor(ps.INK3); cb.outline.set_linewidth(0.5)345 _glyph_legend(fig, ax, [("filled", "in the 90 % model confidence set"), ("hatch", "not available")], fig._wp46_legend_y)346 note = ("pre-registered" if key == "mcs" else "exploratory") + f"; {n_in} memberships; rows = specifications, columns = instruments"347 return _save(fig, path, ["results/metrics/analysis_*/per_symbol/*.json"], int(np.isfinite(M).sum()), note)348349350# ----------------------------------------------------------------------------- 7. horizon profiles with bootstrap bands351def horizon_profile(pooled: pd.DataFrame, specs: list[str], path: Path, cls: str = "all") -> Path:352 """Two panels (Tier A daily-return models | Tier B realized-measure models), GARCH(1,1)-t reference in both; 90 % joint block-bootstrap bands."""353 P = pooled[(pooled.asset_class == cls) & (pooled.spec_id.isin(specs))]354 ref = [s for s in specs if s.startswith("garch11__t__")]355 A = [s for s in specs if fam(s) != "realized" and s not in ref]; B = [s for s in specs if fam(s) == "realized"]356 fig, axes = plt.subplots(1, 2, figsize=(ps.W2, 2.5), layout="constrained")357 n = 0358 for ax, group, ttl in zip(axes, (A, B), ("Tier A: daily-return information set", "Tier B: realized-measure information set")):359 ps.ref_band(ax, "y")360 ends = []361 for sp in ref + group:362 d = P[P.spec_id == sp].sort_values("h"); n += len(d)363 if not len(d):364 continue365 f = fam(sp); c = ps.INK3 if sp in ref else ps.FAMILY_COLOR[f]366 ax.fill_between(d.h, 1 + d.boot_ci_low, 1 + d.boot_ci_high, color=c, alpha=0.12, lw=0, zorder=1)367 ax.plot(d.h, d.mean_ratio, marker=ps.FAMILY_MARKER[f], color=c, ls=ps.FAMILY_LS[f] if sp not in ref else "-", lw=1.0, ms=3.6,368 mec=c, mfc=c if sp not in ref else "white", zorder=3, clip_on=False)369 ends.append((float(d.mean_ratio.iloc[-1]), fig_label(sp) + (" (reference)" if sp in ref else "")))370 G = P[P.spec_id.isin(ref + group)]371 lo = min(0.96, float((1 + G.boot_ci_low).min()) - 0.01); hi = max(1.04, float((1 + G.boot_ci_high).max()) + 0.01)372 ax.set_ylim(lo, min(hi, 1.3))373 ys = ps.spread_labels([e[0] for e in ends], 0.055 * (min(hi, 1.3) - lo), lo=lo, hi=min(hi, 1.3))374 for (y0, lab), y1 in zip(ends, ys):375 ax.annotate(lab, xy=(22, y0), xytext=(24.0, y1), textcoords="data", fontsize=ps.MIN_FONT, va="center", ha="left", color=ps.INK,376 arrowprops=dict(arrowstyle="-", color=ps.INK3, lw=0.4, shrinkA=0, shrinkB=1.5), annotation_clip=False)377 ax.set_xticks([1, 5, 22]); ax.set_xlim(0, 23); ax.set_xlabel("Forecast horizon h (trading days)"); ax.set_title(ttl, fontsize=8)378 ax.set_ylabel("Pooled QLIKE ratio to GARCH(1,1)-N"); ax.grid(axis="x", visible=False)379 fig.get_layout_engine().set(wspace=0.32)380 return _save(fig, path, ["results/tables/pooled_*.csv"], n, "bands = 90 % joint block-bootstrap CI of the pooled ratio; grey band = ±2 % materiality")381382383# ----------------------------------------------------------------------------- 8. asset-class x family small multiples (new)384def class_family_panels(pooled: pd.DataFrame, specs: list[str], h: int, path: Path, lo: float = 0.7, hi: float = 1.3) -> Path:385 """Rows = representative specifications (family blocks), one panel per asset class (+ all): diverging bars from 1.0 with 90 %386 bootstrap whiskers; values beyond [lo, hi] are clipped to the edge and marked with an arrow (full numbers in the tables)."""387 specs = order_specs(specs)388 classes = ["all"] + [c for c in CLASS_ORDER if c in set(pooled.asset_class)]389 nr = len(specs); rp = 0.135390 fig, axes = plt.subplots(1, len(classes), figsize=(ps.W2, nr * rp + 1.05), sharey=True, layout="constrained")391 n = 0; n_clip = 0392 for ax, c in zip(axes, classes):393 d = pooled[(pooled.asset_class == c) & (pooled.h == h)].set_index("spec_id")394 ps.ref_band(ax, "x")395 for i, sp in enumerate(specs):396 if sp not in d.index:397 continue398 r = d.loc[sp]; n += 1; f = fam(sp); col = ps.FAMILY_COLOR[f]399 v = float(r.mean_ratio); vlo, vhi = 1 + float(r.boot_ci_low), 1 + float(r.boot_ci_high)400 vc = float(np.clip(v, lo, hi))401 ax.barh(i, vc - 1, left=1, height=0.62, color=col, lw=0, zorder=2)402 ax.plot([max(vlo, lo), min(vhi, hi)], [i, i], color=ps.INK, lw=0.6, zorder=3, solid_capstyle="butt")403 if v > hi or v < lo:404 n_clip += 1405 ax.scatter(hi - 0.012 if v > hi else lo + 0.012, i, marker=">" if v > hi else "<", s=11, color=ps.INK, zorder=4, linewidths=0)406 ax.set_xlim(lo, hi); ax.set_xticks([0.8, 1.0, 1.2]); ax.set_xticklabels(["0.8", "1.0", "1.2"], fontsize=ps.MIN_FONT)407 n_inst = int(d.n_instruments.iloc[0]) if len(d) else 0408 ax.set_title(f"{'All' if c == 'all' else CLASS_LABEL[c]} (n = {n_inst})", fontsize=8, loc="center")409 ax.grid(axis="y", visible=False); ax.grid(axis="x", visible=True)410 for s in ("left", "top", "right"):411 ax.spines[s].set_visible(False)412 ax.tick_params(axis="y", length=0)413 axes[0].set_yticks(range(nr)); axes[0].set_yticklabels([fig_label(s) for s in specs], fontsize=ps.MIN_FONT); axes[0].set_ylim(nr - 0.5, -0.5)414 fams = [fam(s) for s in specs]415 for ax in axes:416 for k in range(1, nr):417 if fams[k] != fams[k - 1]:418 ax.axhline(k - 0.5, color=ps.INK3, lw=0.5, zorder=1)419 k0 = 0420 for k in range(1, nr + 1):421 if k == nr or fams[k] != fams[k0]:422 axes[-1].text(1.02, (k0 + k - 1) / 2, ps.FAMILY_SHORT.get(fams[k0], fams[k0]), transform=mtrans.blended_transform_factory(axes[-1].transAxes, axes[-1].transData),423 fontsize=ps.MIN_FONT, color=ps.INK2, va="center", ha="left")424 k0 = k425 fig.supxlabel(f"Pooled QLIKE ratio to GARCH(1,1)-N, h = {h}: bar from 1.0, whisker = 90 % joint block-bootstrap CI, arrowhead = beyond the axis (full values in the tables)",426 fontsize=ps.MIN_FONT)427 fig.get_layout_engine().set(wspace=0.04)428 return _save(fig, path, ["results/tables/pooled_*.csv"], n, f"{n_clip} values clipped to [{lo}, {hi}]; family identity by row block and colour")429430431# ----------------------------------------------------------------------------- 10. regime profile (small multiples)432REGIME_GROUPS = [("Trailing-volatility tercile", [("vol_tercile=low", "low"), ("vol_tercile=mid", "mid"), ("vol_tercile=high", "high")]),433 ("Previous-day return", [("prev_sign=neg", "neg."), ("prev_sign=pos", "pos.")]),434 ("Calendar episode", [("episode=none", "none"), ("episode=covid", "COVID"), ("episode=rate_shock_2022", "2022 rates"), ("episode=tariff_shock_2025", "2025 tariffs")])]435436437def regime_profile(per_symbol: dict, specs: list[str], path: Path, h: int = 1) -> Path:438 """One panel per specification: cross-instrument MEDIAN ratio by regime (dot) with the interquartile range across instruments (band),439 display only (the tables report the equal-weight means, which explosive instruments dominate for EGARCH / HAR)."""440 avail = {k for r in per_symbol.values() for k in r["regimes"].get(str(h), {})}441 groups = [(g, [(k, l) for k, l in ks if k in avail]) for g, ks in REGIME_GROUPS]442 groups = [(g, ks) for g, ks in groups if ks]443 xs, labs, xg = [], [], []444 x = 0445 for g, ks in groups:446 x0 = x447 for k, l in ks:448 xs.append((k, x)); labs.append(l); x += 1449 xg.append((g, x0, x - 1)); x += 0.8450 specs = order_specs(specs); nsp = len(specs); ncol = 4; nrow = int(np.ceil(nsp / ncol))451 fig, axes = plt.subplots(nrow, ncol, figsize=(ps.W2, 1.6 * nrow + 0.75), sharex=True, sharey=True, layout="constrained")452 axes = np.atleast_2d(axes); n = 0; nsym_min = 10 ** 9453 for ax, sp in zip(axes.flat, specs):454 f = fam(sp); c = ps.FAMILY_COLOR[f]455 ps.ref_band(ax, "y")456 med, q1, q3, px = [], [], [], []457 for k, xx in xs:458 v = np.array([r["regimes"][str(h)][k][sp]["ratio"] for r in per_symbol.values() if k in r["regimes"].get(str(h), {}) and sp in r["regimes"][str(h)][k]], float)459 v = v[np.isfinite(v)]460 if len(v) == 0:461 continue462 n += len(v); nsym_min = min(nsym_min, len(v))463 med.append(np.median(v)); q1.append(np.percentile(v, 25)); q3.append(np.percentile(v, 75)); px.append(xx)464 for g, a, b in xg: # one band per regime group465 idx = [t for t, xx in enumerate(px) if a <= xx <= b]466 if idx:467 ax.fill_between([px[t] for t in idx], [q1[t] for t in idx], [q3[t] for t in idx], color=c, alpha=0.15, lw=0, zorder=1)468 ax.plot([px[t] for t in idx], [med[t] for t in idx], color=c, lw=1.0, ls=ps.FAMILY_LS[f], marker=ps.FAMILY_MARKER[f], ms=3.6, mec=c, zorder=3)469 ax.set_title(fig_label(sp), fontsize=8, loc="left"); ax.grid(axis="x", visible=False)470 for ax in axes.flat[nsp:]:471 ax.axis("off")472 glab = {"Trailing-volatility tercile": "vol.", "Previous-day return": "prev. return", "Calendar episode": "episode"}473 full = {"low": "low vol.", "mid": "mid vol.", "high": "high vol.", "neg.": "prev. ret. < 0", "pos.": "prev. ret. > 0", "none": "no episode", "COVID": "COVID-19",474 "2022 rates": "2022 rate shock", "2025 tariffs": "2025 tariffs"}475 for ax in axes[-1]:476 ax.set_xticks([xx for _, xx in xs]); ax.set_xticklabels([full.get(l, l) for l in labs], rotation=90, fontsize=ps.MIN_FONT)477 for ax in axes[:, 0]:478 ax.set_ylabel("QLIKE ratio", fontsize=ps.MIN_FONT)479 axes[0, 0].set_ylim(0.78, 1.25); axes[0, 0].set_yticks([0.8, 0.9, 1.0, 1.1, 1.2])480 fig.get_layout_engine().set(hspace=0.08, wspace=0.04)481 fig.supxlabel(f"Median across instruments (n ≥ {nsym_min}) of the per-instrument QLIKE ratio to GARCH(1,1)-N, h = {h}, by pre-registered regime;\n"482 "band = interquartile range across instruments; grey band = ±2 % materiality zone; y-axis trimmed to [0.78, 1.25]", fontsize=ps.MIN_FONT)483 return _save(fig, path, ["results/metrics/analysis_*/per_symbol/*.json"], n, "display statistic = cross-instrument median + IQR (tables: equal-weight means); 2025 tariff episode absent from the regime outputs" if "episode=tariff_shock_2025" not in avail else "display statistic = cross-instrument median + IQR")484485486# ----------------------------------------------------------------------------- 11. VaR calibration (1 % and 5 %, two panels)487def var_calibration(tail: pd.DataFrame, path: Path, specs: list[str] | None = None) -> Path:488 specs = order_specs(specs if specs is not None else tail.spec_id.unique())489 nr = len(specs); rp = 0.135490 fig, axes = plt.subplots(1, 2, figsize=(ps.W2, nr * rp + 1.0), sharey=True, layout="constrained")491 n = 0492 for ax, lvl in zip(axes, (0.01, 0.05)):493 d = tail[tail.level == lvl]494 ax.axvline(100 * lvl, color=ps.INK, lw=0.7, zorder=1)495 ax.axvspan(100 * lvl * 0.8, 100 * lvl * 1.2, color=ps.BAND, zorder=0, lw=0)496 for i, sp in enumerate(specs):497 s = d[d.spec_id == sp]498 if not len(s):499 continue500 n += len(s); f = fam(sp); c = ps.FAMILY_COLOR[f]501 q1, q2, q3 = (100 * s.hit_rate.quantile(q) for q in (0.25, 0.5, 0.75))502 ax.plot([q1, q3], [i, i], color=c, lw=1.6, solid_capstyle="butt", zorder=2)503 ax.scatter(q2, i, color=c, s=16, marker=ps.FAMILY_MARKER[f], zorder=4, edgecolors="white", linewidths=0.5)504 ax.text(1.0, i, f"{100 * (s.kupiec_p < 0.05).mean():.0f}", transform=mtrans.blended_transform_factory(ax.transAxes, ax.transData), fontsize=ps.MIN_FONT,505 color=ps.INK2, va="center", ha="right")506 ax.text(1.0, -1.0, "UC rej. %", transform=mtrans.blended_transform_factory(ax.transAxes, ax.transData), fontsize=ps.MIN_FONT, color=ps.INK2, va="center", ha="right")507 ax.set_title(f"{100 * lvl:.0f} % VaR (nominal exceedance rate {100 * lvl:.0f} %)", fontsize=8, loc="left")508 ax.set_xlabel("Realized exceedance rate, % of test days", fontsize=ps.MIN_FONT)509 ax.grid(axis="y", visible=False)510 for sname in ("left", "top", "right"):511 ax.spines[sname].set_visible(False)512 ax.tick_params(axis="y", length=0)513 xmax = float(np.nanpercentile(100 * d.hit_rate, 97))514 ax.set_xlim(0, max(xmax, 100 * lvl * 2.3) * 1.18)515 axes[0].set_yticks(range(nr)); axes[0].set_yticklabels([fig_label(s) for s in specs], fontsize=ps.MIN_FONT); axes[0].set_ylim(nr - 0.5, -1.6)516 fams = [fam(s) for s in specs]517 for ax in axes:518 for k in range(1, nr):519 if fams[k] != fams[k - 1]:520 ax.axhline(k - 0.5, color=ps.INK3, lw=0.5, zorder=1)521 fig.get_layout_engine().set(wspace=0.06)522 fig.supxlabel("Marker = median across instruments, bar = interquartile range, line = nominal rate, grey band = ±20 % of nominal;\n"523 "right column (UC rej. %) = share of instruments where the unconditional-coverage (Kupiec) test rejects at the 5 % level", fontsize=ps.MIN_FONT)524 return _save(fig, path, ["results/tables/tailrisk_*.csv"], n, "median + IQR of per-instrument hit rates; nominal line; UC rejection share")525526527# ----------------------------------------------------------------------------- 12. Pareto frontiers528def pareto(pooled_h1: pd.DataFrame, accounting: pd.DataFrame, path: Path, xcol: str = "fit_time_median", xlabel: str = "Median refit time per window (s, log scale)") -> Path:529 d = pooled_h1.merge(accounting.drop(columns=[c for c in ("family",) if c in accounting]), on="spec_id", how="inner")530 d = d[np.isfinite(d.mean_ratio) & np.isfinite(d[xcol])].copy()531 ymax = min(float(np.nanpercentile(d.mean_ratio, 90)) * 1.03, 1.6) # degenerate outliers never dictate the axis (mandate §6)532 ymin = min(0.82, float(d.mean_ratio.min()) - 0.04)533 clipped = d[d.mean_ratio > ymax]; shown = d[d.mean_ratio <= ymax]534 fig, ax = plt.subplots(figsize=(ps.W2, 3.4), layout="constrained")535 ps.ref_band(ax, "y")536 if "time" in xcol:537 ax.set_xscale("log")538 else:539 d[xcol] = d[xcol].clip(lower=0)540 fams = [f for f in ps.FAMILY_ORDER if f in set(d.family)]541 for f in fams:542 s = shown[shown.family == f]543 ax.scatter(s[xcol], s.mean_ratio, marker=ps.FAMILY_MARKER.get(f, "o"), color=ps.FAMILY_COLOR.get(f, ps.INK3), s=22, zorder=3, edgecolor="white", linewidth=0.5)544 s = clipped[clipped.family == f]545 ax.scatter(s[xcol], [ymax] * len(s), marker="^", color=ps.FAMILY_COLOR.get(f, ps.INK3), s=18, zorder=3, edgecolor=ps.INK, linewidth=0.4, clip_on=False)546 # Pareto frontier: smallest pooled ratio among specifications with cost <= x547 dd = d.sort_values([xcol, "mean_ratio"]); best = np.minimum.accumulate(dd.mean_ratio.values)548 front = dd[dd.mean_ratio.values <= best + 1e-12].drop_duplicates("mean_ratio")549 front = front[front.mean_ratio <= ymax]550 fx, fy = list(front[xcol]), list(np.minimum(front.mean_ratio, ymax))551 ax.step(fx + [fx[-1] * (1.6 if "time" in xcol else 1) + (0 if "time" in xcol else 0.01)], fy + [fy[-1]], where="post", color=ps.INK, lw=0.7, zorder=2)552 # direct labels for the frontier points: to the right of the point, just above its horizontal step (that strip is free by553 # construction); a label that would overlap an earlier one is pushed up one line; leader line from the marker554 ax.set_xlabel(xlabel); ax.set_ylabel("Pooled QLIKE ratio to GARCH(1,1)-N, h = 1"); ax.set_ylim(ymin, ymax * 1.005)555 fig.canvas.draw()556 bb = ax.get_window_extent(); axw_pt, axh_pt = bb.width / fig.dpi * 72, bb.height / fig.dpi * 72557 to_ax = lambda x, y: ax.transAxes.inverted().transform(ax.transData.transform((x, y))) # noqa: E731558 placed = []559 for x0, y0, lab in sorted(((float(r[xcol]), float(r.mean_ratio), fig_label(r.spec_id)) for _, r in front.iterrows()), key=lambda p: p[0]):560 xa, ya = to_ax(x0, y0)561 w = len(lab) * ps.MIN_FONT * 0.46 / axw_pt; xs_ = xa + 7 / axw_pt; ha, va = "left", "bottom"; ys_ = ya + 4 / axh_pt562 if xs_ + w > 1.0: # no room at the right: below-left of a frontier point is empty by construction (cheaper AND better)563 xs_, ha, va, ys_ = xa - 5 / axw_pt, "right", "top", ya - 4 / axh_pt564 a0, a1 = (xs_, xs_ + w) if ha == "left" else (xs_ - w, xs_)565 for _ in range(6):566 for (b0, b1, yy) in placed:567 if a0 < b1 and a1 > b0 and abs(ys_ - yy) < 9.5 / axh_pt:568 ys_ = yy + (9.5 if va == "bottom" else -9.5) / axh_pt569 placed.append((a0, a1, ys_))570 ax.annotate(lab, xy=(x0, y0), xytext=(xs_, ys_), textcoords="axes fraction", fontsize=ps.MIN_FONT, va=va, ha=ha, color=ps.INK,571 arrowprops=dict(arrowstyle="-", color=ps.INK3, lw=0.4, shrinkA=0, shrinkB=2.5), zorder=5)572 hs = ps.family_handles(fams) + [Line2D([], [], color=ps.INK, lw=0.7, label="Pareto frontier (best ratio at cost ≤ x)")]573 if len(clipped):574 hs.append(Line2D([], [], marker="^", color=ps.INK3, mec=ps.INK, mew=0.4, ls="none", ms=4,575 label=f"{len(clipped)} specifications above {ymax:.2f} (up to {clipped.mean_ratio.max():.2g}), drawn at the top edge"))576 ax.legend(handles=hs, ncol=3, fontsize=ps.MIN_FONT, loc="lower left", bbox_to_anchor=(0.0, 1.01), handletextpad=0.4, columnspacing=1.0, labelspacing=0.3)577 return _save(fig, path, ["results/tables/pooled_*.csv", "results/tables/accounting_*.csv"], len(d), f"y trimmed at {ymax:.2f}; {len(clipped)} clipped (full values in tab_pooled)")578579580# ----------------------------------------------------------------------------- 13. failure diagnostics581def failure_diagnostics(accounting: pd.DataFrame, pooled_h1: pd.DataFrame, specs: list[str], path: Path) -> Path:582 """Left: refits failed vs inadmissible (stacked, % of refits). Right: explosive forecasts (% of forecast origins, pooled h = 1).583 Rows = representative specifications with at least one non-zero share, sorted by the total."""584 A = accounting.set_index("spec_id"); E = pooled_h1.set_index("spec_id")585 rows = []586 for sp in specs:587 if sp not in A.index:588 continue589 fa, ina = float(A.loc[sp, "failed"] or 0), float(A.loc[sp, "inadmissible"] or 0)590 ex = float(E.loc[sp, "explosive_share"]) if sp in E.index and np.isfinite(E.loc[sp, "explosive_share"]) else 0.0591 if fa + ina + ex > 0:592 rows.append((sp, fa, ina, ex))593 rows.sort(key=lambda r: -(r[1] + r[2] + r[3]))594 nr = len(rows); rp = 0.135595 fig, axes = plt.subplots(1, 2, figsize=(ps.W2, nr * rp + 0.95), sharey=True, gridspec_kw={"width_ratios": [1.5, 1]}, layout="constrained")596 y = np.arange(nr)597 fa = 100 * np.array([r[1] for r in rows]); ina = 100 * np.array([r[2] for r in rows]); ex = 100 * np.array([r[3] for r in rows])598 axes[0].barh(y, fa, color=ps.SLOTS[0], height=0.66, lw=0, label="refit failed (no admissible optimum)")599 axes[0].barh(y, ina, left=fa, color=ps.SLOTS[1], height=0.66, lw=0, hatch="////", edgecolor="white", label="refit inadmissible (constraints violated, replaced)")600 axes[1].barh(y, ex, color=ps.SLOTS[2], height=0.66, lw=0, hatch="....", edgecolor="white", label="explosive forecasts")601 for ax, vals in zip(axes, (fa + ina, ex)):602 for i, v in enumerate(vals):603 if v > 0:604 ax.text(v + 0.012 * max(vals.max() * 1.22, 1), i, f"{v:.1f}" if v >= 0.1 else f"{v:.2f}", va="center", ha="left", fontsize=ps.MIN_FONT, color=ps.INK2)605 ax.set_xlim(0, max(vals.max() * 1.22, 1)); ax.grid(axis="y", visible=False); ax.tick_params(axis="y", length=0)606 for sname in ("left", "top", "right"):607 ax.spines[sname].set_visible(False)608 axes[0].set_yticks(y); axes[0].set_yticklabels([fig_label(r[0]) for r in rows], fontsize=ps.MIN_FONT); axes[0].set_ylim(nr - 0.4, -0.6)609 axes[0].set_xlabel("Share of rolling refits, % (all instruments, test period)", fontsize=ps.MIN_FONT)610 axes[1].set_xlabel("Explosive forecasts, % of origins (h = 1)", fontsize=ps.MIN_FONT)611 axes[0].set_title("Estimation: failed and inadmissible refits", fontsize=8); axes[1].set_title("Forecasting: explosive variance paths", fontsize=8)612 axes[0].legend(loc="lower right", fontsize=ps.MIN_FONT, handlelength=1.4)613 fig.get_layout_engine().set(wspace=0.04)614 n_zero = len(specs) - nr615 return _save(fig, path, ["results/tables/accounting_*.csv", "results/tables/pooled_*.csv"], nr, f"{n_zero} representative specifications with zero failures/inadmissible/explosive shares omitted")616617618# ----------------------------------------------------------------------------- 14. extensions vs parents (exploratory)619EXT_PARENT = {"stgarch11": "gjr11", "betategarch11": "egarch11", "garch_midas": "garch11", "hygarch11": "figarch11",620 "fiaparch11": "aparch11", "fiegarch11": "egarch11", "realegarch11": "realgarch11"}621622623def extension_heatmap(per_symbol: dict, h: int, path: Path, dist_pref=("t", "normal")) -> Path:624 """(extension, parent) pairs x instruments actually scored: QLIKE ratio of the extension to its parent (same innovation law)."""625 syms = _syms(per_symbol)626 pairs = []627 for ext, par in EXT_PARENT.items():628 for d in dist_pref:629 e_id, p_id = f"{ext}__{d}__w1000_r5", f"{par}__{d}__w1000_r5"630 if any(e_id in _hz(per_symbol[s], h)["specs"] and p_id in _hz(per_symbol[s], h)["specs"] for s in syms):631 pairs.append((e_id, p_id)); break632 syms = [s for s in syms if any(e in _hz(per_symbol[s], h)["specs"] for e, _ in pairs)]633 M = np.full((len(pairs), len(syms)), np.nan)634 for j, s in enumerate(syms):635 d = _hz(per_symbol[s], h)["specs"]636 for i, (e_id, p_id) in enumerate(pairs):637 re_, rp_ = d.get(e_id, {}).get("ratio", np.nan), d.get(p_id, {}).get("ratio", np.nan)638 if np.isfinite(re_) and np.isfinite(rp_) and rp_ > 0:639 M[i, j] = re_ / rp_640 vmin, vmax = 0.8, 1.25641 cmap = ps.div_cmap(vmin, vmax)642 nr, nc = M.shape643 wide = nc > 8644 fig, ax = plt.subplots(figsize=(ps.W2 if wide else ps.W1 * 1.4, 0.3 * nr + (1.9 if wide else 1.5)), layout="constrained")645 im = ax.imshow(np.ma.masked_invalid(np.log(M)), cmap=cmap, vmin=np.log(vmin), vmax=np.log(vmax), aspect="auto"); ax.grid(False)646 for i in range(nr):647 for j in range(nc):648 if np.isfinite(M[i, j]):649 if not wide:650 dark = ps.luminance(cmap((np.clip(np.log(M[i, j]), np.log(vmin), np.log(vmax)) - np.log(vmin)) / (np.log(vmax) - np.log(vmin)))) < 0.45651 ax.text(j, i, f"{M[i, j]:.2f}", ha="center", va="center", fontsize=ps.MIN_FONT, color="white" if dark else ps.INK)652 else:653 ax.add_patch(plt.Rectangle((j - 0.5, i - 0.5), 1, 1, fc="#ffffff", ec="none", hatch="////"))654 if wide:655 ax.set_xticks(range(nc)); ax.set_xticklabels(syms, fontsize=ps.MIN_FONT - 0.5, rotation=90)656 for k in range(1, nc):657 if CLASS_OF[syms[k]] != CLASS_OF[syms[k - 1]]:658 ax.axvline(k - 0.5, color=ps.INK, lw=0.6)659 ax.xaxis.tick_bottom()660 else:661 ax.set_xticks(range(nc)); ax.set_xticklabels([f"{s}\n({CLASS_LABEL[CLASS_OF[s]].rstrip('s') if CLASS_OF[s] != 'fx' else 'FX'})" for s in syms], fontsize=ps.MIN_FONT)662 ax.xaxis.tick_top()663 ax.tick_params(length=0)664 ax.set_yticks(range(nr)); ax.set_yticklabels([f"{fig_label(e)}\nvs {fig_label(p)}" for e, p in pairs], fontsize=ps.MIN_FONT)665 for s in ax.spines.values():666 s.set_visible(True); s.set_edgecolor(ps.INK3); s.set_linewidth(0.5)667 for k in range(1, nr):668 ax.axhline(k - 0.5, color="white", lw=1.2)669 ps.ratio_colorbar(fig, im, ax, vmin, vmax, label="QLIKE ratio, extension / parent (log scale)", orientation="horizontal", fraction=0.05, pad=0.04, aspect=35, shrink=0.9)670 ax.set_title(f"exploratory: post-registration extensions, h = {h}, {nc} instruments scored", fontsize=ps.MIN_FONT, color=ps.INK2, fontweight="normal", loc="left", pad=(6 if wide else 28),671 bbox=dict(boxstyle="round,pad=0.25", fc=ps.BAND, ec="none"))672 return _save(fig, path, ["results/metrics/analysis_*/per_symbol/*.json"], int(np.isfinite(M).sum()),673 f"exploratory; only the {nc} instruments with extension runs are shown; hatched = pair not scored")674