跳至正文
返回文库全部文档

用预测模型和结构模型评估股票与期权信号

代码 《交易机器学习》

总结

本笔记本比较某横截面 S&P 500 研究中的预测性、结构性和因果模型证据。研究将股票特征与期权衍生指标结合,包括隐含波动率、偏斜和期限结构,并评估每周远期收益排序,同时考察其他标签期限、潜在因子结果、因果估计、十分位表现和信号相关性。核心问题是期权特征能否直接或通过潜在因子提供有用信息,以及哪些发现值得进一步进行策略模拟。

报告的证据较为谨慎:主要每周目标上的监督学习结果无法确定哪一类模型明显胜出,而其他期限上则出现了一些结构性证据。笔记本强调置信区间、针对特定标签的证据,以及预测排序与因果估计之间的区别。结论受限于仅有两个扩展窗口验证折,因此宽广的横截面无法证明结果在时间上的稳定性。资产池使用当前成分,而非历史成分;宏观数据、期权数据和交易成本也进一步限制结论。分析并未选出交易策略;信号仍需在计入换手率和成本后进行回测。

核心观点

  • 在同一数据集内比较模型证据,同时区分预测性、结构性和因果性问题。
  • 即使每周直接预测能力较弱,期权衍生特征仍可能通过潜在因子提取发挥作用。
  • 应解读置信区间和针对特定标签的结果,而不要把点估计的小幅差异视为排名依据。
  • 无论横截面范围多广,两个时间序列折都只能提供有限的时间稳健性证据。
  • 预测信息并不证明交易能够盈利;策略选择需要在计入成本和换手率后进行模拟。

标签

全文
# 13_model_analysis.py


