Files
2026-07-13 13:26:28 +08:00

857 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---
# 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]
# # Robustness and Sensitivity Analysis
#
# **Chapter 8: Feature Engineering**
# **Section Reference**: 8.6 — Combining Features and Controlling Search
#
# **Docker image**: `ml4t`
#
# ## Purpose
#
# A robust signal maintains performance across reasonable variations in
# parameters, regimes, and implementation choices. This notebook teaches how
# to assess robustness through parameter sweeps, regime conditioning, and
# signal × state interactions.
#
# ## Learning Objectives
#
# 1. Conduct parameter sweeps as response-surface analysis
# 2. Define robustness as the **breadth of the near-optimal region**
# 3. Analyze regime-conditional IC with clean conditioning variables
# 4. Build signal × state interaction features (gating, scaling, conditional)
# 5. Apply RAS correction for parameter snooping
#
# ## Data Policy
#
# All examples use **real ETF data**.
# %%
"""Robustness and Sensitivity Analysis — parameter sweeps, regime conditioning, and signal interaction features."""
from __future__ import annotations
import warnings
from datetime import datetime
import matplotlib.pyplot as plt
import numpy as np
import plotly.graph_objects as go
import polars as pl
from ml4t.diagnostic.metrics import pooled_ic
from plotly.subplots import make_subplots
from utils.reproducibility import set_global_seeds
warnings.filterwarnings("ignore")
# %% tags=["parameters"]
START_DATE = "2015-01-01"
END_DATE = "2024-01-01"
SEED = 42
# %%
set_global_seeds(SEED)
# %% [markdown]
# ## 1. Data Loading
# %%
from data import load_etfs
etfs = load_etfs()
etfs_filtered = etfs.filter(
(pl.col("timestamp") >= datetime.strptime(START_DATE, "%Y-%m-%d"))
& (pl.col("timestamp") < datetime.strptime(END_DATE, "%Y-%m-%d"))
).sort(["symbol", "timestamp"])
print(f"ETF data: {len(etfs_filtered):,} rows")
print(f"Symbols: {etfs_filtered['symbol'].n_unique()}")
print(f"Date range: {etfs_filtered['timestamp'].min()} to {etfs_filtered['timestamp'].max()}")
# %%
prices_wide = (
etfs_filtered.select(["timestamp", "symbol", "close"])
.pivot(on="symbol", index="timestamp", values="close")
.sort("timestamp")
)
symbols = [c for c in prices_wide.columns if c != "timestamp"]
print(f"Computing features for {len(symbols)} symbols")
# %% [markdown]
# ## 2. IC Computation Helpers
#
# All IC statistics use HAC standard errors via the `ml4t-diagnostic` library
# to account for serial dependence.
# %%
from ml4t.diagnostic.metrics import compute_ic_hac_stats
def _ic_stats_with_icir(ic_series: np.ndarray) -> dict:
"""Compute HAC-adjusted IC stats and add ICIR (mean IC / std IC)."""
stats = compute_ic_hac_stats(ic_series)
std_ic = float(np.std(ic_series[~np.isnan(ic_series)], ddof=1))
stats["icir"] = stats["mean_ic"] / std_ic if std_ic > 0 else np.nan
return stats
# %% [markdown]
# ### Compute IC Series for a Momentum Signal
# For each date, compute cross-sectional Spearman IC between the momentum
# signal and forward returns.
# %%
def compute_momentum_ic_series(
prices_df: pl.DataFrame,
symbols: list[str],
lookback: int,
forward_horizon: int = 20,
) -> np.ndarray:
"""Compute daily cross-sectional IC series for a momentum signal."""
momentum = prices_df.select(
pl.col("timestamp"),
*[(pl.col(s) / pl.col(s).shift(lookback) - 1).alias(s) for s in symbols],
)
forward_ret = prices_df.select(
pl.col("timestamp"),
*[(pl.col(s).shift(-forward_horizon) / pl.col(s) - 1).alias(s) for s in symbols],
)
mom_long = momentum.unpivot(index="timestamp", variable_name="symbol", value_name="signal")
fwd_long = forward_ret.unpivot(index="timestamp", variable_name="symbol", value_name="fwd_ret")
merged = mom_long.join(fwd_long, on=["timestamp", "symbol"], how="inner").drop_nulls()
ics = []
for date in merged["timestamp"].unique().sort().to_list():
day_data = merged.filter(pl.col("timestamp") == date)
if len(day_data) >= 10:
sig_vals = day_data["signal"].to_numpy()
ret_vals = day_data["fwd_ret"].to_numpy()
valid = np.isfinite(sig_vals) & np.isfinite(ret_vals)
if np.sum(valid) >= 10:
ic = pooled_ic(sig_vals[valid], ret_vals[valid])
if not np.isnan(ic):
ics.append(ic)
return np.array(ics)
# %% [markdown]
# ## 3. Parameter Sweep: Response Surface
#
# We vary the momentum lookback period and observe how IC changes. The goal
# is not to find the "best" parameter but to understand the **response
# surface** and identify a **robust region**.
#
# **One knob at a time**: The sweep varies lookback while holding everything
# else constant (return metric, normalization, label horizon). Changing
# multiple parameters simultaneously makes it impossible to attribute
# performance changes to any single choice.
# %%
LOOKBACK_RANGE = [5, 10, 21, 42, 63, 126, 189, 252]
FORWARD_HORIZON = 20
sweep_results = {}
print("Parameter Sweep: Momentum Lookback")
print("-" * 60)
for lb in LOOKBACK_RANGE:
ic_series = compute_momentum_ic_series(prices_wide, symbols, lb, FORWARD_HORIZON)
if len(ic_series) >= 20:
stats_result = _ic_stats_with_icir(ic_series)
stats_result["ics"] = ic_series # Keep for later use
sweep_results[lb] = stats_result
sig = (
"***"
if stats_result["p_value"] < 0.01
else (
"**"
if stats_result["p_value"] < 0.05
else ("*" if stats_result["p_value"] < 0.10 else "")
)
)
print(
f" Lookback {lb:3d}d: IC={stats_result['mean_ic']:.4f}, "
f"ICIR={stats_result['icir']:.2f}, "
f"HAC t={stats_result['t_stat']:.2f}{sig}"
)
# %%
# Visualize response surface
if sweep_results:
fig = make_subplots(rows=1, cols=2, subplot_titles=["Mean IC by Lookback", "ICIR by Lookback"])
lbs = list(sweep_results.keys())
mean_ics = [sweep_results[lb]["mean_ic"] for lb in lbs]
icirs = [sweep_results[lb]["icir"] for lb in lbs]
hac_ses = [sweep_results[lb]["hac_se"] for lb in lbs]
fig.add_trace(
go.Scatter(
x=lbs,
y=mean_ics,
mode="lines+markers",
name="Mean IC",
error_y=dict(type="data", array=[1.96 * se for se in hac_ses]),
),
row=1,
col=1,
)
fig.add_hline(y=0, line_dash="dash", line_color="gray", row=1, col=1)
fig.add_trace(
go.Scatter(
x=lbs,
y=icirs,
mode="lines+markers",
name="ICIR",
line=dict(color="darkorange"),
),
row=1,
col=2,
)
fig.update_layout(title="ETF Momentum: Response Surface", height=400, showlegend=False)
fig.update_xaxes(title_text="Lookback (days)", row=1, col=1)
fig.update_xaxes(title_text="Lookback (days)", row=1, col=2)
fig.update_yaxes(title_text="Mean IC", row=1, col=1)
fig.update_yaxes(title_text="ICIR", row=1, col=2)
fig.show()
# %% [markdown]
# ## 4. Robustness: Breadth of Near-Optimal Region
#
# Robustness is **not** a scalar score (mean/std ratio). It is the breadth
# of the near-optimal region: how many parameter values achieve performance
# within 90% of the best. A robust signal has a broad plateau; a fragile
# signal has a narrow peak.
# %%
def compute_robust_region(
sweep_results: dict[int, dict],
metric: str = "icir",
threshold_pct: float = 0.90,
) -> dict:
"""Compute the robust region as parameters within threshold_pct of best."""
if not sweep_results:
return {}
params = list(sweep_results.keys())
values = [sweep_results[p][metric] for p in params]
best_idx = np.argmax(values)
best_param = params[best_idx]
best_value = values[best_idx]
threshold = best_value * threshold_pct
robust_params = [p for p, v in zip(params, values, strict=False) if v >= threshold]
return {
"best_param": best_param,
"best_value": best_value,
"threshold": threshold,
"robust_params": robust_params,
"robust_fraction": len(robust_params) / len(params),
"robust_range": [min(robust_params), max(robust_params)] if robust_params else None,
}
# %%
if sweep_results:
robustness = compute_robust_region(sweep_results, metric="icir", threshold_pct=0.90)
print(f"Best lookback: {robustness['best_param']} days (ICIR = {robustness['best_value']:.2f})")
print(f"90% threshold: ICIR >= {robustness['threshold']:.2f}")
print(f"Robust parameters: {robustness['robust_params']}")
rr = robustness["robust_range"]
print(f"Robust range: {rr[0]} to {rr[1]} days" if rr else "Robust range: None")
print(f"Robust fraction: {robustness['robust_fraction']:.0%} of tested parameters")
frac = robustness["robust_fraction"]
band = ">50%" if frac >= 0.5 else "25-50%" if frac >= 0.25 else "<25%"
print(f"\nRobust fraction {frac:.0%} ({band} of parameters near-optimal)")
# %%
# Visualize robust region
if sweep_results and robustness:
fig = go.Figure()
lbs = list(sweep_results.keys())
icirs = [sweep_results[lb]["icir"] for lb in lbs]
fig.add_trace(
go.Scatter(
x=lbs,
y=icirs,
mode="lines+markers",
name="ICIR",
line=dict(color="steelblue", width=2),
)
)
fig.add_hline(
y=robustness["threshold"],
line_dash="dash",
line_color="darkorange",
annotation_text=f"90% of best ({robustness['threshold']:.2f})",
)
if robustness["robust_range"]:
fig.add_vrect(
x0=robustness["robust_range"][0],
x1=robustness["robust_range"][1],
fillcolor="lightgreen",
opacity=0.3,
line_width=0,
annotation_text="Robust Region",
annotation_position="top left",
)
# %%
# Add best-point marker and display
if sweep_results and robustness:
fig.add_trace(
go.Scatter(
x=[robustness["best_param"]],
y=[robustness["best_value"]],
mode="markers",
name=f"Best ({robustness['best_param']}d)",
marker=dict(color="red", size=12, symbol="star"),
)
)
fig.update_layout(
title="ETF Momentum: Robust Region Analysis",
xaxis_title="Lookback (days)",
yaxis_title="ICIR",
height=450,
)
fig.show()
# %% [markdown]
# ### RAS Correction for Parameter Snooping
#
# After sweeping N parameter combinations, the best IC is upward-biased.
# RAS corrects for correlation-aware multiple testing — nearby parameters
# produce correlated IC estimates and count as fewer independent tests.
# %%
from ml4t.diagnostic.evaluation.stats import (
rademacher_complexity,
ras_ic_adjustment,
)
if sweep_results:
ic_matrix = []
for lb in LOOKBACK_RANGE:
ic_s = compute_momentum_ic_series(prices_wide, symbols, lb, FORWARD_HORIZON)
if len(ic_s) >= 20:
ic_matrix.append(ic_s)
if len(ic_matrix) >= 2:
min_len = min(len(s) for s in ic_matrix)
ic_matrix_aligned = np.array([s[:min_len] for s in ic_matrix]) # (N strategies, T)
best_ic_values = np.array([np.mean(s) for s in ic_matrix_aligned])
# complexity is the empirical Rademacher complexity R-hat, NOT the count
# of parameters tested. It expects a (T periods x N strategies) matrix and
# measures how correlated the swept ICs are (correlated -> fewer effective
# independent tests -> smaller snooping penalty).
r_hat = rademacher_complexity(ic_matrix_aligned.T, random_state=42)
# The snooping bias is about the cherry-picked HIGHEST IC, so deflate that.
best_idx = int(np.argmax(best_ic_values))
ras_adjusted = ras_ic_adjustment(
observed_ic=best_ic_values,
complexity=r_hat,
n_samples=min_len,
kappa=0.05,
)
print("=== RAS Correction for Parameter Snooping ===\n")
print(f"Best uncorrected IC: {best_ic_values[best_idx]:.4f}")
print(f"Rademacher complexity (R-hat): {r_hat:.4f}")
print(f"RAS-adjusted IC (lower bound): {ras_adjusted[best_idx]:.4f}")
print(f"Parameters tested: {len(ic_matrix)}")
print(f"Time periods: {min_len}")
# %% [markdown]
# ## 5. Regime-Conditional Performance
#
# A robust signal maintains predictive power across market regimes. We use
# 42-day realized volatility on SPY as a clean, pre-defined conditioning
# variable. Tercile thresholds use expanding-window percentiles to avoid
# lookahead bias.
#
# **Warning**: Avoid conditioning on variables derived from the signal itself.
# %%
spy_prices = prices_wide.select(["timestamp", "SPY"])
spy_vol = spy_prices.with_columns(
(pl.col("SPY").pct_change().rolling_std(42) * np.sqrt(252)).alias("realized_vol")
)
# Expanding-window tercile thresholds
spy_vol = spy_vol.with_columns(
pl.col("realized_vol")
.rolling_quantile(0.33, window_size=100_000, min_samples=63)
.alias("_q33"),
pl.col("realized_vol")
.rolling_quantile(0.67, window_size=100_000, min_samples=63)
.alias("_q67"),
)
spy_vol = spy_vol.with_columns(
pl.when(pl.col("realized_vol") < pl.col("_q33"))
.then(pl.lit("low_vol"))
.when(pl.col("realized_vol") > pl.col("_q67"))
.then(pl.lit("high_vol"))
.otherwise(pl.lit("normal_vol"))
.alias("vol_regime")
).drop(["_q33", "_q67"])
print("Volatility Regime Distribution:")
spy_vol.group_by("vol_regime").len().sort("vol_regime")
# %% [markdown]
# ### Compute Regime-Conditional IC
# Split the IC series by volatility regime to check signal stability.
# %%
def compute_regime_ic(
prices_df: pl.DataFrame,
regime_df: pl.DataFrame,
symbols: list[str],
lookback: int = 126,
forward_horizon: int = 20,
) -> dict[str, dict]:
"""Compute HAC-adjusted IC statistics by regime."""
momentum = prices_df.select(
pl.col("timestamp"),
*[(pl.col(s) / pl.col(s).shift(lookback) - 1).alias(s) for s in symbols],
)
forward_ret = prices_df.select(
pl.col("timestamp"),
*[(pl.col(s).shift(-forward_horizon) / pl.col(s) - 1).alias(s) for s in symbols],
)
mom_long = momentum.unpivot(index="timestamp", variable_name="symbol", value_name="signal")
fwd_long = forward_ret.unpivot(index="timestamp", variable_name="symbol", value_name="fwd_ret")
merged = (
mom_long.join(fwd_long, on=["timestamp", "symbol"], how="inner")
.join(regime_df.select(["timestamp", "vol_regime"]), on="timestamp", how="inner")
.drop_nulls()
)
regime_results = {}
for regime in merged["vol_regime"].unique().to_list():
regime_data = merged.filter(pl.col("vol_regime") == regime)
ics = []
for date in regime_data["timestamp"].unique().sort().to_list():
day_data = regime_data.filter(pl.col("timestamp") == date)
if len(day_data) >= 10:
sig = day_data["signal"].to_numpy()
ret = day_data["fwd_ret"].to_numpy()
valid = np.isfinite(sig) & np.isfinite(ret)
if np.sum(valid) >= 10:
ic = pooled_ic(sig[valid], ret[valid])
if not np.isnan(ic):
ics.append(ic)
if ics:
stats = _ic_stats_with_icir(np.array(ics))
stats["ics"] = np.array(ics)
regime_results[regime] = stats
return regime_results
# %%
if spy_vol is not None:
regime_ic = compute_regime_ic(prices_wide, spy_vol, symbols)
print(f"{'Regime':<15} {'Mean IC':>10} {'ICIR':>8} {'HAC t':>10} {'p-value':>10}")
print("-" * 60)
for regime in ["low_vol", "normal_vol", "high_vol"]:
if regime in regime_ic:
r = regime_ic[regime]
sig = "*" if r["p_value"] < 0.05 else ""
print(
f"{regime:<15} {r['mean_ic']:>10.4f} {r['icir']:>8.2f} "
f"{r['t_stat']:>10.2f}{sig} {r['p_value']:>10.4f}"
)
if len(regime_ic) > 1:
ic_values = [r["mean_ic"] for r in regime_ic.values()]
ic_range = max(ic_values) - min(ic_values)
print(f"\nIC range across regimes: {ic_range:.4f}")
if ic_range > 0.04:
print("IC range > 0.04 across regimes — interaction features are warranted")
else:
print("IC range <= 0.04 across regimes — signal does not depend on regime")
# %% [markdown]
# ### Book Figure: Conditional IC Box Plot
#
# Static matplotlib version for print — box plots with fold-level IC
# observations overlaid, showing distribution stability across regimes.
# %%
if spy_vol is not None and regime_ic:
fig_mpl, ax = plt.subplots(figsize=(12, 5))
regime_order = ["low_vol", "normal_vol", "high_vol"]
regime_labels = [
"Low Vol\n(bottom tercile)",
"Mid Vol\n(middle tercile)",
"High Vol\n(top tercile)",
]
grays = ["0.85", "0.60", "0.35"]
ic_data = [regime_ic[r]["ics"] for r in regime_order if r in regime_ic]
positions = list(range(1, len(ic_data) + 1))
bp = ax.boxplot(
ic_data,
positions=positions,
widths=0.5,
patch_artist=True,
medianprops=dict(color="black", linewidth=1.5),
whiskerprops=dict(color="0.4"),
capprops=dict(color="0.4"),
flierprops=dict(marker=".", markersize=3, markerfacecolor="0.5"),
)
for patch, gray in zip(bp["boxes"], grays, strict=False):
patch.set_facecolor(gray)
patch.set_edgecolor("black")
patch.set_linewidth(0.8)
for i, (ics, pos) in enumerate(zip(ic_data, positions, strict=False)):
jitter = np.random.default_rng(SEED).uniform(-0.12, 0.12, size=len(ics))
ax.scatter(pos + jitter, ics, s=8, alpha=0.3, color="black", zorder=3)
# Annotate and finalize the regime IC figure (must be in the same cell as
# the plot to avoid the matplotlib-inline backend auto-displaying the
# un-annotated figure on cell exit).
for i, regime in enumerate(regime_order):
if regime in regime_ic:
r = regime_ic[regime]
ax.text(
i + 1,
ax.get_ylim()[1] * 0.92,
f"IC = {r['mean_ic']:+.3f}\n(t = {r['t_stat']:.1f})",
ha="center",
va="top",
fontsize=8,
)
ax.axhline(y=0, ls="--", color="black", lw=0.7)
ax.set_xticks(positions)
ax.set_xticklabels(regime_labels)
ax.set_ylabel("Information Coefficient (rank IC)")
ax.set_title("Momentum IC by Volatility Regime")
ax.text(
0.5,
-0.12,
"126d momentum, 20d fwd returns, 42d vol window",
transform=ax.transAxes,
ha="center",
fontsize=8,
style="italic",
color="0.4",
)
plt.show()
# %% [markdown]
# ## 6. Signal × State Interaction Features
#
# The text (§8.6) describes three interaction templates. We demonstrate all
# three using momentum (signal) and realized volatility (state).
#
# | Template | Construction | What changes |
# |---|---|---|
# | **Gating** | Zero signal in high-vol regime | Active sample (turnover, capacity) |
# | **Scaling** | Divide signal by volatility | Position size |
# | **Conditional** | IC computed within each regime | Separate testable hypotheses |
# %%
if spy_vol is not None and len(spy_vol) > 252:
interact_df = (
spy_vol.join(
prices_wide.select(["timestamp", "SPY"]),
on="timestamp",
how="inner",
suffix="_price",
)
.sort("timestamp")
.with_columns(
(pl.col("SPY") / pl.col("SPY").shift(63) - 1).alias("momentum"),
(pl.col("SPY").shift(-5) / pl.col("SPY") - 1).alias("fwd_return_5d"),
)
.drop_nulls(["momentum", "fwd_return_5d", "realized_vol"])
)
# Gating: zero momentum in high-vol regime
interact_df = interact_df.with_columns(
(pl.col("momentum") * (pl.col("vol_regime") != pl.lit("high_vol")).cast(pl.Float64)).alias(
"momentum_gated"
)
)
# Scaling: risk-adjusted momentum
interact_df = interact_df.with_columns(
(pl.col("momentum") / pl.col("realized_vol").clip(0.01, None)).alias("momentum_scaled")
)
# Compare IC: raw vs gated vs scaled
print("Signal × State Interaction Templates:\n")
for col in ["momentum", "momentum_gated", "momentum_scaled"]:
valid = interact_df.drop_nulls([col, "fwd_return_5d"])
if len(valid) > 100:
corr = valid.select(pl.corr(col, "fwd_return_5d", method="spearman")).item()
print(f" {col:25s}: IC = {corr:+.4f}")
# Conditional IC by regime
print("\nConditional IC by Regime:")
for regime in ["low_vol", "normal_vol", "high_vol"]:
subset = interact_df.filter(pl.col("vol_regime") == regime)
if len(subset) > 50:
ic = subset.select(pl.corr("momentum", "fwd_return_5d", method="spearman")).item()
print(f" {regime:15s}: IC = {ic:+.4f} (n={len(subset):,})")
# %% [markdown]
# **Interpretation**: Gating suppresses the signal during high-volatility
# episodes where momentum historically underperforms. Scaling normalizes by
# recent volatility, producing a risk-adjusted signal. The conditional IC
# reveals whether the signal works differently across regimes.
# %%
# Rolling IC comparison: raw vs gated
if spy_vol is not None and len(interact_df) > 252:
ROLL_IC_WINDOW = 126
ic_raw, ic_gated = [], []
for i in range(ROLL_IC_WINDOW, len(interact_df)):
window = interact_df.slice(i - ROLL_IC_WINDOW, ROLL_IC_WINDOW)
valid = window.drop_nulls(["momentum", "momentum_gated", "fwd_return_5d"])
if len(valid) > 30:
ic_r = valid.select(pl.corr("momentum", "fwd_return_5d", method="spearman")).item()
ic_g = valid.select(
pl.corr("momentum_gated", "fwd_return_5d", method="spearman")
).item()
ic_raw.append(ic_r)
ic_gated.append(ic_g)
else:
ic_raw.append(np.nan)
ic_gated.append(np.nan)
fig = go.Figure()
fig.add_trace(
go.Scatter(
y=ic_raw, mode="lines", name="Raw Momentum", line=dict(color="steelblue", width=1.5)
)
)
fig.add_trace(
go.Scatter(
y=ic_gated,
mode="lines",
name="Gated Momentum",
line=dict(color="darkorange", width=1.5),
)
)
fig.add_hline(y=0, line_dash="dash", line_color="gray")
fig.update_layout(
title="Rolling 126-Day IC: Raw vs Gated Momentum",
xaxis_title="Trading Days",
yaxis_title="Spearman IC",
height=400,
)
fig.show()
# %% [markdown]
# The gated signal avoids the worst IC drawdowns during high-volatility
# episodes, at the cost of fewer active days (reduced breadth). Whether
# gating improves net performance depends on the IC gain vs breadth loss —
# a question for the modeling chapters (Ch1112).
# %% [markdown]
# ## 7. Implementation Variants
#
# Different implementation choices are hyperparameters. A robust signal
# should not depend critically on one specific choice. We compare five
# momentum variants with the same lookback (63 days).
# %%
def _compute_variant_ic(
signal_df: pl.DataFrame,
fwd_long: pl.DataFrame,
symbols: list[str],
) -> dict:
"""Compute HAC-adjusted IC for a signal variant."""
sig_long = signal_df.unpivot(
index="timestamp", variable_name="symbol", value_name="signal"
).filter(pl.col("signal").is_finite())
merged = sig_long.join(fwd_long, on=["timestamp", "symbol"], how="inner").drop_nulls()
ics = []
for date in merged["timestamp"].unique().sort().to_list():
day_data = merged.filter(pl.col("timestamp") == date)
if len(day_data) >= 10:
sig = day_data["signal"].to_numpy()
ret = day_data["fwd_ret"].to_numpy()
valid = np.isfinite(sig) & np.isfinite(ret)
if np.sum(valid) >= 10:
ic = pooled_ic(sig[valid], ret[valid])
if not np.isnan(ic):
ics.append(ic)
return _ic_stats_with_icir(np.array(ics))
# %%
LOOKBACK = 63
returns = prices_wide.select(
pl.col("timestamp"), *[(pl.col(s) / pl.col(s).shift(1) - 1).alias(s) for s in symbols]
)
forward_ret = prices_wide.select(
pl.col("timestamp"),
*[(pl.col(s).shift(-FORWARD_HORIZON) / pl.col(s) - 1).alias(s) for s in symbols],
)
fwd_long = forward_ret.unpivot(index="timestamp", variable_name="symbol", value_name="fwd_ret")
impl_results = {}
# %%
# Simple price momentum
mom1 = prices_wide.select(
pl.col("timestamp"),
*[(pl.col(s) / pl.col(s).shift(LOOKBACK) - 1).alias(s) for s in symbols],
)
impl_results["Simple"] = _compute_variant_ic(mom1, fwd_long, symbols)
# Risk-adjusted (divided by rolling vol)
vol = returns.select(
pl.col("timestamp"), *[pl.col(s).rolling_std(LOOKBACK).alias(s) for s in symbols]
)
mom2 = mom1.join(vol, on="timestamp", suffix="_vol")
for s in symbols:
mom2 = mom2.with_columns((pl.col(s) / pl.col(f"{s}_vol").clip(lower_bound=1e-6)).alias(s))
mom2 = mom2.select(["timestamp"] + symbols)
impl_results["Risk-Adjusted"] = _compute_variant_ic(mom2, fwd_long, symbols)
# Skip-month (skip most recent 21 days)
mom3 = prices_wide.select(
pl.col("timestamp"),
*[(pl.col(s).shift(21) / pl.col(s).shift(LOOKBACK) - 1).alias(s) for s in symbols],
)
impl_results["Skip-1M"] = _compute_variant_ic(mom3, fwd_long, symbols)
# Log returns
mom4 = prices_wide.select(
pl.col("timestamp"),
*[(pl.col(s) / pl.col(s).shift(LOOKBACK)).log().alias(s) for s in symbols],
)
impl_results["Log"] = _compute_variant_ic(mom4, fwd_long, symbols)
# EMA-smoothed
mom5_raw = prices_wide.select(
pl.col("timestamp"),
*[(pl.col(s) / pl.col(s).shift(LOOKBACK) - 1).alias(s) for s in symbols],
)
mom5 = mom5_raw.select(pl.col("timestamp"), *[pl.col(s).ewm_mean(span=5).alias(s) for s in symbols])
impl_results["EMA-5"] = _compute_variant_ic(mom5, fwd_long, symbols)
# %%
print(f"{'Variant':<18} {'Mean IC':>10} {'ICIR':>8} {'HAC t':>10}")
print("-" * 50)
for name, r in sorted(impl_results.items(), key=lambda x: -x[1]["icir"]):
sig = "*" if r["p_value"] < 0.05 else ""
print(f"{name:<18} {r['mean_ic']:>10.4f} {r['icir']:>8.2f} {r['t_stat']:>10.2f}{sig}")
icir_range = max(r["icir"] for r in impl_results.values()) - min(
r["icir"] for r in impl_results.values()
)
print(f"\nICIR range: {icir_range:.2f}")
print(
f"ICIR range across implementations: {icir_range:.2f} (threshold for cross-implementation stability: 0.3)"
)
# %% [markdown]
# ## 8. Robustness Summary
# %%
print("\n" + "=" * 50)
print("ROBUSTNESS REPORT")
print("=" * 50)
print("\n1. PARAMETER ROBUSTNESS")
if sweep_results and robustness:
rr = robustness["robust_range"]
print(f" Best: {robustness['best_param']}d (ICIR = {robustness['best_value']:.2f})")
print(f" Robust range: {rr[0]}-{rr[1]}d" if rr else " Robust range: None")
print(f" Fraction near-optimal: {robustness['robust_fraction']:.0%}")
print("\n2. REGIME ROBUSTNESS")
if spy_vol is not None and regime_ic:
ic_vals = [r["mean_ic"] for r in regime_ic.values()]
print(f" IC range across regimes: {max(ic_vals) - min(ic_vals):.4f}")
print("\n3. IMPLEMENTATION ROBUSTNESS")
if impl_results:
print(f" ICIR range: {icir_range:.2f}")
best = max(impl_results.items(), key=lambda x: x[1]["icir"])
print(f" Best variant: {best[0]}")
print("=" * 50)
# %% [markdown]
# ## Key Takeaways
#
# 1. **Robustness is breadth, not a ratio**: The fraction of parameters
# within 90% of best performance, not mean/std
# 2. **One knob at a time**: Vary lookback while holding everything else
# constant; combine best single-knob settings afterward
# 3. **Regime conditioning requires care**: Use pre-defined conditioning
# variables (not derived from the signal); only condition when the IC
# range across regimes exceeds noise (>0.04)
# 4. **Correct for snooping**: After sweeping N parameters, apply RAS to
# deflate the best IC for data-mining bias
# 5. **Interactions multiply search**: Signal × state combinations must
# enter the searched-set accounting from §7.4
#
# **Next**: `07_event_studies` — event-based signal validation