stefan-jansen--machine-learning-for-trading
813 行
28 KiB
Python
813 行
28 KiB
Python
# ---
|
||
# jupyter:
|
||
# jupytext:
|
||
# text_representation:
|
||
# extension: .py
|
||
# format_name: percent
|
||
# format_version: '1.3'
|
||
# jupytext_version: 1.19.1
|
||
# kernelspec:
|
||
# display_name: Python 3
|
||
# language: python
|
||
# name: python3
|
||
# ---
|
||
|
||
# %% [markdown]
|
||
# # Feature Evaluation — ETFs
|
||
#
|
||
# Consolidated evaluation of Ch8 financial features and Ch9 temporal features
|
||
# against forward return labels. Produces triage decisions for Ch11 modeling.
|
||
#
|
||
# **Learning Objectives**:
|
||
# - Evaluate individual feature predictive power via Information Coefficient (IC)
|
||
# - Apply HAC adjustment for overlapping-return autocorrelation
|
||
# - Control false discovery rate with Benjamini-Hochberg correction
|
||
# - Assess feature redundancy and family-level signal concentration
|
||
# - Produce a triage ledger for downstream model selection
|
||
#
|
||
# **Book Reference**: Chapter 8, Section 8.5 (Feature Evaluation)
|
||
#
|
||
# **Prerequisites**: `03_financial_features.py` and `04_temporal.py` must have run
|
||
# (produces `features/financial.parquet` and `features/model_based.parquet`).
|
||
|
||
# %%
|
||
"""Feature Evaluation - ETFs case study."""
|
||
|
||
import warnings
|
||
from pathlib import Path
|
||
|
||
warnings.filterwarnings("ignore")
|
||
|
||
import numpy as np
|
||
import plotly.graph_objects as go
|
||
import polars as pl
|
||
import yaml
|
||
from ml4t.diagnostic.evaluation.stats import benjamini_hochberg_fdr
|
||
from ml4t.diagnostic.metrics import compute_ic_hac_stats, cross_sectional_ic_series
|
||
from plotly.subplots import make_subplots
|
||
from scipy.stats import spearmanr
|
||
|
||
from utils.paths import get_case_study_dir
|
||
|
||
# %% tags=["parameters"]
|
||
# Production defaults
|
||
MAX_SYMBOLS = 0
|
||
|
||
# %%
|
||
CASE_STUDY_ID = "etfs"
|
||
CASE_DIR = get_case_study_dir(CASE_STUDY_ID)
|
||
EVAL_DIR = CASE_DIR / "evaluation"
|
||
EVAL_DIR.mkdir(exist_ok=True)
|
||
|
||
# ETFs config
|
||
PRIMARY_LABEL_FILE = "fwd_ret_21d.parquet"
|
||
HAC_MAXLAGS = 21 # 21-day forward return
|
||
MIN_PERIODS = 10 # 99 symbols
|
||
IC_THRESHOLD = 0.01 # Monthly horizon
|
||
|
||
# %% [markdown]
|
||
# ## 0. Load Artifacts & Build Evaluation Panel
|
||
#
|
||
# We load the pre-computed feature matrices and labels, then join into a single
|
||
# evaluation panel. Temporal features include both date-level (HMM regime, FFD)
|
||
# and per-symbol (GARCH) features, so we join on `[date, symbol]`.
|
||
|
||
# %%
|
||
# Load features (normalize date column type to pl.Date for consistent joins)
|
||
features = pl.read_parquet(CASE_DIR / "features" / "financial.parquet").with_columns(
|
||
pl.col("timestamp").cast(pl.Date)
|
||
)
|
||
temporal = pl.read_parquet(CASE_DIR / "features" / "model_based.parquet").with_columns(
|
||
pl.col("timestamp").cast(pl.Date)
|
||
)
|
||
# model_based.parquet now carries one row per (timestamp, symbol, fold);
|
||
# drop fold and de-dup so the join doesn't multiply the eval panel by fold count.
|
||
if "fold" in temporal.columns:
|
||
temporal = temporal.drop("fold").unique(subset=["timestamp", "symbol"], keep="last")
|
||
|
||
# Load primary label
|
||
label_df = pl.read_parquet(CASE_DIR / "labels" / PRIMARY_LABEL_FILE).with_columns(
|
||
pl.col("timestamp").cast(pl.Date)
|
||
)
|
||
label_col = [c for c in label_df.columns if c not in ("timestamp", "symbol")][0]
|
||
|
||
# Load evaluation config from setup.yaml
|
||
with open(CASE_DIR / "config" / "setup.yaml") as f:
|
||
setup_config = yaml.safe_load(f)
|
||
eval_config = setup_config.get("evaluation", {})
|
||
|
||
print(f"Features: {features.shape}")
|
||
print(f"Temporal: {temporal.shape}")
|
||
print(f"Labels: {label_df.shape}, column: {label_col}")
|
||
|
||
# %%
|
||
# Identify feature columns by source
|
||
JOIN_COLS = ["timestamp", "symbol"]
|
||
DATE_COL = "timestamp"
|
||
|
||
financial_cols = [c for c in features.columns if c not in JOIN_COLS]
|
||
temporal_cols = [c for c in temporal.columns if c not in ("timestamp", "symbol")]
|
||
|
||
# Join: features + temporal (on [date, symbol]) + labels
|
||
eval_panel = features.join(temporal, on=JOIN_COLS, how="left")
|
||
eval_panel = eval_panel.join(label_df, on=JOIN_COLS, how="inner")
|
||
|
||
all_feature_cols = financial_cols + temporal_cols
|
||
|
||
if MAX_SYMBOLS > 0:
|
||
top = eval_panel.group_by("symbol").len().sort("len", descending=True).head(MAX_SYMBOLS)
|
||
eval_panel = eval_panel.filter(pl.col("symbol").is_in(top["symbol"]))
|
||
|
||
n_rows = len(eval_panel)
|
||
n_symbols = eval_panel["symbol"].n_unique()
|
||
n_dates = eval_panel[DATE_COL].n_unique()
|
||
print(f"\nEval panel: {n_rows:,} rows, {n_symbols} symbols, {n_dates:,} dates")
|
||
print(
|
||
f"Features: {len(financial_cols)} financial + {len(temporal_cols)} temporal"
|
||
f" = {len(all_feature_cols)} total"
|
||
)
|
||
print(f"Label: {label_col}")
|
||
|
||
# %% [markdown]
|
||
# ## 0.5 Data Quality Gate
|
||
#
|
||
# Verify upstream artifacts are free of critical defects (negative prices,
|
||
# infinities, extreme returns) before any statistical evaluation.
|
||
|
||
# %%
|
||
from utils.data_quality import validate_modeling_inputs
|
||
|
||
validate_modeling_inputs(
|
||
features_df=eval_panel,
|
||
label_df=eval_panel,
|
||
feature_cols=all_feature_cols,
|
||
label_col=label_col,
|
||
join_cols=JOIN_COLS,
|
||
asset_col="symbol",
|
||
max_abs_return=1.0, # 21-day ETF returns (max observed ~0.71)
|
||
fail_on_critical=True,
|
||
)
|
||
|
||
# %% [markdown]
|
||
# ## 1. Correctness Screens
|
||
#
|
||
# Before evaluating predictive power, we check data quality:
|
||
# - **Coverage**: fraction of non-null values (threshold: 70%)
|
||
# - **Staleness**: fraction of unchanged values from prior date (threshold: 50%)
|
||
#
|
||
# Features that fail either gate are marked STOP in the triage.
|
||
|
||
# %%
|
||
coverage = {}
|
||
staleness = {}
|
||
|
||
for feat in all_feature_cols:
|
||
col = eval_panel[feat]
|
||
coverage[feat] = col.drop_nulls().len() / n_rows
|
||
|
||
# Staleness: fraction unchanged from prior row within same symbol
|
||
unchanged = (
|
||
eval_panel.sort(JOIN_COLS)
|
||
.select((pl.col(feat) == pl.col(feat).shift(1).over("symbol")).alias("same"))["same"]
|
||
.sum()
|
||
)
|
||
staleness[feat] = float(unchanged) / max(n_rows - n_symbols, 1)
|
||
|
||
correctness = {
|
||
feat: coverage[feat] >= 0.70 and staleness[feat] <= 0.50 for feat in all_feature_cols
|
||
}
|
||
n_pass = sum(correctness.values())
|
||
n_fail = len(correctness) - n_pass
|
||
print(f"Correctness gate: {n_pass} PASS, {n_fail} FAIL")
|
||
|
||
if n_fail > 0:
|
||
fail_df = pl.DataFrame(
|
||
{
|
||
"feature": [f for f, ok in correctness.items() if not ok],
|
||
"coverage": [round(coverage[f], 3) for f, ok in correctness.items() if not ok],
|
||
"staleness": [round(staleness[f], 3) for f, ok in correctness.items() if not ok],
|
||
}
|
||
)
|
||
print(fail_df)
|
||
|
||
# %% [markdown]
|
||
# ### Effective Sample Size
|
||
#
|
||
# With daily observations and 21-day forward return labels, consecutive labels
|
||
# overlap by 20 days. This autocorrelation reduces the effective number of
|
||
# independent observations:
|
||
#
|
||
# $$N_{\text{eff}} \approx \frac{N_{\text{dates}}}{h} \times N_{\text{symbols}}$$
|
||
#
|
||
# where $h = 21$ is the label horizon. HAC standard errors (below) account for
|
||
# this, but raw row counts overstate statistical power.
|
||
|
||
# %%
|
||
n_eff_dates = n_dates // HAC_MAXLAGS
|
||
n_eff = n_eff_dates * n_symbols
|
||
print(f"Raw panel: {n_rows:,} rows ({n_dates:,} dates × {n_symbols} symbols)")
|
||
print(
|
||
f"Effective sample size: ~{n_eff:,} ({n_eff_dates:,} independent date blocks × {n_symbols} symbols)"
|
||
)
|
||
print(f"Overlap reduction factor: {n_dates / n_eff_dates:.0f}x")
|
||
|
||
# %% [markdown]
|
||
# ## 2. Univariate Association (IC + HAC)
|
||
#
|
||
# For each feature that passes correctness, we compute:
|
||
# - **IC time series**: daily cross-sectional Spearman rank correlation with the label
|
||
# - **HAC-adjusted t-statistics**: Newey-West standard errors with bandwidth = 21
|
||
# (matching the 21-day label horizon) to account for overlapping-return autocorrelation
|
||
#
|
||
# Date-level features (identical across all symbols on a given date) produce zero
|
||
# cross-sectional IC by construction. We detect and flag these separately.
|
||
|
||
# %%
|
||
evaluable_features = [f for f in all_feature_cols if correctness[f]]
|
||
|
||
# Detect date-level features (zero cross-sectional variance)
|
||
# Compute std per date for all features at once, then check which have ~zero mean std
|
||
cs_std_df = eval_panel.group_by(DATE_COL).agg(
|
||
[pl.col(f).std().alias(f) for f in evaluable_features]
|
||
)
|
||
date_level_features = set()
|
||
for feat in evaluable_features:
|
||
mean_std = cs_std_df[feat].drop_nulls().mean()
|
||
if mean_std is not None and mean_std < 1e-10:
|
||
date_level_features.add(feat)
|
||
|
||
if date_level_features:
|
||
print(f"Date-level features (zero CS variance): {sorted(date_level_features)}")
|
||
|
||
# %%
|
||
# Batch IC computation: one pass over dates, all features at once
|
||
# This is much faster than calling cross_sectional_ic_series per feature
|
||
cs_features = [f for f in evaluable_features if f not in date_level_features]
|
||
cols_needed = [DATE_COL] + cs_features + [label_col]
|
||
eval_sub = eval_panel.select(cols_needed).drop_nulls(subset=[label_col])
|
||
|
||
# Group by date and compute Spearman IC for all features per date
|
||
dates_list = eval_sub[DATE_COL].unique().sort().to_list()
|
||
n_total = len(dates_list)
|
||
|
||
# Pre-allocate: dict of feature -> list of (date, ic, n_obs)
|
||
ic_series_data = {feat: [] for feat in cs_features}
|
||
|
||
for i, dt in enumerate(dates_list):
|
||
cross_section = eval_sub.filter(pl.col(DATE_COL) == dt)
|
||
n_obs = len(cross_section)
|
||
if n_obs < MIN_PERIODS:
|
||
continue
|
||
|
||
label_arr = cross_section[label_col].to_numpy()
|
||
label_valid = ~np.isnan(label_arr)
|
||
|
||
for feat in cs_features:
|
||
feat_arr = cross_section[feat].to_numpy()
|
||
valid_mask = label_valid & ~np.isnan(feat_arr)
|
||
n_valid = int(valid_mask.sum())
|
||
if n_valid >= MIN_PERIODS:
|
||
ic_val, _ = spearmanr(feat_arr[valid_mask], label_arr[valid_mask])
|
||
if not np.isnan(ic_val):
|
||
ic_series_data[feat].append((dt, float(ic_val), n_valid))
|
||
|
||
if (i + 1) % 1000 == 0:
|
||
print(f" IC progress: {i + 1}/{n_total} dates")
|
||
|
||
print(f" IC progress: {n_total}/{n_total} dates (done)")
|
||
|
||
# %%
|
||
# Convert to DataFrames and compute HAC stats
|
||
ic_results = {}
|
||
ic_timeseries = {}
|
||
for feat in cs_features:
|
||
data = ic_series_data[feat]
|
||
if len(data) < 20:
|
||
continue
|
||
dates_f, ics_f, nobs_f = zip(*data, strict=False)
|
||
ic_df = pl.DataFrame({DATE_COL: list(dates_f), "ic": list(ics_f), "n_obs": list(nobs_f)})
|
||
hac_stats = compute_ic_hac_stats(ic_df, ic_col="ic", maxlags=HAC_MAXLAGS)
|
||
ic_results[feat] = hac_stats
|
||
ic_timeseries[feat] = ic_df
|
||
|
||
print(f"IC computed for {len(ic_results)} cross-sectional features")
|
||
print(f"Skipped {len(date_level_features)} date-level features")
|
||
|
||
# %% [markdown]
|
||
# ### Fold-Level Stability
|
||
#
|
||
# We divide the evaluation period into approximate annual folds (matching the
|
||
# CV config's 1-year test windows) and check whether IC sign is consistent
|
||
# across folds. Features with > 60% positive-IC folds are more robust.
|
||
|
||
# %%
|
||
# Generate approximate fold boundaries
|
||
all_dates = eval_panel[DATE_COL].unique().sort().to_list()
|
||
n_folds = eval_config.get("n_splits", 8)
|
||
fold_size = len(all_dates) // n_folds
|
||
|
||
fold_boundaries = []
|
||
for i in range(n_folds):
|
||
start_idx = i * fold_size
|
||
end_idx = min((i + 1) * fold_size - 1, len(all_dates) - 1)
|
||
fold_boundaries.append((all_dates[start_idx], all_dates[end_idx]))
|
||
|
||
fold_stats = {}
|
||
for feat in ic_results:
|
||
fold_ics = []
|
||
ts = ic_timeseries[feat]
|
||
for fold_start, fold_end in fold_boundaries:
|
||
fold_ic = ts.filter((pl.col(DATE_COL) >= fold_start) & (pl.col(DATE_COL) <= fold_end))
|
||
if len(fold_ic) >= 5:
|
||
fold_ics.append(float(fold_ic["ic"].mean()))
|
||
|
||
if fold_ics:
|
||
sign_consistency = sum(1 for ic in fold_ics if ic > 0) / len(fold_ics)
|
||
fold_stats[feat] = {
|
||
"n_folds": len(fold_ics),
|
||
"sign_consistency": sign_consistency,
|
||
"worst_fold_ic": min(fold_ics),
|
||
"best_fold_ic": max(fold_ics),
|
||
"median_fold_ic": float(np.median(fold_ics)),
|
||
}
|
||
|
||
print(f"Fold stability computed for {len(fold_stats)} features")
|
||
|
||
# %% [markdown]
|
||
# ## 3. Multiple Testing (BH-FDR)
|
||
#
|
||
# Testing 50+ features simultaneously at $\alpha = 0.05$ expects ~2.5 false
|
||
# positives. We apply the Benjamini-Hochberg procedure to control the false
|
||
# discovery rate. The **inflation factor** measures how much naive significance
|
||
# overstates true significance:
|
||
#
|
||
# $$\text{Inflation} = \frac{N_{\text{naive significant}}}{N_{\text{FDR significant}}}$$
|
||
|
||
# %%
|
||
feature_names = list(ic_results.keys())
|
||
p_values = [ic_results[f]["p_value"] for f in feature_names]
|
||
|
||
fdr_result = benjamini_hochberg_fdr(p_values, alpha=0.05, return_details=True)
|
||
|
||
eval_summary = pl.DataFrame(
|
||
{
|
||
"feature": feature_names,
|
||
"source": ["temporal" if f in temporal_cols else "financial" for f in feature_names],
|
||
"ic_mean": [ic_results[f]["mean_ic"] for f in feature_names],
|
||
"hac_se": [ic_results[f]["hac_se"] for f in feature_names],
|
||
"hac_t": [ic_results[f]["t_stat"] for f in feature_names],
|
||
"hac_p": p_values,
|
||
"fdr_p": [float(p) for p in fdr_result["adjusted_p_values"]],
|
||
"fdr_sig": [bool(r) for r in fdr_result["rejected"]],
|
||
"naive_t": [ic_results[f]["naive_t_stat"] for f in feature_names],
|
||
}
|
||
).sort(pl.col("ic_mean").cast(pl.Float64, strict=False).abs(), descending=True)
|
||
|
||
n_significant_naive = sum(1 for p in p_values if p < 0.05)
|
||
n_significant_hac = sum(1 for f in feature_names if abs(ic_results[f]["t_stat"]) > 1.96)
|
||
n_significant_fdr = int(fdr_result["n_rejected"])
|
||
|
||
inflation_hac = n_significant_naive / max(n_significant_hac, 1)
|
||
inflation_fdr = n_significant_naive / max(n_significant_fdr, 1)
|
||
|
||
print(f"Features tested: {len(feature_names)}")
|
||
print(f"Naive significant (p < 0.05): {n_significant_naive}")
|
||
print(f"HAC significant (|t| > 1.96): {n_significant_hac}")
|
||
print(f"FDR significant (q < 0.05): {n_significant_fdr}")
|
||
print(f"Inflation factor (HAC): {inflation_hac:.2f}x")
|
||
print(f"Inflation factor (FDR): {inflation_fdr:.2f}x")
|
||
|
||
# %%
|
||
# Top features table
|
||
print("\nTop 15 features by absolute IC:")
|
||
print(eval_summary.head(15))
|
||
|
||
# %%
|
||
top_n = min(25, len(eval_summary))
|
||
top = eval_summary.head(top_n)
|
||
|
||
fig = make_subplots(
|
||
rows=1,
|
||
cols=2,
|
||
subplot_titles=[
|
||
f"Top {top_n} Features by |IC| (green = FDR-sig)",
|
||
"HAC vs Naive t-statistics",
|
||
],
|
||
horizontal_spacing=0.12,
|
||
)
|
||
|
||
# Panel 1: IC bar chart
|
||
colors = ["#2ecc71" if s else "#95a5a6" for s in top["fdr_sig"].to_list()]
|
||
fig.add_trace(
|
||
go.Bar(
|
||
x=top["feature"].to_list(),
|
||
y=top["ic_mean"].to_list(),
|
||
marker_color=colors,
|
||
text=[f"t={t:.1f}" for t in top["hac_t"].to_list()],
|
||
textposition="outside",
|
||
showlegend=False,
|
||
),
|
||
row=1,
|
||
col=1,
|
||
)
|
||
|
||
# Panel 2: HAC scatter
|
||
fig.add_trace(
|
||
go.Scatter(
|
||
x=eval_summary["naive_t"].to_list(),
|
||
y=eval_summary["hac_t"].to_list(),
|
||
mode="markers",
|
||
marker=dict(
|
||
color=["#2ecc71" if s else "#e74c3c" for s in eval_summary["fdr_sig"].to_list()],
|
||
size=7,
|
||
),
|
||
text=eval_summary["feature"].to_list(),
|
||
showlegend=False,
|
||
),
|
||
row=1,
|
||
col=2,
|
||
)
|
||
max_t = (
|
||
max(
|
||
eval_summary["naive_t"].cast(pl.Float64, strict=False).abs().max() or 1.0,
|
||
eval_summary["hac_t"].cast(pl.Float64, strict=False).abs().max() or 1.0,
|
||
)
|
||
* 1.1
|
||
)
|
||
fig.add_trace(
|
||
go.Scatter(
|
||
x=[-max_t, max_t],
|
||
y=[-max_t, max_t],
|
||
mode="lines",
|
||
line=dict(dash="dash", color="gray"),
|
||
showlegend=False,
|
||
),
|
||
row=1,
|
||
col=2,
|
||
)
|
||
|
||
fig.update_layout(template="plotly_white", height=450, width=1100)
|
||
fig.update_xaxes(tickangle=-45, row=1, col=1)
|
||
fig.update_xaxes(title_text="Naive t", row=1, col=2)
|
||
fig.update_yaxes(title_text="HAC t", row=1, col=2)
|
||
fig.show()
|
||
|
||
# %% [markdown]
|
||
# **Interpretation**: Points below the diagonal in the right panel show where
|
||
# HAC adjustment deflates naive t-statistics. With overlapping 21-day returns,
|
||
# daily IC values are autocorrelated and naive standard errors understate
|
||
# uncertainty. The green markers survive both HAC and FDR correction — these
|
||
# features have genuine standalone predictive power.
|
||
|
||
# %% [markdown]
|
||
# ## 4. Shape Diagnostics
|
||
#
|
||
# Quantile monotonicity analysis: does the label spread monotonically across
|
||
# feature quintiles? A monotone relationship (Q1 < Q2 < ... < Q5 or reverse)
|
||
# suggests a robust, exploitable signal. Non-monotone shapes may indicate
|
||
# non-linear interactions or noise.
|
||
|
||
# %%
|
||
N_QUANTILES = 5
|
||
top_features_for_shape = eval_summary.filter(pl.col("fdr_sig").fill_null(False))[
|
||
"feature"
|
||
].to_list()[:15]
|
||
if not top_features_for_shape:
|
||
top_features_for_shape = eval_summary.head(10)["feature"].to_list()
|
||
|
||
monotonicity_scores = {}
|
||
quantile_spreads = {}
|
||
|
||
for feat in top_features_for_shape:
|
||
valid = eval_panel.select([feat, label_col]).drop_nulls()
|
||
if len(valid) < N_QUANTILES * 20:
|
||
continue
|
||
|
||
valid = valid.with_columns(
|
||
pl.col(feat)
|
||
.qcut(N_QUANTILES, labels=[f"Q{i + 1}" for i in range(N_QUANTILES)])
|
||
.alias("quantile")
|
||
)
|
||
q_means = valid.group_by("quantile").agg(pl.col(label_col).mean()).sort("quantile")
|
||
means = q_means[label_col].to_list()
|
||
spread = means[-1] - means[0]
|
||
quantile_spreads[feat] = {"q_means": means, "spread": spread}
|
||
|
||
mono_corr, _ = spearmanr(range(len(means)), means)
|
||
monotonicity_scores[feat] = float(mono_corr)
|
||
|
||
print(f"Shape analysis for {len(quantile_spreads)} features")
|
||
|
||
# %%
|
||
if quantile_spreads:
|
||
n_show = min(6, len(quantile_spreads))
|
||
feats_to_show = list(quantile_spreads.keys())[:n_show]
|
||
n_rows_fig = (n_show + 2) // 3
|
||
fig = make_subplots(rows=n_rows_fig, cols=3, subplot_titles=feats_to_show)
|
||
for idx, feat in enumerate(feats_to_show):
|
||
r, c = divmod(idx, 3)
|
||
q_means = quantile_spreads[feat]["q_means"]
|
||
mono = monotonicity_scores.get(feat, 0)
|
||
fig.add_trace(
|
||
go.Bar(
|
||
x=[f"Q{i + 1}" for i in range(len(q_means))],
|
||
y=q_means,
|
||
marker_color=[
|
||
"#e74c3c",
|
||
"#f39c12",
|
||
"#95a5a6",
|
||
"#3498db",
|
||
"#2ecc71",
|
||
],
|
||
showlegend=False,
|
||
text=[f"{m:.4f}" for m in q_means],
|
||
textposition="outside",
|
||
),
|
||
row=r + 1,
|
||
col=c + 1,
|
||
)
|
||
fig.update_layout(
|
||
template="plotly_white",
|
||
height=250 * n_rows_fig,
|
||
width=900,
|
||
title_text="Quantile Mean Returns (Top FDR-Significant Features)",
|
||
)
|
||
fig.show()
|
||
|
||
# %% [markdown]
|
||
# **Interpretation**: Monotone quintile spreads (Q1 → Q5 increasing or
|
||
# decreasing) confirm a robust relationship. Non-monotone patterns (e.g.,
|
||
# U-shaped) suggest non-linear effects that may require interaction terms or
|
||
# tree-based models to capture.
|
||
|
||
# %% [markdown]
|
||
# ## 5. Redundancy & Feature Families
|
||
#
|
||
# We group features into interpretive families and compute family-level IC
|
||
# aggregates. Highly correlated feature pairs (|corr| > 0.7) flag redundancy
|
||
# that downstream modeling should address via clustering or selection.
|
||
|
||
|
||
# %%
|
||
def assign_feature_family(feature_name: str) -> str:
|
||
"""Map feature name to family based on prefix."""
|
||
FAMILY_MAP = [
|
||
(["sharpe_", "risk_adj"], "risk_adj_momentum"),
|
||
(["skip_recent", "mom_"], "momentum"),
|
||
(["ret_"], "momentum"),
|
||
(["vol_ratio"], "vol_ratio"),
|
||
(["vol_"], "volatility"),
|
||
(["natr", "range_", "max_dd"], "volatility"),
|
||
(["rsi", "macd", "adx", "cci", "stoch", "aroon", "bb_"], "technical"),
|
||
(["sma_", "ema_"], "trend"),
|
||
(["yield_curve", "spy_tlt", "regime", "chop", "hurst"], "regime"),
|
||
(["rank_"], "cross_sectional"),
|
||
(["obv", "turnover", "volume"], "volume"),
|
||
(["pct_positive", "up_ratio"], "consistency"),
|
||
(["dist_"], "distance"),
|
||
(["corr_"], "correlation"),
|
||
]
|
||
for prefixes, family in FAMILY_MAP:
|
||
if any(p in feature_name.lower() for p in prefixes):
|
||
return family
|
||
return "other"
|
||
|
||
|
||
families = {feat: assign_feature_family(feat) for feat in all_feature_cols}
|
||
|
||
# Override temporal features with specific families
|
||
for feat in temporal_cols:
|
||
if "regime" in feat.lower():
|
||
families[feat] = "temporal_regime"
|
||
elif "ffd" in feat.lower():
|
||
families[feat] = "temporal_ffd"
|
||
else:
|
||
families[feat] = "temporal_other"
|
||
|
||
# %%
|
||
# Pairwise correlation on sampled dates
|
||
sample_step = max(1, n_dates // 200)
|
||
sample_dates = eval_panel[DATE_COL].unique().sort().to_list()[::sample_step]
|
||
corr_data = (
|
||
eval_panel.filter(pl.col(DATE_COL).is_in(sample_dates)).select(evaluable_features).to_pandas()
|
||
)
|
||
corr_matrix = corr_data.corr(method="spearman")
|
||
|
||
high_corr_pairs = []
|
||
cols = corr_matrix.columns
|
||
for i in range(len(cols)):
|
||
for j in range(i + 1, len(cols)):
|
||
if abs(corr_matrix.iloc[i, j]) > 0.7:
|
||
high_corr_pairs.append((cols[i], cols[j], float(corr_matrix.iloc[i, j])))
|
||
|
||
print(f"Feature pairs with |corr| > 0.7: {len(high_corr_pairs)}")
|
||
|
||
# %%
|
||
# Family-level IC summary
|
||
family_ic = {}
|
||
fdr_sig_set = set(eval_summary.filter(pl.col("fdr_sig").fill_null(False))["feature"].to_list())
|
||
|
||
for feat in ic_results:
|
||
fam = families.get(feat, "other")
|
||
family_ic.setdefault(fam, []).append(
|
||
{
|
||
"feature": feat,
|
||
"ic": ic_results[feat]["mean_ic"],
|
||
"fdr_sig": feat in fdr_sig_set,
|
||
}
|
||
)
|
||
|
||
family_summary = {}
|
||
for fam, feats in sorted(family_ic.items()):
|
||
ics = [f["ic"] for f in feats if f["ic"] is not None]
|
||
n_sig = sum(1 for f in feats if f["fdr_sig"])
|
||
family_summary[fam] = {
|
||
"n_features": len(feats),
|
||
"avg_abs_ic": float(np.mean([abs(ic) for ic in ics])) if ics else 0.0,
|
||
"avg_ic": float(np.mean(ics)) if ics else 0.0,
|
||
"n_fdr_sig": n_sig,
|
||
}
|
||
|
||
if family_summary:
|
||
fam_df = pl.DataFrame([{"family": fam, **stats} for fam, stats in family_summary.items()]).sort(
|
||
"avg_abs_ic", descending=True
|
||
)
|
||
else:
|
||
fam_df = pl.DataFrame(
|
||
schema={
|
||
"family": pl.Utf8,
|
||
"n_features": pl.Int64,
|
||
"avg_abs_ic": pl.Float64,
|
||
"avg_ic": pl.Float64,
|
||
"n_fdr_sig": pl.Int64,
|
||
}
|
||
)
|
||
print(fam_df)
|
||
|
||
# %%
|
||
fig = go.Figure(
|
||
data=go.Heatmap(
|
||
z=corr_matrix.values,
|
||
x=corr_matrix.columns.tolist(),
|
||
y=corr_matrix.columns.tolist(),
|
||
colorscale="RdBu_r",
|
||
zmid=0,
|
||
zmin=-1,
|
||
zmax=1,
|
||
)
|
||
)
|
||
fig.update_layout(
|
||
title=f"Feature Correlation Matrix ({len(high_corr_pairs)} pairs above 0.7)",
|
||
template="plotly_white",
|
||
height=700,
|
||
width=800,
|
||
)
|
||
fig.show()
|
||
|
||
# %% [markdown]
|
||
# **Interpretation**: The correlation heatmap reveals clusters of highly
|
||
# correlated features — particularly within the momentum (multi-horizon
|
||
# returns) and volatility (multi-horizon realized vol) families. Downstream
|
||
# modeling in Ch11 should use clustering or PCA within families to reduce
|
||
# redundancy, or rely on tree-based models that handle correlated inputs
|
||
# natively.
|
||
|
||
# %% [markdown]
|
||
# ## 6. Triage & Handoff
|
||
#
|
||
# Each feature receives a triage decision:
|
||
#
|
||
# | Decision | Criteria |
|
||
# |----------|----------|
|
||
# | **PROCEED** | FDR-significant at 5%, OR sign consistent across > 60% of folds AND abs(IC) > 0.01 |
|
||
# | **STOP** | Correctness failure (coverage < 70% or staleness > 50%) |
|
||
# | **REVISE** | Everything else — evaluate in multivariate context in Ch11 |
|
||
#
|
||
# Date-level features are triaged as REVISE with a note, since their value
|
||
# lies in regime-conditional interactions rather than standalone cross-sectional IC.
|
||
|
||
# %%
|
||
triage = {}
|
||
for feat in all_feature_cols:
|
||
if not correctness[feat]:
|
||
triage[feat] = ("STOP", "correctness_fail")
|
||
continue
|
||
|
||
if feat in date_level_features:
|
||
triage[feat] = ("REVISE", "date_level_feature")
|
||
continue
|
||
|
||
if feat not in ic_results:
|
||
triage[feat] = ("REVISE", "insufficient_data")
|
||
continue
|
||
|
||
is_fdr_sig = feat in fdr_sig_set
|
||
sign_con = fold_stats.get(feat, {}).get("sign_consistency", 0)
|
||
abs_ic = abs(ic_results[feat]["mean_ic"])
|
||
|
||
if is_fdr_sig:
|
||
triage[feat] = ("PROCEED", "fdr_significant")
|
||
elif sign_con >= 0.60 and abs_ic >= IC_THRESHOLD:
|
||
triage[feat] = ("PROCEED", "stable_and_above_threshold")
|
||
else:
|
||
triage[feat] = ("REVISE", "not_significant_standalone")
|
||
|
||
# %%
|
||
# Build triage ledger
|
||
ledger_rows = []
|
||
for feat in all_feature_cols:
|
||
decision, note = triage[feat]
|
||
row = {
|
||
"feature": feat,
|
||
"family": families.get(feat, "other"),
|
||
"source": "temporal" if feat in temporal_cols else "financial",
|
||
"ic_mean": ic_results.get(feat, {}).get("mean_ic"),
|
||
"hac_t": ic_results.get(feat, {}).get("t_stat"),
|
||
"hac_p": ic_results.get(feat, {}).get("p_value"),
|
||
"fdr_p": None,
|
||
"fdr_sig": False,
|
||
"sign_consistency": fold_stats.get(feat, {}).get("sign_consistency"),
|
||
"worst_fold_ic": fold_stats.get(feat, {}).get("worst_fold_ic"),
|
||
"monotonicity": monotonicity_scores.get(feat),
|
||
"coverage": coverage[feat],
|
||
"staleness": staleness[feat],
|
||
"decision": decision,
|
||
"note": note,
|
||
}
|
||
match = eval_summary.filter(pl.col("feature") == feat)
|
||
if len(match) > 0:
|
||
row["fdr_p"] = float(match["fdr_p"][0])
|
||
row["fdr_sig"] = bool(match["fdr_sig"][0])
|
||
ledger_rows.append(row)
|
||
|
||
triage_ledger = pl.DataFrame(ledger_rows)
|
||
triage_ledger.write_parquet(EVAL_DIR / "triage_ledger.parquet")
|
||
print(f"Triage ledger saved: {EVAL_DIR / 'triage_ledger.parquet'}")
|
||
print(triage_ledger.group_by("decision").len().sort("decision"))
|
||
|
||
# %%
|
||
# Save IC time series (long format)
|
||
ic_ts_frames = []
|
||
for feat, ts in ic_timeseries.items():
|
||
ic_ts_frames.append(ts.with_columns(pl.lit(feat).alias("feature")))
|
||
|
||
if ic_ts_frames:
|
||
ic_ts_all = pl.concat(ic_ts_frames)
|
||
ic_ts_all.write_parquet(EVAL_DIR / "ic_timeseries.parquet")
|
||
print(f"IC time series saved: {EVAL_DIR / 'ic_timeseries.parquet'}")
|
||
|
||
# %%
|
||
# Write results JSON
|
||
proceed_features = sorted(f for f, (d, _) in triage.items() if d == "PROCEED")
|
||
revise_features = [f for f, (d, _) in triage.items() if d == "REVISE"]
|
||
stop_features = [f for f, (d, _) in triage.items() if d == "STOP"]
|
||
|
||
sorted_by_ic = sorted(ic_results.items(), key=lambda x: x[1].get("mean_ic") or 0, reverse=True)
|
||
best = sorted_by_ic[0] if sorted_by_ic else ("n/a", {})
|
||
worst = sorted_by_ic[-1] if sorted_by_ic else ("n/a", {})
|
||
|
||
# %%
|
||
print(f"\n{'=' * 60}")
|
||
print(f"TRIAGE SUMMARY: {CASE_STUDY_ID}")
|
||
print(f"{'=' * 60}")
|
||
print(f" PROCEED: {len(proceed_features)} features")
|
||
print(f" REVISE: {len(revise_features)} features")
|
||
print(f" STOP: {len(stop_features)} features")
|
||
print("\nPROMOTED (PROCEED) features:")
|
||
for f in proceed_features:
|
||
ic = ic_results[f]["mean_ic"]
|
||
t = ic_results[f]["t_stat"]
|
||
print(f" {f:40s} IC={ic:+.4f} t={t:.2f} [{families.get(f, '?')}]")
|
||
|
||
# %% [markdown]
|
||
# ### Quality Gate Verdict
|
||
#
|
||
# **Fit for modeling.** The triage promotes a broad set of features to PROCEED,
|
||
# concentrated in the momentum and risk-adjusted momentum families. IC magnitudes
|
||
# are moderate (0.03--0.04 range), consistent with monthly-horizon cross-sectional
|
||
# equity signals over a 100-ETF universe. Date-level regime and macro features
|
||
# are excluded from cross-sectional IC but carry forward as conditioning variables
|
||
# for tree-based and interaction models in Ch11+.
|
||
|
||
# %% [markdown]
|
||
# ## Key Takeaways
|
||
#
|
||
# 1. **FDR correction is essential**: With 50+ features, naive p-values
|
||
# dramatically overstate the number of true signals. The inflation factor
|
||
# quantifies the gap between naive and corrected significance.
|
||
#
|
||
# 2. **HAC adjustment matters**: Overlapping 21-day returns create
|
||
# autocorrelation in the IC time series. Newey-West standard errors
|
||
# with bandwidth = 21 properly account for this.
|
||
#
|
||
# 3. **Date-level features require special handling**: Regime and macro
|
||
# features have zero cross-sectional IC by construction. Their value
|
||
# emerges through conditional interactions in Ch11 modeling.
|
||
#
|
||
# 4. **Redundancy within families**: Momentum and volatility features are
|
||
# highly correlated. Downstream modeling should cluster or select
|
||
# representative features from each family.
|
||
#
|
||
# **Next**: `05_linear.py` (Ch11) uses the triage ledger and promoted
|
||
# features for ridge/lasso baseline modeling.
|