```py
# ---
# jupyter:
#   jupytext:
#     cell_metadata_filter: tags,-all
#     text_representation:
#       extension: .py
#       format_name: percent
#       format_version: '1.3'
#       jupytext_version: 1.19.3
#   kernelspec:
#     display_name: Python 3 (ipykernel)
#     language: python
#     name: python3
# ---

# %% [markdown]
# # Model Analysis: S&P 500 Equity + Option Analytics
#
# This notebook evaluates models trained on the S&P 500 equity+option
# case study across predictive (Ch11-13), structural (Ch14), and causal
# (Ch15) approaches. The goal is to identify which signals merit simulation,
# subject to the uncertainty and two-fold limitations reported below.
#
# This case study starts from the book's largest configured equity roster
# (633 current S&P 500 constituents at daily frequency) and is the only one
# that combines traditional
# equity features with option-derived features - implied volatility surfaces,
# put-call ratios, IV skew, term structure, and the implied-realized
# volatility spread. The central question is not just "can we predict?"
# but **"do option-derived features add predictive power, and if so, through
# what mechanism - direct prediction or latent factor extraction?"**
#
# The S&P 500 is the most analyzed equity universe on the planet. Direct
# supervised prediction of weekly forward returns ($fwd\_ret\_5d$) proves
# difficult. The corrected registry instead points to target-specific
# structural evidence: PCA clears zero at the 10-day and risk-adjusted
# horizons, while no family clears zero on the primary weekly target.
#
# With only 2 expanding-window folds, stability analysis is inherently
# limited. All fold-level conclusions carry a strong caveat: two
# observations do not establish robustness. The statistical power comes
# instead from the broad cross-section, which supplies hundreds of names per
# bucket but cannot establish stability across time.
#
# **Population scope**: The source universe is a current-constituent roster,
# not point-in-time S&P 500 membership. Historical performance describes this
# retrospective roster and does not generalize to the index-membership process
# or a prospective S&P 500 population.
#
# **Learning Objectives**:
# - Apply a structured model evaluation workflow to a real dataset
# - Compare predictive, structural, and causal model evidence
# - Assess whether option-derived features add value through factor extraction
# - Use decile analysis to detect ranking ability even when supervised IC is near zero
# - Make explicit, evidence-based decisions about which models to backtest
#
# **Prerequisites**: Model training notebooks Ch11-15 must have run for this
# case study. Linear and GBM results come from the registry; TabM, DL,
# latent factor, and causal DML results come from the training pipeline.
#
# **Book Reference**: This notebook bridges Part III (Models, Ch11-15) and
# Part IV (Strategy Implementation, Ch16-20). The chapter insights notebooks
# in Ch11-15 compare each model family *across* case studies; here we compare
# all families *within* a single dataset - with particular focus on the
# option feature question and the structural vs predictive distinction
# that make this case study unique.

# %%
"""Compare model families for the S&P 500 equity and option case study."""

import sqlite3

import matplotlib.pyplot as plt
import numpy as np
import polars as pl
import torch  # cudart preload - required before ml4t.diagnostic imports # noqa: F401
import yaml

from case_studies.research import CausalResult, Study, open_study
from case_studies.utils.latent_factors import load_fold_extras
from case_studies.utils.model_analysis import (
    best_model_per_family_fast,
    fold_performance_matrix,
    load_all_metrics,
    load_fold_metrics_from_registry,
    load_gbm_feature_importance,
    load_predictions,
    prediction_bucket_monotonicity,
    prediction_correlation_matrix,
    regime_conditional_ic,
)
from case_studies.utils.model_viz import (
    plot_bucket_monotonicity,
    plot_correlation_matrix,
    plot_cv_timeline,
    plot_feature_importance_heatmap,
    plot_fold_boxplot,
    plot_fold_heatmap,
    plot_label_horizon_forest,
    plot_learning_curves,
    plot_regime_bars,
)
from case_studies.utils.notebook_contracts import (
    declared_population_members,
    degenerate_prediction_hashes,
    incompletely_registered_predictions,
)
from case_studies.utils.notebook_render import conformal_coverage_diagnostic
from utils.paths import get_case_study_dir
from utils.style import COLORS, FIGSIZE, show_with_alt

# %% tags=["parameters"]
CASE_STUDY = "sp500_equity_option_analytics"
EXECUTION_TIER = "canonical"
WORKSPACE: str = ""
PRIMARY_LABEL = "fwd_ret_5d"
DATE_COL = "timestamp"
ENTITY_COL = "symbol"
N_BUCKETS = 10
TOP_N_FEATURES = 15
REGIME_WINDOW = 63
# The populations 06 through 11e publish, keyed by the name each notebook builds from. Named
# rather than hashed: a name resolves to the generation in force, so a refit that supersedes its
# predecessor is picked up here without an edit, while every superseded snapshot stays readable
# by hash. Five metric families are spread across nine of them - `deep_learning` over two
# sequence notebooks, `latent_factors` over the five `11*` models - so the family each belongs
# to is carried alongside, for the counts below.
POPULATION_FAMILY = {
    "linear": "linear",
    "gbm": "gbm",
    "tabular_dl": "tabular_dl",
    "sequence": "deep_learning",
    "patchtst": "deep_learning",
    "pca": "latent_factors",
    "ipca": "latent_factors",
    "cae": "latent_factors",
    "sdf": "latent_factors",
    "sae": "latent_factors",
}
POPULATIONS = {model: f"{CASE_STUDY}-{model}-validation-v1" for model in POPULATION_FAMILY}

# %% [markdown]
# This notebook reads; it registers nothing, and that is what decides how it opens the registry.
# Every route through `open_study` ends in `Study.activate()`, which rewrites `ML4T_OUTPUT_DIR`
# for the rest of the process and clears the caches keyed on it, so every later
# `get_case_study_dir` answers for a different directory than the one resolved before it. On the
# canonical tier with no workspace that route is `Study.regenerate`, which refuses outright
# unless `features`, `labels` and `run_log` are symlinks - true in a maintainer worktree, false
# in every clean clone and every CI run. So canonical opens `Study.at`: the read-only form, one
# root, no activation.
#
# A run given a workspace is the case where that rewrite is the point. It reads and reports on
# the rows registered in that workspace and nowhere else, so the analysis has to follow
# `ML4T_OUTPUT_DIR` there rather than answer from the released directory. `activate()` links
# `config`, `labels` and `features` into that directory, and `CASE_DIR` below is whichever root
# the tier and the workspace resolved.
#
# `WORKSPACE` is read at both tiers. Read on the preview branch only, it left a canonical run
# that passed one reporting on the published registry while its caller believed it was reading
# the workspace it asked for - the read-side half of #1100. A preview still requires a
# workspace, because a preview has nowhere else of its own.
#
# What a preview cannot have is a published population: `_refuse_preview_activation` stops a
# reduced run from creating one, by design. The resolution below already distinguishes that from
# a broken lineage and falls through to comparing every registered prediction set, so the preview
# needs no branch of its own here - it takes the same path as a fixture or a clean clone.

# %%
if EXECUTION_TIER == "preview" and not WORKSPACE:
    raise ValueError("preview execution requires WORKSPACE")
if WORKSPACE or EXECUTION_TIER == "preview":
    study = open_study(
        CASE_STUDY,
        execution_tier=EXECUTION_TIER,
        workspace=WORKSPACE or None,
        entry_point="13_model_analysis",
    )
    CASE_DIR = get_case_study_dir(CASE_STUDY)
else:
    CASE_DIR = get_case_study_dir(CASE_STUDY)
    study = Study.at(CASE_DIR, case_study=CASE_STUDY, entry_point="13_model_analysis")

with open(CASE_DIR / "config" / "setup.yaml") as f:
    setup = yaml.safe_load(f)

n_splits = setup["evaluation"]["n_splits"]
train_size = setup["evaluation"]["train_size"]
val_size = setup["evaluation"]["val_size"]
holdout_start = setup["evaluation"].get("holdout_start")
n_assets = setup["universe"]["n_assets"]
cost_range = setup["costs"]["per_leg_cost_bps_range"]  # [3, 10]

print(f"Case Study: {CASE_STUDY}")
print(f"  Universe: {n_assets} S&P 500 stocks (with listed options)")
print(f"  Label: {PRIMARY_LABEL} (weekly forward return)")
print(f"  CV: {n_splits} expanding-window folds, train={train_size}, val={val_size}")
print(f"  Holdout: {holdout_start} onwards")
print(f"  Trading costs: {cost_range[0]}–{cost_range[1]} bps per leg")

# %% [markdown]
# ## 1. What Is the Prediction Problem?
#
# **Primary target tuple**: `fwd_ret_5d` | regression | IC | weekly rebalancing
#
# We predict the 5-trading-day forward return for eligible S&P 500
# constituents, ranking them cross-sectionally each week to identify
# stocks with the highest expected short-term returns. The strategy
# buys the top-ranked stocks and rebalances weekly.
#
# The 48-feature set combines three broad categories:
#
# 1. **Equity features**: momentum at multiple horizons (5d to 252d),
#    risk-adjusted momentum, realized volatility (20d, 63d), Garman-Klass
#    vol, vol-of-vol, and cross-sectional ranks.
# 2. **Option-derived features**: 30-day ATM implied volatility,
#    7-day and 90-day ATM IV, 25-delta put and call IV, risk-reversal
#    skew, IV term structure slope and convexity, IV momentum (5d, 21d),
#    IV z-scores, and the implied-realized volatility spread.
# 3. **Model-based volatility features**: GARCH conditional volatility,
#    volatility surprise, and the GARCH-based IV-RV spread.
#
# The configured roster contains 633 current S&P 500 stocks with listed options.
# After feature and label availability, the current validation leaders cover
# 543 to 550 distinct stocks. This remains the largest equity cross-section in
# the book, but many observations per bucket do not replace time-series evidence.
# Trading costs are 3-10 bps per leg,
# reflecting the high liquidity of S&P 500 large caps.
#
# The evaluation uses 2 expanding-window folds with 2-year training
# and 1-year validation, with a holdout period from 2021 onwards. The
# limited fold count is a significant constraint: all fold-level
# conclusions carry a caveat about small-sample stability.

# %% [markdown]
# **A population is immutable and the registry keeps every generation, so a candidate set built
# straight from it counts retired members beside current ones.** Refitting a configuration under a
# corrected estimator publishes a new snapshot that supersedes the old one; both stay readable, and
# nothing in the registry read path filters on that - `case_studies/utils/registry/queries.py`
# contains no occurrence of `supersed`. Without the filter both generations of a refitted
# configuration enter the ranking as separate candidates, with near-identical scores, and the
# published leaders are then fewer distinct strategies than they appear to be.
#
# The filter is what the nine names in `POPULATIONS` resolve to. `OfficialPopulation.one` returns
# the one generation in a name's chain that nothing supersedes, and refuses rather than guessing
# if the chain has forked, so a retired generation cannot arrive through the name that retired it.
#
# A registry that publishes no population at all is a different state, and it is not a broken one:
# a fixture, or a clean clone whose cohorts have not run. `declared_population_members` separates
# it from a declared name that will not resolve, which is a broken lineage and refuses when the
# family has registered rows. Where nothing is declared, the comparison below runs on every
# registered prediction set and says so - that is a weaker claim than a declared population, but a
# statable one, and it is not the same as filtering everything away.

# %%
# Phase 1: Load pre-computed metrics (fast - no raw prediction loading)
raw_metrics = load_all_metrics(CASE_STUDY, label=None).filter(pl.col("label").is_not_null())

# %%
# `produced` is per family rather than per population, because which registered row belongs to
# which of a family's populations is what the population itself declares. A family with rows and
# an unresolvable declared name is the refusing case whichever of its names failed.
_family_produced = dict(
    raw_metrics.group_by("family").len().iter_rows()  # (family, count)
)
_declared, _population_notes = declared_population_members(
    study,
    CASE_DIR,
    POPULATIONS,
    produced={
        model: _family_produced.get(family, 0) for model, family in POPULATION_FAMILY.items()
    },
)
for _note in _population_notes:
    print(_note)

if _declared:
    CURRENT_MEMBERS = frozenset().union(*_declared.values())
    # Filtering to the members is not the same as checking the members arrived. A population is
    # published before its members finish fitting, so an interrupted run leaves a member absent
    # from the registry rather than incomplete in it - the filter then silently returns a
    # shorter leaderboard, and every recommendation below is made over whatever did arrive.
    # `load_all_metrics` drops a prediction set with a constant-prediction fold, because its
    # pooled IC is computed over the surviving folds only and is not a model result; those are
    # declared members that correctly never reach a leaderboard, so they are counted rather than
    # reported as missing.
    _degenerate = degenerate_prediction_hashes(CASE_DIR)
    _arrived = set(raw_metrics.get_column("prediction_hash").unique().to_list())
    _dropped = CURRENT_MEMBERS & _degenerate
    _missing = sorted(CURRENT_MEMBERS - _degenerate - _arrived)
    if _missing:
        raise RuntimeError(
            f"{len(_missing)} declared member(s) never reached the registry: "
            f"{', '.join(_missing[:5])}. The populations were published before their members "
            "finished fitting, so the comparison below would be short without saying so."
        )
    # Present is not the same as finished either. Coverage, the headline metrics, the per-fold
    # metrics and the predictions parquet are separate writes, so a run interrupted between them
    # leaves a member this leaderboard ranks off whatever did land - a score over the folds it
    # managed, where a shorter window is an easier window, or a rank on a set whose predictions
    # nothing downstream can read. Each member is reported with which of those it is.
    _short = incompletely_registered_predictions(CASE_DIR, CURRENT_MEMBERS)
    if _short:
        _named = ", ".join(f"{h}: {why}" for h, why in sorted(_short.items())[:5])
        raise RuntimeError(
            f"{len(_short)} declared member(s) are registered but unfinished: {_named}. "
            "Ranking them would compare a partial run against complete ones."
        )
    print(
        f"{len(CURRENT_MEMBERS):,} prediction sets in the populations in force"
        + (f"; {len(_dropped):,} excluded as degenerate" if _dropped else "")
    )
    raw_metrics = raw_metrics.filter(pl.col("prediction_hash").is_in(CURRENT_MEMBERS))
else:
    CURRENT_MEMBERS = frozenset(raw_metrics.get_column("prediction_hash").unique().to_list())
    print(
        f"no populations declared here; comparing all {len(CURRENT_MEMBERS):,} registered "
        "prediction sets"
    )
all_labels_metrics = (
    raw_metrics.with_columns(
        pl.col("ic_n_days").max().over(["family", "label"]).alias("_family_label_days")
    )
    .filter(
        pl.col("ic_n_days").is_not_null(),
        pl.col("ic_n_days") == pl.col("_family_label_days"),
    )
    .drop("_family_label_days")
)
all_metrics = all_labels_metrics.filter(pl.col("label") == PRIMARY_LABEL)

if all_metrics.height == 0:
    raise RuntimeError(f"No metrics found for {CASE_STUDY} / {PRIMARY_LABEL}")

families_present = sorted(all_metrics["family"].unique().to_list())
excluded_partial = raw_metrics.height - all_labels_metrics.height
print(f"Pre-computed metrics: {all_metrics.height} entries across {len(families_present)} families")
print(f"  Excluded partial-coverage variants: {excluded_partial}")

for fam in families_present:
    sub = all_metrics.filter(pl.col("family") == fam)
    configs = sub["config_name"].n_unique()
    checkpoints = sub["checkpoint_value"].drop_nulls().n_unique()
    best_ic = sub["ic_mean_daily"].max()
    best_ic_text = f"{best_ic:+.4f}" if best_ic is not None else "n/a"
    print(
        f"  {fam:20s}  {configs:3d} configs  {checkpoints:3d} checkpoints  best IC={best_ic_text}"
    )

# %% [markdown]
# The family census prevents a partial registry from silently becoming the
# model leaderboard.

# %%
EXPECTED_METRIC_FAMILIES = {"linear", "gbm", "tabular_dl", "deep_learning", "latent_factors"}
missing = EXPECTED_METRIC_FAMILIES - set(families_present)
if missing:
    n_present = len(families_present)
    print(
        f"\nWARNING: {n_present}/{len(EXPECTED_METRIC_FAMILIES)} predictive/structural "
        f"families present. Missing: {', '.join(sorted(missing))}"
    )
    print("  Recommendations below may change when missing families are added.")
else:
    print("\nFull predictive/structural coverage: all 5 metric families present.")

# %%
# Best model per family
best_per_family = best_model_per_family_fast(all_metrics)

print("\nBest model per family:")
print(
    best_per_family.select(
        ["family", "config_name", "checkpoint_value", "ic_mean_daily", "ic_se_hac"]
    )
)

# %%
# Phase 2a: Load per-fold metrics from registry (fast path - no raw predictions needed)
fold_metrics = load_fold_metrics_from_registry(CASE_STUDY, label=PRIMARY_LABEL)
if fold_metrics.height > 0:
    print(f"Fold metrics from registry: {fold_metrics.height} entries")
else:
    print("No fold_metrics table - will compute from raw predictions")

# %%
# Phase 2: Load raw predictions ONLY for the ~5 best models (not all 47M+)
representative_preds = []

for row in best_per_family.filter(pl.col("family") != "causal_dml").iter_rows(named=True):
    family = row["family"]
    config = row["config_name"]
    checkpoint = row.get("checkpoint_value")

    preds = load_predictions(
        CASE_STUDY,
        prediction_hash=row["prediction_hash"],
        family=family,
        label=PRIMARY_LABEL,
        config_name=config,
        checkpoint_value=checkpoint,
    )
    if preds.height > 0:
        representative_preds.append(preds)
        print(f"  Loaded {family}/{config}: {preds.height:,} predictions")

if representative_preds:
    best_preds = pl.concat(representative_preds, how="diagonal_relaxed")
    print(f"\nTotal representative predictions: {best_preds.height:,}")
else:
    best_preds = pl.DataFrame()
    print("WARNING: No raw predictions could be loaded")

# %%
# Fold date ranges for timeline
if best_preds.height > 0:
    _date_dtype = best_preds[DATE_COL].dtype
    if _date_dtype == pl.String:
        _date_expr = pl.col(DATE_COL).str.to_datetime(strict=False).cast(pl.Date)
    else:
        _date_expr = pl.col(DATE_COL).cast(pl.Date)
    fold_ranges = (
        best_preds.filter(pl.col("fold_id").is_not_null())
        .with_columns(_date_expr)
        .group_by("fold_id")
        .agg(
            pl.col(DATE_COL).min().alias("val_start"),
            pl.col(DATE_COL).max().alias("val_end"),
        )
        .sort("fold_id")
    )

# %% [markdown]
# ### Validation Outcomes Stop Before the Holdout

# %%
if best_preds.height > 0 and fold_ranges.height > 0:
    plot_cv_timeline(
        fold_ranges,
        n_splits,
        holdout_start,
        title="Every validation outcome ends before the 2021 holdout",
    )

# %% [markdown]
# With only 2 folds, the cross-validation design is minimal. Fold 0
# trains on the first 2 years and validates on year 3; fold 1 expands
# the training window and validates on a later year. The holdout
# period (2021 onwards) is never used for model selection.
#
# The 2-fold limitation means we cannot distinguish systematic
# performance from period-specific luck. The expanding window gives
# fold 1 more training data, but if fold 1 happens to cover a
# regime that favors momentum (or mean-reversion), we cannot
# separate the effect of more data from the effect of a favorable
# market environment. This caveat applies to every fold-level
# conclusion in this notebook.

# %% [markdown]
# ## 2. What Was Actually Run?
#
# Before comparing results, we map what is actually comparable. Not all
# model families were trained on all labels, and the five modeling
# chapters contribute different kinds of evidence: Ch11-13 produce
# predictive forecasts; Ch14 extracts latent structure; Ch15 estimates
# causal effects. A single ranking over all of them would compare answers
# to different questions.

# %%
# Coverage map: family × label × evidence type
EVIDENCE_TYPE = {
    "linear": "predictive",
    "gbm": "predictive",
    "tabular_dl": "predictive",
    "deep_learning": "predictive",
    "latent_factors": "structural",
    "causal_dml": "causal",
}
FAMILY_CHAPTER = {
    "linear": "Ch11",
    "gbm": "Ch12",
    "tabular_dl": "Ch12",
    "deep_learning": "Ch13",
    "latent_factors": "Ch14",
    "causal_dml": "Ch15",
}

coverage = (
    raw_metrics.group_by(["family", "label"])
    .agg(pl.col("config_name").n_unique().alias("n_configs"))
    .join(
        all_labels_metrics.group_by(["family", "label"]).agg(
            pl.col("ic_mean_daily").max().alias("best_ic")
        ),
        on=["family", "label"],
        how="left",
    )
    .with_columns(
        chapter=pl.col("family").replace(FAMILY_CHAPTER),
        evidence=pl.col("family").replace(EVIDENCE_TYPE),
    )
    .sort(["family", "label"])
)

print("Coverage Map: Families × Labels")
print(coverage.select(["chapter", "family", "label", "evidence", "n_configs", "best_ic"]))

# %%
# Primary label coverage summary
primary_coverage = coverage.filter(pl.col("label") == PRIMARY_LABEL)
predictive_families = primary_coverage.filter(pl.col("evidence") == "predictive")[
    "family"
].to_list()
structural_families = primary_coverage.filter(pl.col("evidence") == "structural")[
    "family"
].to_list()
# Counted over the rows that resolve, not over every row the table holds. A registration a
# reader cannot resolve is not coverage, and listing `causal_dml` as a family on the strength
# of one would put a family in the map whose evidence Section 7 then declines to show.
_causal_db = CASE_DIR / "run_log" / "registry.db"
if _causal_db.exists():
    import sqlite3

    from case_studies.utils.registry.store import current_causal_identities

    # Resolved once, here, through the reader's own path, and reused by Section 7 below.
    # Two checks were drifting apart otherwise: this one counted identities while the
    # evidence block tested a single metric, so a row could be coverage here and withheld
    # there. `CausalResult.one` refuses an ambiguous label rather than choosing between two
    # current identities, and `.complete` is the contract for whether the row holds what its
    # run was asked to produce - including the refutation, when one was asked for.
    try:
        CAUSAL_RESULT = CausalResult.one(study, label=PRIMARY_LABEL)
        CAUSAL_REFUSAL = "" if CAUSAL_RESULT.complete else f"{CAUSAL_RESULT.hash} is incomplete"
    except ValueError as _causal_err:
        CAUSAL_RESULT, CAUSAL_REFUSAL = None, str(_causal_err)
    _causal_primary_count = 0 if CAUSAL_REFUSAL else 1
else:
    CAUSAL_RESULT, CAUSAL_REFUSAL = None, "no registry"
    _causal_primary_count = 0
causal_families = ["causal_dml"] if _causal_primary_count else []
all_labels = sorted(coverage["label"].unique().to_list())

print(f"\nPrimary label ({PRIMARY_LABEL}):")
print(f"  Predictive families: {predictive_families}")
print(f"  Structural families: {structural_families or 'none'}")
print(f"  Causal families: {causal_families or 'none'}")
print(f"\nAll labels trained: {all_labels}")

# %% [markdown]
# The coverage map reveals an asymmetric training landscape. Only **GBM
# and linear** were trained across all five labels. TabM and the Ch13
# temporal models cover only the primary label. Latent factors cover the
# primary, 10-day, and risk-adjusted return labels. Causal DML is stored in
# its own registry table and is evaluated separately in Section 7.
#
# The evidence types are distinct: 4 predictive families (linear, GBM,
# tabular_dl, deep_learning), 1 structural family (latent_factors), and
# 1 causal family (causal_dml). The primary ranking in the next
# section uses only predictive families on the primary label; structural
# and causal evidence receive dedicated sections later.

# %% [markdown]
# ## 3. Primary Comparative View
#
# This section combines the signal baseline test with the family ranking
# into a single comparative view. We first check whether any model can beat
# the linear baseline, then rank all families.

# %% [markdown]
# ### Is There Forecastable Signal?
#
# Before comparing model families, we establish a baseline. If the
# simplest possible model - OLS linear regression on 48 equity, option, and
# option features - produces zero or negative IC, the prediction
# problem may be too hard for this cross-section. Given that the
# S&P 500 is the most efficient and most analyzed equity universe,
# very weak signal is expected.

# %%
# Linear baseline
linear_metrics = all_metrics.filter(pl.col("family") == "linear")
if linear_metrics.height > 0:
    for name in ["ols", "ridge_a0.001", "ridge_a0.01", "ridge"]:
        baseline = linear_metrics.filter(pl.col("config_name") == name)
        if baseline.height > 0:
            ic = baseline["ic_mean_daily"][0]
            se = baseline["ic_se_hac"][0]
            print(f"Linear baseline ({name}):")
            print(f"  Daily IC mean: {ic:+.4f}" if ic is not None else "  Daily IC mean: n/a")
            if se is not None and se > 0:
                print(f"  HAC SE:        {se:.4f}")
                print(f"  HAC t-stat:    {ic / se:.1f}")
            break

# %%
# Full ranking (top 15)
print(f"\nFull ranking ({all_metrics.height} model × checkpoint variants):")
print(
    all_metrics.head(15).select(
        ["family", "config_name", "checkpoint_value", "ic_mean_daily", "ic_se_hac"]
    )
)

# %% [markdown]
# ### One row per family, with the interval beside the point estimate
#
# The ranking above is per configuration and checkpoint, so a family with many checkpoints fills
# it. This reduces each family to its highest-IC row and puts a HAC interval, at two standard
# errors, beside the point estimate - the comparison the rest of the section is read against.
#
# `covers_zero` is the column to read first. Where it is true for every family, the ordering of
# the point estimates is not a ranking that inference supports, and the gap between two families
# is smaller than what either estimate is measured to. Reading the ordering anyway is the mistake
# this table is arranged to prevent.

# %% tags=["results"]
family_leaders = (
    all_metrics.filter(pl.col("ic_mean_daily").is_not_null() & pl.col("ic_se_hac").is_not_null())
    .filter(pl.col("ic_se_hac") > 0)
    .sort("ic_mean_daily", descending=True, nulls_last=True)
    .group_by("family", maintain_order=True)
    .first()
    .select(
        "family",
        "config_name",
        "checkpoint_value",
        "ic_mean_daily",
        "ic_se_hac",
        t_hac=pl.col("ic_mean_daily") / pl.col("ic_se_hac"),
        ci_lo=pl.col("ic_mean_daily") - 1.96 * pl.col("ic_se_hac"),
        ci_hi=pl.col("ic_mean_daily") + 1.96 * pl.col("ic_se_hac"),
    )
    .with_columns(covers_zero=(pl.col("ci_lo") <= 0) & (pl.col("ci_hi") >= 0))
    .sort("ic_mean_daily", descending=True)
)
print(f"families compared: {family_leaders.height}")
print(f"intervals covering zero: {family_leaders.get_column('covers_zero').sum()}")
family_leaders

# %% [markdown]
# **The spread between families is small relative to what any of them is measured to.** This is
# the most informationally efficient equity universe in the book, and the regression target on
# `fwd_ret_5d` is where that shows: the point estimates sit close together and close to zero,
# and the intervals overlap each other heavily.
#
# What follows from that is a constraint on how the rest of this notebook may be read. A family
# ordering taken off point estimates whose intervals overlap is not evidence about the families;
# it is evidence about which random draw this sample happens to be. The directional reframings
# and the longer and risk-adjusted horizons in §6 are a different question asked of the same
# features, and that is where the equity-and-option feature set is worth judging.

# %% [markdown]
# ### Which Model Families Extract the Most Signal?
#
# The primary comparison uses the highest-IC configuration from each family,
# evaluated by both mean IC and consistency across the 2 folds. With
# only 2 data points per family, statistical conclusions are inherently
# weak: the broad cross-section improves precision within each
# fold's IC estimate, but not in the stability of that estimate
# across time.

# %%
# Phase 2c: Build fold x family IC matrix, preferring registry fold metrics.
if fold_metrics.height > 0:
    # Fast path: use pre-computed fold-level IC from registry
    _best_keys = best_per_family.select("prediction_hash")
    _fm = fold_metrics.join(
        _best_keys,
        on="prediction_hash",
        how="semi",
    )
    if "ic" in _fm.columns and _fm.height > 0:
        fold_ic = _fm.with_columns(
            (pl.col("family") + "/" + pl.col("config_name")).alias("model_label"),
            pl.col("ic").alias("ic_mean"),
        ).select(["model_label", "fold_id", "ic_mean"])
        print(f"Using registry fold_metrics: {fold_ic.height} fold entries")
    else:
        fold_ic = (
            fold_performance_matrix(best_preds, date_col=DATE_COL)
            if best_preds.height > 0
            else pl.DataFrame()
        )
else:
    fold_ic = (
        fold_performance_matrix(best_preds, date_col=DATE_COL)
        if best_preds.height > 0
        else pl.DataFrame()
    )

# %% [markdown]
# ### Fold Signs Vary Across Family Leaders

# %%
if fold_ic.height > 0:
    positive_all_folds = (
        fold_ic.group_by("model_label")
        .agg((pl.col("ic_mean") > 0).all().alias("positive_all"))
        .filter(pl.col("positive_all"))["model_label"]
        .sort()
        .to_list()
    )
    fold_title = (
        f"{', '.join(label.split('/')[0] for label in positive_all_folds)} "
        "stay positive in both folds"
        if positive_all_folds
        else "No family leader stays positive in both validation folds"
    )
    model_labels, fold_cols, matrix = plot_fold_heatmap(
        fold_ic,
        title=fold_title,
    )
else:
    model_labels, fold_cols, matrix = [], [], np.array([])

# %%
# Summary statistics per family
if fold_ic.height > 0:
    family_stats = (
        fold_ic.group_by("model_label")
        .agg(
            pl.col("ic_mean").mean().alias("mean_ic"),
            pl.col("ic_mean").median().alias("median_ic"),
            pl.col("ic_mean").std().alias("std_ic"),
            pl.col("ic_mean").min().alias("worst_fold"),
            pl.col("ic_mean").max().alias("best_fold"),
            (pl.col("ic_mean") > 0).mean().alias("pct_positive"),
            pl.col("ic_mean").count().alias("n_folds"),
        )
        .sort("mean_ic", descending=True)
    )
    print("Family performance summary:")
    print(family_stats)

# %% [markdown]
# The heatmap reads alongside Section 3's CI tiers. All five family
# leaders now appear, including the null-checkpoint GBM and linear rows.
# Their fold-level scatter reflects the compression around zero. The
# broad cross-section improves precision *within* each fold; what
# the two-fold setup cannot do is establish stability across time. The
# combined picture is consistent with the Section 3 reading: none of the
# highest-IC configurations clears credibility on `fwd_ret_5d` after
# HAC inference, and the heatmap should not be read as evidence of
# stable family superiority.

# %% [markdown]
# ## 4. Stability Over Time
#
# With only 2 folds, traditional stability analysis (IQR widths,
# bimodal detection) is not meaningful. Box plots with 2 data points
# are degenerate. Instead, we focus on two questions: (1) is each
# family positive in both folds? and (2) does the fold ranking of
# families change between folds?

# %% [markdown]
# ### Two Folds Leave Family Rankings Fragile

# %%
if fold_ic.height > 0:
    plot_fold_boxplot(
        fold_ic,
        title="Two folds leave model-family rankings fragile",
    )

# %% [markdown]
# With two folds, and the highest-IC point estimates compressed close to zero (§3),
# the box plots are minimally informative -
# each "distribution" is two dots, and the inter-family overlap is
# almost complete. Four of the five family leaders are positive and cluster
# together (`tabm_m` at 0.0155, `pca` at 0.0099, `lstm_h64` at 0.0066 and
# `leaves_7_mse` at 0.0050); the linear leader `enet_f0.08` sits below them
# at -0.0022. None of the families has
# established time-series robustness in the formal sense on this
# label, and the daily-pooled HAC CIs (§3) - which use the full
# panel rather than the 2 fold-aggregates - are the binding
# inference, not the per-fold IC dispersion.

# %% [markdown]
# ## 5. What Are the Models Learning?
#
# This section consolidates signal structure, model complexity, and feature
# importance into a single diagnostic view. Three questions matter:
#
# 1. **Monotonicity**: do higher predicted scores correspond to higher
#    realized returns? A monotonic relationship confirms ranking ability.
# 2. **Diversity**: do different model families produce similar or
#    different rankings? Low correlation between families means ensemble
#    value; high correlation means diminishing returns from complexity.
# 3. **Features**: which inputs drive the forecasts, and do option-derived
#    features justify their data cost?

# %%
# Compute prediction bucket monotonicity for best model per family
bucket_results = {}
for row in best_per_family.iter_rows(named=True):
    family = row["family"]
    mask = pl.col("prediction_hash") == row["prediction_hash"]

    model_preds = best_preds.filter(mask) if best_preds.height > 0 else pl.DataFrame()
    if model_preds.height == 0:
        continue

    buckets = prediction_bucket_monotonicity(model_preds, N_BUCKETS, DATE_COL)
    if buckets.height > 0:
        bucket_results[family] = buckets

# %% [markdown]
# ### TabM Has the Largest Positive Bucket Spread

# %%
if bucket_results:
    unconditional_mean = best_preds["y_true"].mean() if best_preds.height > 0 else None
    plot_bucket_monotonicity(
        bucket_results,
        N_BUCKETS,
        unconditional_mean=unconditional_mean,
        cost_range=cost_range,
        title="TabM has the largest positive bucket spread",
    )

# %% [markdown]
# The decile plot is read alongside the 6-20 bps round-trip cost range.
# TabM produces a 27 bps top-minus-bottom spread. The LSTM and PCA reach
# 21 and 14 bps, linear 8, and GBM is approximately flat at -2 bps despite
# a positive mean IC. Ordering by spread is not the ordering by IC: the
# LSTM ranks third on IC and second here, which is what a decile spread
# measures that a rank correlation does not. With only two validation
# folds, these gross spreads are diagnostics rather than trading claims.
# They reinforce the Section 3 result that the primary-label evidence is
# weak and sensitive to the representation used.

# %%
# Pairwise prediction correlations
corr_matrix, corr_labels = (
    prediction_correlation_matrix(best_preds, date_col=DATE_COL, entity_col=ENTITY_COL)
    if best_preds.height > 0
    else (np.array([]), [])
)
if corr_matrix.size > 0 and len(corr_labels) >= 2:
    off_diagonal = corr_matrix[np.triu_indices(len(corr_labels), k=1)]
    print(
        f"Daily cross-sectional pairwise rank correlation: mean={off_diagonal.mean():.2f}, "
        f"range=[{off_diagonal.min():.2f}, {off_diagonal.max():.2f}]"
    )

# %% [markdown]
# ### Model Rankings Share Limited Common Signal

# %%
if corr_matrix.size > 0 and len(corr_labels) >= 2:
    plot_correlation_matrix(
        corr_matrix,
        corr_labels,
        title="Model rankings share limited common signal",
    )

# %% [markdown]
# Pairwise rank correlations are computed within each decision time and then
# averaged over time, matching the cross-sectional ranking task.
# The families are not redundant, but GBM and linear share a moderately
# similar ranking. Because no family clears credibility
# on `fwd_ret_5d`, the practical reading is not "ensemble of strong
# diverse signals" but "ensemble of orthogonal weak signals" - useful
# for label-routed allocation in §6 (different families have CI-
# credible point estimates on different labels) more than for a
# uniform §3 average. The structural-vs-supervised split in this
# feature set - equity volatility, option implied surfaces,
# momentum, and term-structure inputs - is consistent with different
# extraction mechanisms (autoencoder factor rotation vs direct
# feature-to-return mapping) producing genuinely different rankings.

# %% [markdown]
# ### How Much Does Additional Model Complexity Help?
#
# For models with checkpoint data, we observe how validation IC evolves
# with training. This reveals where diminishing returns begin and
# whether models overfit with additional epochs.

# %%
# Learning curves from pre-computed metrics (fast path)
cp_data = all_metrics.filter(pl.col("checkpoint_value").is_not_null())
if cp_data.height > 0:
    _curve_configs = (
        cp_data.group_by(["family", "config_name"])
        .agg(pl.col("checkpoint_value").n_unique().alias("n_cp"))
        .filter(pl.col("n_cp") > 1)
        .select("family", "config_name")
    )
    cp_data = cp_data.join(_curve_configs, on=["family", "config_name"], how="semi")
    cp_families = sorted(cp_data["family"].unique().to_list())
else:
    cp_families = []

print(f"Families with checkpoint data: {cp_families}")

# %% [markdown]
# ### Checkpoint Sensitivity Differs Across Families

# %%
if cp_families:
    plot_learning_curves(
        cp_data,
        cp_families,
        titles={family: f"{family} IC across the published checkpoints" for family in cp_families},
    )

# %% [markdown]
# The learning curves show optimization dynamics for the families that
# emit per-checkpoint metrics. With highest daily-pooled IC compressed
# in a tight band (§3), the curve heights are small in absolute
# terms; the informative patterns are about *shape* rather than
# magnitude:
#
# - **Latent factors**: oscillatory IC across checkpoints
#   on this broad panel; checkpoint selection is fragile and
#   the late-epoch ceiling is close to the early-epoch best.
# - **Tabular DL (TabM)**: the schedule is read off the curve rather than named here. The
#   checkpoint each configuration reaches its highest IC at moves when the declared schedule
#   moves - `08_tabular_dl` publishes the presets' full epoch budget at the declared interval -
#   so an epoch quoted in this prose would describe a grid the notebooks no longer run.
#
# The current GBM and Ch13 rows retain only one selected checkpoint per
# configuration, so this notebook does not manufacture learning curves by
# joining checkpoints from separate configs or executions. Their training
# notebooks carry the exact-run checkpoint evidence.
#
# The takeaway is that none of the families converts late-checkpoint
# capacity into a CI-credible point estimate on `fwd_ret_5d`; early
# stopping is appropriate for the deep families on this case study.

# %% [markdown]
# ### Which Features Drive the Forecasts?
#
# Feature importance is the most important subsection for this case study.
# The central question is whether option-derived features - implied
# volatility, skew, term structure, put-call ratios - appear among
# the top predictors, or whether traditional equity momentum and
# volatility features dominate. If option features do not rank highly,
# the expensive option data adds no incremental value.

# %%
gbm_importance = load_gbm_feature_importance(CASE_STUDY, label=PRIMARY_LABEL, top_n=TOP_N_FEATURES)
importance_rows = []
merged = pl.DataFrame()

if gbm_importance is None:
    print("No GBM booster files available. Computing feature-prediction correlation as fallback...")
    features_path = CASE_DIR / "features" / "financial.parquet"
    if features_path.exists() and best_preds.height > 0:
        features_df = pl.read_parquet(features_path)
        feat_cols = [c for c in features_df.columns if c not in [DATE_COL, ENTITY_COL]]
        linear_preds = best_preds.filter(pl.col("family") == "linear")
        if linear_preds.height > 0:
            left_dtype = linear_preds[DATE_COL].dtype
            right_dtype = features_df[DATE_COL].dtype
            if left_dtype != right_dtype:
                target = pl.Datetime("ms")
                linear_preds = linear_preds.with_columns(pl.col(DATE_COL).cast(target))
                features_df = features_df.with_columns(pl.col(DATE_COL).cast(target))
            merged = linear_preds.join(features_df, on=[DATE_COL, ENTITY_COL], how="inner")

# %% [markdown]
# If booster gain data is unavailable, rank features by their within-fold
# Spearman association with the selected linear model's score.

# %%
if gbm_importance is None and merged.height > 0:
    from scipy.stats import spearmanr

    for fold in sorted(merged["fold_id"].unique().drop_nulls().to_list()):
        fold_data = merged.filter(pl.col("fold_id") == fold)
        for feature in feat_cols:
            values = fold_data[[feature, "y_score"]].drop_nulls()
            if values.height <= 50:
                continue
            correlation, _ = spearmanr(values[feature].to_numpy(), values["y_score"].to_numpy())
            importance_rows.append(
                {
                    "config_name": "linear",
                    "fold_id": int(fold),
                    "feature": feature,
                    "importance": abs(float(correlation)),
                }
            )

# %% [markdown]
# Normalize the fallback inside each fold before retaining the most recurrent
# features, so folds with different raw scales remain comparable.

# %%
if importance_rows:
    gbm_importance = pl.DataFrame(importance_rows).with_columns(
        (pl.col("importance") / pl.col("importance").max().over(["config_name", "fold_id"])).alias(
            "importance_norm"
        )
    )
    top_features = (
        gbm_importance.group_by("feature")
        .agg(pl.col("importance_norm").mean().alias("mean_imp"))
        .sort("mean_imp", descending=True)
        .head(TOP_N_FEATURES)["feature"]
        .to_list()
    )
    gbm_importance = gbm_importance.filter(pl.col("feature").is_in(top_features))
    print(
        f"Computed feature-score correlation for {len(top_features)} features "
        f"across {merged['fold_id'].n_unique()} folds"
    )

if gbm_importance is not None and gbm_importance.height > 0:
    _n_importance_features = gbm_importance["feature"].n_unique()
    _n_importance_folds = gbm_importance["fold_id"].n_unique()
    print(f"Feature importance: {_n_importance_features} features × {_n_importance_folds} folds")
else:
    print("Feature importance data not available.")

# %% [markdown]
# ### Term-Structure Slope Is the Most Stable Feature

# %%
if gbm_importance is not None and gbm_importance.height > 0:
    plot_feature_importance_heatmap(
        gbm_importance,
        TOP_N_FEATURES,
        title="Term-structure slope is the most stable feature",
    )

    # Option vs equity feature breakdown
    option_keywords = [
        "put_call",
        "skew_rr",
        "vega",
        "theta",
        "delta",
        "gamma",
        "term_struct",
        "term_slope",
        "term_ratio",
        "ivrv",
        "implied",
        "oi_",
        "open_interest",
        "option",
    ]
    all_top_features = (
        gbm_importance.group_by("feature")
        .agg(pl.col("importance_norm").mean().alias("mean_imp"))
        .sort("mean_imp", descending=True)
        .head(TOP_N_FEATURES)["feature"]
        .to_list()
    )
    opt_in_top = [
        f
        for f in all_top_features
        if f.lower().startswith(("iv_", "ivrv_")) or any(kw in f.lower() for kw in option_keywords)
    ]
    eq_in_top = [f for f in all_top_features if f not in opt_in_top]
    print(f"\nOption-derived features in top {TOP_N_FEATURES}: {len(opt_in_top)} - {opt_in_top}")
    print(f"Equity features in top {TOP_N_FEATURES}: {len(eq_in_top)} - {eq_in_top}")

# %% [markdown]
# **Equity features hold a narrow majority of the top slots.** Eight of the top
# 15 are equity-side and seven are option-derived: five IV levels across the
# 7, 30 and 90-day tenors and the 25-delta put and call wings, the 252-day
# z-score of 30-day ATM IV, and the ATM term ratio. The eight equity features
# are realized volatility at 20 and 63 days, Garman-Klass volatility, and
# momentum at 63, 126 and 252 days plus its risk-adjusted and skip-recent
# variants.
#
# Only two features hold a top-5 slot in at least three quarters of folds:
# `iv_30_put_25d` and `rv_63`. With two folds that is a weak statement about
# persistence, and it is the reason the ranking below is read as breadth
# rather than as a stable ordering.
#
# The feature importance pattern tells a nuanced story:
#
# 1. **Volatility features dominate both sides**: both realized
#    volatility (equity) and implied volatility (option) are the
#    strongest individual predictors. The signal is fundamentally
#    about volatility regime positioning.
# 2. **Option features add breadth**: seven of 15 slots show that the
#    option surface participates in the forecast, but this importance
#    ranking is not an ablation and does not isolate incremental value.
# 3. **The IV-RV spread is absent**: ivrv_spread does not rank in the
#    top 15, despite being the theoretically most interesting option
#    feature. This may reflect high noise at the individual stock level.
# 4. **Momentum features are secondary**: momentum at 63, 126 and 252 days
#    and `mom_skip_recent` all appear but none reaches the top of the
#    ranking, suggesting that pure price momentum
#    is less important than volatility regime for weekly stock selection
#    in S&P 500.
#
# The feature-level view shows why joint equity-option structure remains
# worth testing. It does not explain family superiority on the primary
# label: PCA is the latent-factor leader there, and every family-leader CI
# on this label still includes zero.

# %% [markdown]
# ## 6. Heterogeneity: Labels, Horizons, and Regimes
#
# Signal strength may vary across prediction targets, forecast horizons,
# and market regimes. This section examines all three dimensions.

# %% [markdown]
# ### Multi-Label Comparison
#
# Five labels were trained on this case study: the primary `fwd_ret_5d`
# (weekly regression), a longer-horizon regression (`fwd_ret_10d`),
# a risk-adjusted variant (`fwd_ret_risk_adj_5d`), and two directional
# reframings (`fwd_dir_5d`, `fwd_dir_10d`). The forest below renders
# the highest-IC config per family for each label as a point estimate
# with its HAC interval; tiles labeled "no run" mean a family was not
# trained on that label, which is itself part of the diagnosis.

# %%
multi_rows = []
for lbl in [PRIMARY_LABEL] + [l for l in all_labels if l != PRIMARY_LABEL]:
    lbl_metrics = all_labels_metrics.filter(pl.col("label") == lbl)
    for fam in lbl_metrics["family"].unique().to_list():
        fam_data = lbl_metrics.filter(pl.col("family") == fam)
        rank1 = fam_data.sort("ic_mean_daily", descending=True, nulls_last=True).head(1)
        if rank1.height == 0:
            continue
        r = rank1.row(0, named=True)
        if r.get("ic_mean_daily") is None:
            continue
        multi_rows.append(
            {
                "label": lbl,
                "family": fam,
                "config_name": r["config_name"],
                "ic_mean_daily": r["ic_mean_daily"],
                "ic_ci_lo": r.get("ic_ci_lo"),
                "ic_ci_hi": r.get("ic_ci_hi"),
                "ic_t_hac": r.get("ic_t_hac"),
            }
        )

multi_label_df = pl.DataFrame(multi_rows)
multi_label_df

# %%
plot_label_horizon_forest(
    multi_label_df,
    families=["linear", "gbm", "tabular_dl", "deep_learning", "latent_factors", "causal_dml"],
    labels=[PRIMARY_LABEL] + [l for l in all_labels if l != PRIMARY_LABEL],
    label_display={
        "fwd_ret_5d": "fwd_ret_5d (weekly, primary)",
        "fwd_ret_10d": "fwd_ret_10d (biweekly)",
        "fwd_ret_risk_adj_5d": "fwd_ret_risk_adj_5d (vol-scaled)",
        "fwd_dir_5d": "fwd_dir_5d (binary direction, weekly)",
        "fwd_dir_10d": "fwd_dir_10d (binary direction, biweekly)",
    },
    title="Where each family lands, one panel per label",
)

# %% [markdown]
# Coverage is uneven across the panel. On the primary `fwd_ret_5d`
# all five predictive and structural families have a registry entry, plus
# causal DML has a primary-label estimate rendered separately in Section 7.
# On `fwd_ret_10d` and `fwd_ret_risk_adj_5d` only linear, GBM, and
# latent factors have runs; TabM and the Ch13 deep models
# causal_dml are absent. On the two directional labels only linear
# and GBM have runs - neither the deep families nor latent factors
# were retrained on the binary targets. Causal_dml's missing tiles
# are the same: the family ran a single ATE on the primary label.
#
# **Read the panels for which intervals clear zero, not for which point estimate is highest.**
# The primary label in §3 had every family's interval covering zero; the alternate regression
# labels need not, and where one does not, that is the strongest statement this notebook makes
# about any target. The table below counts it rather than leaving it to the eye.
#
# Two things are worth reading off the panel beyond that. **Which family leads changes with the
# target** - a family that leads on one label need not lead on another, and where that happens
# it is evidence about routing a label to a model rather than about one family dominating.
# **The directional reframings are a separate question**: recasting the target as a sign is a
# different problem, and in some case studies it rescues a family whose regression estimate is
# indistinguishable from zero. Whether it does so here is in the panel.

# %% [markdown]
# ### Regime Sensitivity
#
# The S&P 500 equity+option universe has a natural regime variable:
# the VIX (or its proxy, cross-sectional return dispersion). Option
# features should be more informative in high-volatility periods,
# when implied volatility surfaces contain more information about
# future returns. In low-volatility environments, options are cheap,
# IV surfaces are flat, and the incremental signal from option data
# diminishes.

# %%
# Compute regime-conditional IC
regime_results = []

for row in best_per_family.iter_rows(named=True):
    family = row["family"]
    mask = pl.col("prediction_hash") == row["prediction_hash"]

    model_preds = best_preds.filter(mask) if best_preds.height > 0 else pl.DataFrame()
    if model_preds.height == 0:
        continue

    regime_ic = regime_conditional_ic(model_preds, date_col=DATE_COL)
    if regime_ic.height > 0:
        regime_ic = regime_ic.with_columns(pl.lit(family).alias("family"))
        regime_results.append(regime_ic)

regime_df = pl.concat(regime_results) if regime_results else pl.DataFrame()

# %% [markdown]
# ### Model Performance Changes Sign Across Volatility Regimes

# %%
if regime_df.height > 0:
    plot_regime_bars(
        regime_df,
        title="Model performance changes sign across volatility regimes",
    )

# %% [markdown]
# **What to look for is whether the families move together or in opposite directions.** The
# premise of the section is that option features should carry more information when implied
# volatility surfaces are informative, which would show as most families improving in the
# high-volatility bucket. Families changing sign in opposite directions is the other outcome,
# and it argues against a static family ranking rather than for regime timing.
#
# Either reading is bounded by the same limit: two validation folds split into volatility
# buckets leaves very few dates per bucket, so a sign change here is not enough evidence to
# weight a strategy by regime. The measurement is worth making and is not worth trading on.

# %% [markdown]
# ## 7. Structural and Causal Evidence
#
# Not all model chapters produce comparable predictive scores. Ch14
# (latent factors) extracts structure; Ch15 (causal DML) estimates
# treatment effects. These require separate evidence blocks.

# %% [markdown]
# ### Latent Factors (Ch14)
#
# All five latent factor models were trained on the configured S&P 500
# Equity+Options roster. The broad validation cross-section
# and rich option-implied features makes this the most informative
# latent-factor case study in the book - even where supervised IC
# straddles zero on the primary label, the structural variants extract
# stable factor structure and reach CI credibility on the 10-day and
# vol-normalized horizons (Section 6: PCA on both labels).

# %%
# `load_fold_extras` reads `run_log/training/<training_hash>/fold_extras.json`, so it takes a
# training hash and not an estimator name. Passing the name resolved to a directory that never
# exists, every lookup returned None, the filter emptied the dict, and each `if "<model>" in
# lf_extras` block below silently printed nothing while the prose beside it described the
# figures. Nothing raised, because a missing extras file is a legitimate answer for a model
# that stores none.
#
# The hash has to come from the published member rather than from any run of the estimator: 44
# latent-factor training runs are registered here and all of them wrote fold extras, so picking
# by estimator alone would show a superseded fit's diagnostics beside the current fit's score.
# `_declared` is the population in force per model, which is the same set the leaderboard above
# is ranked over, and the member taken is the one whose IC the summary prints.
lf_models = ["pca", "ipca", "cae", "sdf", "sae"]


