SPB Git

Repository Public Research preview

figures.py 45.6 KB 674 lines python

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
Files main
Menu

Theme