def _published_training_hash(model: str) -> str | None:
    """The training run behind the best-scoring published member of ``model``."""
    members = _declared.get(model) if _declared else None
    if not members:
        return None
    scored = raw_metrics.filter(
        pl.col("prediction_hash").is_in(list(members)), pl.col("label") == PRIMARY_LABEL
    ).sort("ic_mean_daily", descending=True, nulls_last=True)
    if scored.height == 0:
        return None
    best = scored.row(0, named=True)["prediction_hash"]
    with sqlite3.connect(CASE_DIR / "run_log" / "registry.db") as _db:
        found = _db.execute(
            "SELECT training_hash FROM prediction_sets WHERE prediction_hash = ?", (best,)
        ).fetchone()
    return found[0] if found else None


lf_training = {m: _published_training_hash(m) for m in lf_models}
lf_extras = {m: load_fold_extras(CASE_STUDY, h) for m, h in lf_training.items() if h}
lf_extras = {m: e for m, e in lf_extras.items() if e is not None}
_lf_silent = sorted(m for m in lf_models if m not in lf_extras)
if _lf_silent:
    # Named rather than left to an empty figure. A model whose extras cannot be read has no
    # diagnostic below it, and the reader is told which one and why instead of seeing a gap.
    print(
        "No fold extras for: "
        + ", ".join(f"{m} (training {lf_training[m] or 'unresolved'})" for m in _lf_silent)
    )

# Print IC summary from registry
lf_metrics = all_labels_metrics.filter(
    pl.col("family") == "latent_factors", pl.col("label") == PRIMARY_LABEL
)
if lf_metrics.height > 0:
    lf_best = (
        lf_metrics.group_by("config_name")
        .agg(ic=pl.col("ic_mean_daily").max())
        .sort("ic", descending=True)
    )
    print(f"Latent factor IC on {PRIMARY_LABEL}:")
    for row in lf_best.iter_rows(named=True):
        print(f"  {row['config_name']:6s}: {row['ic']:+.4f}")

    # Show best supervised for comparison
    sup_metrics = all_labels_metrics.filter(
        pl.col("family").is_in(["linear", "gbm", "tabular_dl", "deep_learning"]),
        pl.col("label") == PRIMARY_LABEL,
    )
    if sup_metrics.height > 0:
        sup_best = sup_metrics.sort("ic_mean_daily", descending=True).head(1)
        print(f"\nBest supervised: {sup_best['family'][0]} IC={sup_best['ic_mean_daily'][0]:+.4f}")

print(f"\nFold extras available: {list(lf_extras.keys())}")

# %% [markdown]
# #### PCA Variance Decomposition

# %%
if "pca" in lf_extras:
    var_ratios = [e["explained_variance_ratio"] for e in lf_extras["pca"]]
    mean_var = np.mean(var_ratios, axis=0)

    fig, axes = plt.subplots(1, 2, figsize=FIGSIZE["dual_h_tall"], layout="tight")
    axes[0].bar(range(1, len(mean_var) + 1), mean_var, color=COLORS["blue"])
    axes[0].set_xlabel("Component")
    axes[0].set_ylabel("Variance Explained")
    axes[0].set_title("Component variance", loc="left")

    axes[1].plot(range(1, len(mean_var) + 1), np.cumsum(mean_var), marker="o", color=COLORS["blue"])
    axes[1].set_xlabel("Components")
    axes[1].set_ylabel("Cumulative Variance")
    axes[1].set_title("Cumulative variance", loc="left")
    axes[1].axhline(0.5, ls="--", color=COLORS["neutral"], alpha=0.5)
    fig.suptitle(
        "How much of the validation variance the retained factors account for",
        x=0.02,
        ha="left",
        fontweight="semibold",
    )
    fig.tight_layout(rect=(0, 0, 1, 0.92))
    show_with_alt(
        fig,
        "Two panels. Left: a bar per retained principal component, height the share of "
        "validation variance that component explains, averaged over folds. Right: the same "
        "shares accumulated left to right as a line with a marker per component, against a "
        "dashed reference line at one half.",
    )

# %% [markdown]
# **Interpretation**: The scree plot shows how variance concentrates
# across the broad equity+option validation cross-section. The steep initial
# drop indicates a small number of dominant factors - consistent with
# the well-documented factor structure of S&P 500 returns. The
# cumulative curve reveals how many components are needed to capture
# the majority of cross-sectional variation in this joint
# equity+option feature space.

# %% [markdown]
# #### IPCA Characteristic Loadings ($\Gamma$ Matrix)
#
# The $\Gamma$ matrix maps the 48 equity+option characteristics to
# latent factor loadings. Option-implied features (IV, skew, term
# structure) that load heavily suggest the model captures
# volatility-regime-based factor structure.

# %%
if "ipca" in lf_extras:
    last_fold = lf_extras["ipca"][-1]
    if "Gamma" in last_fold:
        Gamma = np.array(last_fold["Gamma"])
        n_chars, n_factors = Gamma.shape

        # Load feature names
        feat_names = []
        for fname in ["financial.parquet", "model_based.parquet"]:
            fpath = CASE_DIR / "features" / fname
            if fpath.exists():
                cols = pl.scan_parquet(fpath).collect_schema().names()
                feat_names.extend(
                    c
                    for c in cols
                    if c not in {"symbol", "timestamp", "date", "asset"}
                    and not c.startswith("fwd_")
                )

        # Top 10 characteristics per factor
        n_top = min(10, n_chars)
        panel_count = min(3, n_factors)
        siz

在遵守原作品许可的前提下,附作者信息全文展示。 许可协议: MIT

此摘要由 Stratmill 研究智能体根据原文撰写,并非原文副本。