1513 lines
50 KiB
Python
1513 lines
50 KiB
Python
# ---
|
||
# 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]
|
||
# # Signal Evaluation: IC, Quantiles, and Spreads
|
||
#
|
||
# **Chapter 7: Defining the Learning Task**
|
||
# **Section Reference**: 7.3 - Feature and Label Evaluation as Triage
|
||
#
|
||
# **Docker image**: `ml4t`
|
||
#
|
||
# ## Purpose
|
||
#
|
||
# This notebook demonstrates **single-factor evaluation** using Information Coefficient
|
||
# (IC) analysis, quantile returns, and spread metrics. We answer: "Is this factor
|
||
# predictive in the cross-section, and what horizon does it live on?"
|
||
#
|
||
# ## Learning Objectives
|
||
#
|
||
# 1. Compute cross-sectional IC and understand its time series properties
|
||
# 2. Interpret IC, ICIR, and HAC-adjusted significance
|
||
# 3. Analyze quantile returns, spread, and monotonicity
|
||
# 4. Understand horizon comparison with proper overlap warnings
|
||
# 5. Measure turnover and signal half-life
|
||
#
|
||
# ## Data Policy
|
||
#
|
||
# All examples use **real ETF data** from the case study store.
|
||
# NO synthetic data is used in this notebook.
|
||
#
|
||
# ## Prerequisites
|
||
#
|
||
# - `02_preprocessing_pipeline` — for split-aware preprocessing concepts that
|
||
# underpin fold-aware IC evaluation in §7.
|
||
# - `03_label_methods` — supplies the forward-return labels used as `y_true`.
|
||
# - Familiarity with rank correlations (Spearman) and walk-forward CV.
|
||
|
||
# %%
|
||
"""Signal Evaluation — IC analysis, quintile spreads, and classification diagnostics for alpha signals."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import warnings
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import plotly.graph_objects as go
|
||
import polars as pl
|
||
from IPython.display import display
|
||
from ml4t.diagnostic.evaluation.binary_metrics import (
|
||
binary_classification_report,
|
||
wilson_score_interval,
|
||
)
|
||
from ml4t.diagnostic.metrics import cross_sectional_ic, pooled_ic
|
||
from ml4t.diagnostic.signal import analyze_signal
|
||
from plotly.subplots import make_subplots
|
||
from scipy.stats import rankdata, spearmanr
|
||
from sklearn.metrics import (
|
||
auc,
|
||
confusion_matrix,
|
||
precision_recall_curve,
|
||
roc_auc_score,
|
||
roc_curve,
|
||
)
|
||
|
||
from data import load_etfs
|
||
from utils.reproducibility import set_global_seeds
|
||
|
||
warnings.filterwarnings("ignore")
|
||
|
||
# %% tags=["parameters"]
|
||
SEED = 42
|
||
OUTPUT_DIR = Path("07_defining_the_learning_task/output")
|
||
START_DATE = "2006-01-01"
|
||
MAX_SYMBOLS = 0
|
||
N_PERMUTATIONS = 200
|
||
DECAY_HORIZONS = (1, 2, 3, 5, 7, 10, 15, 21, 42)
|
||
N_SPLITS = 8
|
||
|
||
# %%
|
||
set_global_seeds(SEED)
|
||
|
||
|
||
# %% [markdown]
|
||
# ## 1. Data Contract
|
||
#
|
||
# Signal analysis requires two DataFrames with specific schemas:
|
||
#
|
||
# **Factor Panel**:
|
||
# - `timestamp`: Decision date
|
||
# - `symbol`: Asset identifier
|
||
# - `factor` or signal column(s): Factor value(s)
|
||
#
|
||
# **Prices Panel**:
|
||
# - `timestamp`: Same as factor panel
|
||
# - `symbol`: Same as factor panel
|
||
# - `close` or `price`: Closing price
|
||
#
|
||
# The `ml4t-diagnostic` library aligns these using ASOF joins to compute
|
||
# forward returns at each decision point.
|
||
|
||
# %%
|
||
# Load real ETF data
|
||
etfs = load_etfs()
|
||
print(f"ETF universe: {etfs['symbol'].n_unique()} symbols, {len(etfs):,} rows")
|
||
print(f"Date range: {etfs['timestamp'].min()} to {etfs['timestamp'].max()}")
|
||
|
||
# %% [markdown]
|
||
# ## 2. Preparing Factor and Price Panels
|
||
#
|
||
# We compute a simple momentum factor (21-day return) and prepare the data
|
||
# in the format required by `analyze_signal()`.
|
||
|
||
# %%
|
||
# Compute momentum factor (21-day return)
|
||
# This is a teaching example - production factors come from Ch8 feature pipelines
|
||
|
||
if START_DATE != "2006-01-01":
|
||
etfs = etfs.filter(pl.col("timestamp") >= pl.lit(START_DATE).str.to_date())
|
||
if MAX_SYMBOLS > 0:
|
||
keep = sorted(etfs["symbol"].unique().to_list())[:MAX_SYMBOLS]
|
||
etfs = etfs.filter(pl.col("symbol").is_in(keep))
|
||
|
||
# Compute 21-day momentum per asset
|
||
factor_df = (
|
||
etfs.sort(["symbol", "timestamp"])
|
||
.with_columns(
|
||
[(pl.col("close") / pl.col("close").shift(21).over("symbol") - 1).alias("factor")]
|
||
)
|
||
.filter(pl.col("factor").is_not_null())
|
||
.select(["timestamp", "symbol", "factor"])
|
||
)
|
||
|
||
# Price panel
|
||
prices_df = etfs.select(["timestamp", "symbol", "close"]).rename({"close": "price"})
|
||
|
||
print(f"Factor panel: {factor_df.shape}")
|
||
print(f"Price panel: {prices_df.shape}")
|
||
print("Factor summary:")
|
||
display(factor_df.select("factor").describe())
|
||
|
||
# %%
|
||
# Pre-compute forward returns — reused in fold-aware (§7) and binary (§9) sections
|
||
eval_df = (
|
||
factor_df.join(prices_df, on=["timestamp", "symbol"], how="inner")
|
||
.sort(["symbol", "timestamp"])
|
||
.with_columns(
|
||
fwd_21d=(pl.col("price").shift(-21).over("symbol") / pl.col("price") - 1),
|
||
)
|
||
.filter(pl.col("fwd_21d").is_not_null())
|
||
)
|
||
print(f"\nEvaluation panel: {eval_df.shape} (factor + 21D forward returns)")
|
||
|
||
# %% [markdown]
|
||
# ## 2.1 Correctness Screens
|
||
#
|
||
# Before evaluating predictive power, verify that the factor is usable under the
|
||
# stated protocol. Section 7.3 prescribes four checks; we demonstrate coverage
|
||
# and staleness here. Timing/lag consistency and mask alignment become critical
|
||
# with fundamental or third-party data (Chapters 8-10) but are trivially satisfied
|
||
# for a price-derived momentum signal.
|
||
|
||
# %%
|
||
# Coverage: fraction of (date, asset) pairs with non-null factor values
|
||
all_pairs = prices_df.select("timestamp", "symbol").unique()
|
||
factor_pairs = factor_df.select("timestamp", "symbol").unique()
|
||
coverage = len(factor_pairs) / len(all_pairs)
|
||
|
||
# Per-date coverage (assets with factor / total assets)
|
||
daily_coverage = (
|
||
all_pairs.join(factor_df, on=["timestamp", "symbol"], how="left")
|
||
.group_by("timestamp")
|
||
.agg(
|
||
total=pl.len(),
|
||
has_factor=pl.col("factor").is_not_null().sum(),
|
||
)
|
||
.with_columns(coverage=(pl.col("has_factor") / pl.col("total")))
|
||
.sort("timestamp")
|
||
)
|
||
|
||
print("=== Correctness Screen: Coverage ===\n")
|
||
print(f"Overall coverage: {coverage:.1%}")
|
||
print(
|
||
f"Per-date coverage — min: {daily_coverage['coverage'].min():.1%}, "
|
||
f"median: {daily_coverage['coverage'].median():.1%}, "
|
||
f"max: {daily_coverage['coverage'].max():.1%}"
|
||
)
|
||
print("\nNote: Coverage < 100% is expected — momentum requires 21 days of history,")
|
||
print("so new listings lack factor values during their first 21 trading days.")
|
||
|
||
# %%
|
||
# Staleness: verify that the factor updates at appropriate frequency
|
||
# For a 21-day momentum signal, the factor should change daily
|
||
staleness = (
|
||
factor_df.sort(["symbol", "timestamp"])
|
||
.with_columns(
|
||
factor_change=(pl.col("factor") != pl.col("factor").shift(1).over("symbol")).cast(pl.Int32)
|
||
)
|
||
.group_by("symbol")
|
||
.agg(
|
||
n_obs=pl.len(),
|
||
n_changes=pl.col("factor_change").sum(),
|
||
)
|
||
.with_columns(change_rate=(pl.col("n_changes") / pl.col("n_obs")))
|
||
)
|
||
|
||
median_change_rate = staleness["change_rate"].median()
|
||
min_change_rate = staleness["change_rate"].min()
|
||
|
||
print("\n=== Correctness Screen: Staleness ===\n")
|
||
print(f"Median daily change rate: {median_change_rate:.1%}")
|
||
print(f"Min change rate (worst asset): {min_change_rate:.1%}")
|
||
if median_change_rate > 0.9:
|
||
print("[PASS] Factor updates daily as expected for a price-derived signal.")
|
||
else:
|
||
print("[WARNING] Some assets show stale factor values — investigate data gaps.")
|
||
|
||
# %% [markdown]
|
||
# ## 3. Information Coefficient (IC) Analysis
|
||
#
|
||
# IC measures the **cross-sectional** rank correlation between signals and forward returns:
|
||
#
|
||
# $$IC_t = \text{Spearman}(\text{signal}_{t}, \text{return}_{t \to t+h})$$
|
||
#
|
||
# Where the correlation is computed across assets at each time $t$.
|
||
|
||
# %%
|
||
# Run signal analysis
|
||
PERIODS = (1, 5, 21) # Forward return horizons (days)
|
||
QUANTILES = 5 # Quintile analysis
|
||
|
||
result = analyze_signal(
|
||
factor_df,
|
||
prices_df,
|
||
periods=PERIODS,
|
||
quantiles=QUANTILES,
|
||
ic_method="spearman", # Rank correlation (robust to outliers)
|
||
date_col="timestamp",
|
||
asset_col="symbol",
|
||
)
|
||
|
||
print("Signal analysis complete")
|
||
print(f"Assets: {result.n_assets}, Dates: {result.n_dates}")
|
||
|
||
# %%
|
||
# Information Coefficient by Horizon
|
||
ic_rows = []
|
||
for period in PERIODS:
|
||
period_key = f"{period}D"
|
||
ic_mean = result.ic.get(period_key, float("nan"))
|
||
icir = result.ic_ir.get(period_key, float("nan"))
|
||
t_stat = result.ic_t_stat.get(period_key, float("nan"))
|
||
p_value = result.ic_p_value.get(period_key, float("nan"))
|
||
sig = "***" if p_value < 0.01 else "**" if p_value < 0.05 else "*" if p_value < 0.10 else ""
|
||
ic_rows.append(
|
||
{
|
||
"horizon": period_key,
|
||
"mean_ic": round(ic_mean, 4),
|
||
"icir": round(icir, 3),
|
||
"t_stat": round(t_stat, 2),
|
||
"p_value": round(p_value, 4),
|
||
"sig": sig,
|
||
}
|
||
)
|
||
|
||
ic_summary = pl.DataFrame(ic_rows)
|
||
ic_summary
|
||
|
||
# %% [markdown]
|
||
# ### Two IC Conventions: Pooled vs Cross-Sectional
|
||
#
|
||
# The library exposes both, and the distinction matters for ranking strategies:
|
||
#
|
||
# - **`pooled_ic`** — one global Spearman correlation across **all (date, asset)**
|
||
# observations. Conflates *which days were good* with *which assets ranked
|
||
# correctly within a day*; sensitive to time-series mean shifts in returns.
|
||
# - **`cross_sectional_ic`** — Spearman per date, then mean across dates. Measures
|
||
# only the daily ranking skill that a long-short strategy can monetise, and exposes
|
||
# IC IR / t-stat / p-value on the per-date series.
|
||
#
|
||
# Chapter 14 standardises on the cross-sectional convention. The two can disagree
|
||
# materially on the same data — pooled may inflate or deflate magnitude depending
|
||
# on the regime structure of returns.
|
||
|
||
# %%
|
||
# Build a (date, symbol, y_pred, y_true) frame from eval_df at horizon 21D
|
||
ic_frame = eval_df.select(
|
||
[
|
||
pl.col("timestamp").alias("date"),
|
||
pl.col("symbol"),
|
||
pl.col("factor").alias("y_pred"),
|
||
pl.col("fwd_21d").alias("y_true"),
|
||
]
|
||
).drop_nulls()
|
||
|
||
ic_pooled = pooled_ic(ic_frame["y_pred"], ic_frame["y_true"], method="spearman")
|
||
ic_xs = cross_sectional_ic(
|
||
ic_frame,
|
||
ic_frame,
|
||
pred_col="y_pred",
|
||
ret_col="y_true",
|
||
date_col="date",
|
||
entity_col="symbol",
|
||
method="spearman",
|
||
min_obs=5,
|
||
)
|
||
|
||
print(f"pooled_ic : {ic_pooled:.4f} (one global Spearman)")
|
||
print(
|
||
f"cross_sectional_ic : {ic_xs['ic_mean']:.4f} "
|
||
f"(mean of {ic_xs['n_periods']} daily Spearmans; "
|
||
f"t={ic_xs['ic_t']:.2f}, p={ic_xs['p_value']:.4f})"
|
||
)
|
||
|
||
# %% [markdown]
|
||
# ### Interpreting an IC magnitude
|
||
#
|
||
# The right anchor for interpreting a mean IC is not the headline value but
|
||
# the standard error of that mean, which is set by the number of periods
|
||
# $T$ in the daily-IC series and by the dispersion of that series:
|
||
#
|
||
# $$\text{SE}(\bar{\text{IC}}) \approx \frac{\sigma_{\text{IC}}}{\sqrt{T}}$$
|
||
#
|
||
# The same point estimate $\bar{\text{IC}} = 0.02$ carries very different
|
||
# evidence depending on $\sigma_{\text{IC}}$ and $T$:
|
||
#
|
||
# - $T \approx 2{,}500$ daily IC values (about ten years) with
|
||
# $\sigma_{\text{IC}} = 0.05$ gives $\text{SE} = 0.001$ and a 95% CI
|
||
# of $[0.018, 0.022]$ — comfortably above zero.
|
||
# - The same $T$ with $\sigma_{\text{IC}} = 0.30$ gives $\text{SE} = 0.006$
|
||
# and a CI of $[0.008, 0.032]$ — above zero, but the band is wide
|
||
# enough that the central value carries little information about the
|
||
# tail behaviour of the signal.
|
||
# - $T \approx 250$ with $\sigma_{\text{IC}} = 0.30$ gives $\text{SE} = 0.019$
|
||
# and a CI of $[-0.017, 0.057]$ — indistinguishable from zero.
|
||
#
|
||
# Reporting a daily-mean IC therefore requires the CI (or the
|
||
# $t$-statistic) alongside, and ideally the dispersion of the daily
|
||
# series as well. The **ICIR** $= \bar{\text{IC}} / \sigma_{\text{IC}}$
|
||
# is the signal-level analog of an information ratio: $t \approx
|
||
# \text{ICIR} \times \sqrt{T}$ for serially uncorrelated daily IC, and a
|
||
# HAC-adjusted $t$ for the realistic correlated case. The ranges below
|
||
# are typical magnitudes from the equity-factor literature on
|
||
# multi-year daily-rebalanced cross-sectional studies; they are
|
||
# starting points for the SE calculation above, not standalone verdicts.
|
||
#
|
||
# | $\bar{\text{IC}}$ | Typical interpretation (conditional on $T$ and $\sigma_{\text{IC}}$) |
|
||
# |---|---|
|
||
# | < 0.02 | At or below the daily-IC noise floor on multi-year samples — the SE alone often spans this range. |
|
||
# | 0.02 – 0.04 | Detectable on 5–10 year samples with the dispersions seen in published studies; net P&L is a separate cost question. |
|
||
# | 0.04 – 0.06 | Comparable to documented equity-factor effects (month-on-month momentum, short-term reversal). |
|
||
# | 0.06 – 0.10 | Above most factor-zoo benchmarks; the cross-validation question is whether the magnitude survives expanding-window evaluation. |
|
||
# | > 0.10 | Outside the published academic range; the prior is leakage or label corruption until ruled out. |
|
||
|
||
# %%
|
||
# Visualize IC time series
|
||
fig = make_subplots(
|
||
rows=1, cols=2, subplot_titles=["IC Time Series (21D)", "IC Distribution (21D)"]
|
||
)
|
||
|
||
# Get 21D IC series
|
||
ic_21d = result.ic_series.get("21D", [])
|
||
if ic_21d:
|
||
# Time series
|
||
fig.add_trace(
|
||
go.Scatter(
|
||
y=ic_21d, mode="lines", name="Daily IC", line=dict(color="#1f77b4"), opacity=0.7
|
||
),
|
||
row=1,
|
||
col=1,
|
||
)
|
||
|
||
# Rolling mean
|
||
window = 21
|
||
ic_series = pl.Series(ic_21d)
|
||
rolling_ic = ic_series.rolling_mean(window_size=window).to_list()
|
||
fig.add_trace(
|
||
go.Scatter(
|
||
y=rolling_ic,
|
||
mode="lines",
|
||
name=f"{window}D Rolling Mean",
|
||
line=dict(color="#ff7f0e", width=2),
|
||
),
|
||
row=1,
|
||
col=1,
|
||
)
|
||
|
||
fig.add_hline(y=0, line_dash="dash", line_color="gray", row=1, col=1)
|
||
|
||
# Distribution
|
||
fig.add_trace(
|
||
go.Histogram(x=ic_21d, nbinsx=30, name="IC Distribution", marker_color="#2ca02c"),
|
||
row=1,
|
||
col=2,
|
||
)
|
||
|
||
mean_ic = np.mean(ic_21d)
|
||
fig.add_vline(x=mean_ic, line_dash="dash", line_color="red", row=1, col=2)
|
||
|
||
fig.update_xaxes(title_text="Date", row=1, col=1)
|
||
fig.update_yaxes(title_text="IC (Spearman)", row=1, col=1)
|
||
fig.update_xaxes(title_text="IC", row=1, col=2)
|
||
fig.update_yaxes(title_text="Count", row=1, col=2)
|
||
fig.update_layout(height=350, template="plotly_white", showlegend=True)
|
||
fig.show()
|
||
|
||
# %% [markdown]
|
||
# ### Publication Figure Artifact
|
||
#
|
||
# The book IC time-series figure reads a compact NumPy artifact so formatting
|
||
# changes do not reload the ETF panel or recompute daily cross-sectional ICs.
|
||
|
||
# %%
|
||
# Collect (native timestamp, IC) pairs and sort on the actual timestamp dtype
|
||
# rather than its string form — lexicographic sorting of stringified dates is
|
||
# only correct for zero-padded ISO output and would silently reorder the series
|
||
# under any other rendering.
|
||
ic_pairs: list[tuple[object, float]] = []
|
||
for date_df in eval_df.partition_by("timestamp"):
|
||
if len(date_df) < 20:
|
||
continue
|
||
corr, _ = spearmanr(date_df["factor"].to_numpy(), date_df["fwd_21d"].to_numpy())
|
||
if not np.isnan(corr):
|
||
ic_pairs.append((date_df["timestamp"][0], float(corr)))
|
||
|
||
ic_pairs.sort(key=lambda t: t[0])
|
||
ic_dates_for_figure = [str(ts) for ts, _ in ic_pairs]
|
||
ic_values_arr = np.array([v for _, v in ic_pairs])
|
||
|
||
rolling_window = 63
|
||
rolling_ic_for_figure = np.full_like(ic_values_arr, np.nan)
|
||
for i in range(rolling_window, len(ic_values_arr)):
|
||
rolling_ic_for_figure[i] = np.mean(ic_values_arr[i - rolling_window : i])
|
||
|
||
min_train = int(len(ic_dates_for_figure) * 0.3)
|
||
test_size = 252
|
||
fold_boundaries_for_figure = ic_dates_for_figure[min_train::test_size]
|
||
|
||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
figure_7_3_artifact = OUTPUT_DIR / "figure_7_3_ic_time_series_with_folds.npz"
|
||
np.savez(
|
||
figure_7_3_artifact,
|
||
ic_dates=np.array(ic_dates_for_figure),
|
||
ic_values=ic_values_arr,
|
||
rolling_ic=rolling_ic_for_figure,
|
||
fold_boundaries=np.array(fold_boundaries_for_figure),
|
||
)
|
||
print(f"Wrote publication figure artifact: {figure_7_3_artifact}")
|
||
|
||
# %% [markdown]
|
||
# ## 4. Quantile Analysis
|
||
#
|
||
# Examine returns by signal quantile to assess **monotonicity** (do higher signal
|
||
# values lead to higher returns?) and **spread** (what's the return difference
|
||
# between top and bottom quantiles?).
|
||
|
||
# %%
|
||
# Quantile returns
|
||
print("\n=== Mean Returns by Quantile ===\n")
|
||
|
||
for period in PERIODS:
|
||
period_key = f"{period}D"
|
||
quantile_rets = result.quantile_returns.get(period_key, {})
|
||
|
||
if quantile_rets:
|
||
print(f"{period_key} Forward Returns:")
|
||
for quantile in sorted(quantile_rets.keys()):
|
||
ret = quantile_rets[quantile]
|
||
bar = "█" * int(abs(ret) * 500) # Simple text bar
|
||
sign = "+" if ret >= 0 else ""
|
||
print(f" Q{quantile}: {sign}{ret:.4%} {bar}")
|
||
print()
|
||
|
||
# %%
|
||
# Visualize quantile returns
|
||
fig = make_subplots(
|
||
rows=1, cols=len(PERIODS), subplot_titles=[f"{p}D Forward Returns" for p in PERIODS]
|
||
)
|
||
|
||
for i, period in enumerate(PERIODS, 1):
|
||
period_key = f"{period}D"
|
||
period_returns = result.quantile_returns.get(period_key, {})
|
||
|
||
if period_returns:
|
||
quantiles = [f"Q{q}" for q in sorted(period_returns.keys())]
|
||
returns = [period_returns[q] for q in sorted(period_returns.keys())]
|
||
|
||
fig.add_trace(
|
||
go.Bar(
|
||
x=quantiles,
|
||
y=returns,
|
||
name=period_key,
|
||
showlegend=False,
|
||
marker_color=["#f97316" if v < 0 else "#2166ac" for v in returns],
|
||
),
|
||
row=1,
|
||
col=i,
|
||
)
|
||
|
||
for i in range(1, len(PERIODS) + 1):
|
||
fig.update_xaxes(title_text="Quantile", row=1, col=i)
|
||
fig.update_yaxes(title_text="Mean Forward Return", row=1, col=i)
|
||
fig.update_layout(title="Mean Returns by Signal Quantile", template="plotly_white", height=350)
|
||
fig.show()
|
||
|
||
# %%
|
||
# Spread and monotonicity analysis
|
||
spread_rows = []
|
||
for period in PERIODS:
|
||
period_key = f"{period}D"
|
||
spread = result.spread.get(period_key, float("nan"))
|
||
t_stat = result.spread_t_stat.get(period_key, float("nan"))
|
||
mono = result.monotonicity.get(period_key, float("nan"))
|
||
spread_rows.append(
|
||
{
|
||
"horizon": period_key,
|
||
"spread_pct": round(spread * 100, 4),
|
||
"t_stat": round(t_stat, 2),
|
||
"monotonicity_pct": round(mono * 100, 1),
|
||
}
|
||
)
|
||
|
||
spread_summary = pl.DataFrame(spread_rows)
|
||
spread_summary
|
||
|
||
# %% [markdown]
|
||
# ### Monotonicity Interpretation
|
||
#
|
||
# Monotonicity measures how consistently returns increase (or decrease) across
|
||
# quantiles. Perfect monotonicity (100%) means each quantile has higher returns
|
||
# than the previous one.
|
||
#
|
||
# | Monotonicity | Interpretation |
|
||
# |--------------|----------------|
|
||
# | > 80% | Strong, consistent signal |
|
||
# | 60-80% | Moderate; may work for long-short |
|
||
# | < 60% | Weak; consider non-linear models |
|
||
|
||
# %% [markdown]
|
||
# ## 5. Horizon Comparison
|
||
#
|
||
# Compare IC across different forward return horizons to identify the optimal
|
||
# holding period for the signal.
|
||
|
||
# %%
|
||
# Visualize IC by horizon
|
||
fig = make_subplots(rows=1, cols=2, subplot_titles=["Mean IC by Horizon", "ICIR by Horizon"])
|
||
|
||
horizons = list(PERIODS)
|
||
ics = [result.ic.get(f"{h}D", float("nan")) for h in horizons]
|
||
icirs = [result.ic_ir.get(f"{h}D", float("nan")) for h in horizons]
|
||
|
||
# IC plot (colorblind-safe)
|
||
fig.add_trace(
|
||
go.Bar(
|
||
x=[f"{h}D" for h in horizons],
|
||
y=ics,
|
||
name="Mean IC",
|
||
marker_color=["#2166ac" if v > 0 else "#f97316" for v in ics],
|
||
),
|
||
row=1,
|
||
col=1,
|
||
)
|
||
|
||
# ICIR plot
|
||
icir_colors = ["#2166ac" if v > 0.5 else "#9ecae1" if v > 0 else "#f97316" for v in icirs]
|
||
fig.add_trace(
|
||
go.Bar(x=[f"{h}D" for h in horizons], y=icirs, name="ICIR", marker_color=icir_colors),
|
||
row=1,
|
||
col=2,
|
||
)
|
||
|
||
fig.add_hline(y=0.5, line_dash="dash", line_color="gray", row=1, col=2)
|
||
|
||
fig.update_xaxes(title_text="Horizon", row=1, col=1)
|
||
fig.update_yaxes(title_text="Mean IC", row=1, col=1)
|
||
fig.update_xaxes(title_text="Horizon", row=1, col=2)
|
||
fig.update_yaxes(title_text="ICIR", row=1, col=2)
|
||
fig.update_layout(height=350, template="plotly_white", showlegend=False)
|
||
fig.show()
|
||
|
||
# %% [markdown]
|
||
# ### Overlapping Returns Warning
|
||
#
|
||
# When comparing IC across horizons, be aware that **longer horizons have overlapping
|
||
# forward returns**, which introduces autocorrelation in the IC series.
|
||
#
|
||
# For example, 21-day forward returns on consecutive days share 20 days of overlap.
|
||
# This means:
|
||
#
|
||
# 1. **IC series are autocorrelated** at longer horizons
|
||
# 2. **Standard errors are understated** without HAC adjustment
|
||
# 3. **Comparing IC across horizons requires caution** - higher IC at longer
|
||
# horizons may reflect overlap, not better predictability
|
||
#
|
||
# **Best practice**: Use HAC-adjusted t-statistics (as provided by `analyze_signal`)
|
||
# and be skeptical of IC that increases monotonically with horizon.
|
||
|
||
# %% [markdown]
|
||
# ### 5.1 IC Decay Analysis
|
||
#
|
||
# IC decay determines the optimal rebalancing frequency. A signal with 5-day
|
||
# half-life should not be held for 21 days. We compute IC at finer granularity
|
||
# to estimate the signal's useful life.
|
||
|
||
# %%
|
||
# Compute IC across a finer horizon grid (single call — batches all horizons)
|
||
decay_horizons = DECAY_HORIZONS
|
||
|
||
decay_result = analyze_signal(
|
||
factor_df,
|
||
prices_df,
|
||
periods=decay_horizons,
|
||
quantiles=3,
|
||
ic_method="spearman",
|
||
date_col="timestamp",
|
||
asset_col="symbol",
|
||
)
|
||
decay_ics = [decay_result.ic.get(f"{h}D", float("nan")) for h in decay_horizons]
|
||
|
||
# %%
|
||
# Plot IC decay curve
|
||
fig = go.Figure()
|
||
fig.add_trace(
|
||
go.Scatter(
|
||
x=decay_horizons,
|
||
y=decay_ics,
|
||
mode="lines+markers",
|
||
name="Mean IC",
|
||
line=dict(width=2),
|
||
)
|
||
)
|
||
fig.add_hline(y=0, line_dash="dash", line_color="gray")
|
||
|
||
# First horizon at which IC falls below half its peak — diagnostic only.
|
||
# When IC reverses sign rather than decays monotonically (as is common for
|
||
# short-horizon reversal signals), label the crossing as "IC falls below
|
||
# half-peak" rather than "half-life" to avoid implying smooth decay.
|
||
peak_ic = max(decay_ics)
|
||
half_ic = peak_ic / 2
|
||
for i, ic in enumerate(decay_ics):
|
||
if ic < half_ic and peak_ic > 0:
|
||
crossing = decay_horizons[i]
|
||
fig.add_vline(
|
||
x=crossing,
|
||
line_dash="dot",
|
||
line_color="red",
|
||
annotation_text=f"IC < ½·peak by day {crossing}",
|
||
)
|
||
break
|
||
|
||
fig.update_layout(
|
||
title="IC Decay Across Horizons",
|
||
xaxis_title="Forward Return Horizon (days)",
|
||
yaxis_title="Mean IC (Spearman)",
|
||
height=350,
|
||
template="plotly_white",
|
||
)
|
||
fig.show()
|
||
|
||
# %% [markdown]
|
||
# **Interpretation**: The IC decay curve shows how quickly the signal's
|
||
# predictive power diminishes with longer horizons. Rebalancing should match
|
||
# the horizon where IC peaks or begins to decay significantly.
|
||
|
||
# %% [markdown]
|
||
# ## 6. Turnover Analysis
|
||
#
|
||
# High turnover erodes returns through transaction costs. A signal with high IC
|
||
# but excessive turnover may not be profitable after costs.
|
||
|
||
# %%
|
||
# Turnover metrics
|
||
print("\n=== Turnover Analysis ===\n")
|
||
|
||
if result.turnover:
|
||
print("Mean Turnover by Period:")
|
||
for period_key, turnover in result.turnover.items():
|
||
print(f" {period_key}: {turnover:.1%}")
|
||
else:
|
||
print("Turnover: Not computed")
|
||
|
||
if result.autocorrelation:
|
||
print(
|
||
f"\nSignal Autocorrelation (lag 1-5): {[f'{ac:.3f}' for ac in result.autocorrelation[:5]]}"
|
||
)
|
||
else:
|
||
print("\nAutocorrelation: Not computed")
|
||
|
||
if result.half_life:
|
||
print(f"\nSignal Half-Life: {result.half_life:.1f} periods")
|
||
|
||
# Interpretation
|
||
if result.half_life < 5:
|
||
print(" Interpretation: Fast decay - requires frequent rebalancing")
|
||
elif result.half_life < 20:
|
||
print(" Interpretation: Moderate decay - weekly rebalancing appropriate")
|
||
else:
|
||
print(" Interpretation: Slow decay - monthly rebalancing sufficient")
|
||
|
||
# %% [markdown]
|
||
# ### Turnover and Costs
|
||
#
|
||
# A simple cost-adjusted IC (Grinold approximation):
|
||
#
|
||
# $$IC_{net} \approx IC - \frac{c \times \text{turnover}}{E[r]}$$
|
||
#
|
||
# Where $c$ is round-trip transaction cost and $E[r]$ is expected return.
|
||
# For most equity strategies, turnover > 100%/month significantly erodes alpha.
|
||
|
||
# %% [markdown]
|
||
# ### Break-Even Cost Analysis
|
||
#
|
||
# A feasibility check asks: **could this signal survive transaction costs?**
|
||
#
|
||
# We compare the expected spread (top-bottom quantile return) to the cost of
|
||
# achieving that spread. If round-trip costs exceed the expected edge, the
|
||
# signal is not tradeable at the given rebalancing frequency.
|
||
|
||
# %%
|
||
# Break-even cost analysis
|
||
print("\n=== Break-Even Cost Analysis ===\n")
|
||
|
||
# Get the 21-day spread and turnover
|
||
spread_21d = result.spread.get("21D", float("nan"))
|
||
turnover_21d = result.turnover.get("21D", 0.5) if result.turnover else 0.5 # default 50%
|
||
|
||
# Define cost assumptions (conservative for US equities)
|
||
# These should match the trading setup's cost model
|
||
COST_ASSUMPTIONS = {
|
||
"spread_bps": 10, # Half-spread in basis points
|
||
"commission_bps": 5, # Commission per side
|
||
"market_impact_bps": 10, # Expected market impact
|
||
}
|
||
|
||
round_trip_cost = (
|
||
2 * COST_ASSUMPTIONS["spread_bps"]
|
||
+ 2 * COST_ASSUMPTIONS["commission_bps"]
|
||
+ 2 * COST_ASSUMPTIONS["market_impact_bps"]
|
||
) / 10000 # Convert to decimal
|
||
|
||
print("Cost Assumptions (per leg):")
|
||
print(f" Spread: {COST_ASSUMPTIONS['spread_bps']} bps")
|
||
print(f" Commission: {COST_ASSUMPTIONS['commission_bps']} bps")
|
||
print(f" Market impact: {COST_ASSUMPTIONS['market_impact_bps']} bps")
|
||
print(f" Round-trip: {round_trip_cost * 10000:.0f} bps ({round_trip_cost:.2%})")
|
||
|
||
# %%
|
||
# Compute cost-adjusted spread
|
||
expected_turnover_per_period = turnover_21d * 2 # Both legs
|
||
cost_drag = round_trip_cost * expected_turnover_per_period
|
||
|
||
cost_adjusted_spread = spread_21d - cost_drag
|
||
|
||
print("\n21D Signal Analysis:")
|
||
print(f" Raw spread: {spread_21d:.2%}")
|
||
print(f" Expected turnover: {expected_turnover_per_period:.0%}")
|
||
print(f" Cost drag per period: {cost_drag:.2%}")
|
||
print(f" Cost-adjusted spread: {cost_adjusted_spread:.2%}")
|
||
|
||
# Break-even calculation
|
||
if spread_21d > 0:
|
||
break_even_cost = spread_21d / expected_turnover_per_period
|
||
print(f" Break-even cost: {break_even_cost * 10000:.0f} bps")
|
||
|
||
if cost_adjusted_spread > 0:
|
||
print("\n[PASS] Signal survives cost assumptions")
|
||
else:
|
||
print("\n[WARNING] Signal does not survive cost assumptions at this turnover")
|
||
print(" Consider: longer horizon, lower turnover, or reduced position sizing")
|
||
else:
|
||
print("\n[WARNING] Negative spread - signal direction may be inverted")
|
||
|
||
# %% [markdown]
|
||
# ### Feasibility Guidelines
|
||
#
|
||
# Three checks for signal feasibility (from Section 7.3):
|
||
#
|
||
# 1. **Turnover proxies**: Measure entry/exit rates in the top-k set (see above)
|
||
# 2. **Break-even cost checks**: Compare spread to conservative cost estimates
|
||
# 3. **Capacity warnings**: Recompute IC by liquidity bucket (deferred to Ch8)
|
||
#
|
||
# **Note**: Liquidity-bucket analysis requires actual market microstructure data
|
||
# (average volume, bid-ask spreads) which is not available for this placeholder
|
||
# feature. Chapter 8 demonstrates this check with real case study data.
|
||
|
||
# %% [markdown]
|
||
# ## 7. Fold-Aware Evaluation
|
||
#
|
||
# **Critical**: The IC computed above pools all dates into a single statistic. However,
|
||
# real trading strategies are evaluated on **out-of-sample** data using walk-forward
|
||
# validation. This section demonstrates fold-aware IC computation.
|
||
#
|
||
# ### Why Folds Matter
|
||
#
|
||
# Global IC can be misleading because:
|
||
# 1. **Regime dependence**: IC may be high in some periods and zero in others
|
||
# 2. **Lookahead contamination**: Parameters tuned on full data leak future information
|
||
# 3. **Overfitting detection**: Consistent IC across folds suggests robust signal
|
||
#
|
||
# The text emphasizes computing IC **per fold** and reporting the distribution of
|
||
# fold-level statistics, not just their pooled mean.
|
||
|
||
# %%
|
||
# Define walk-forward splits
|
||
# We use expanding window: train on all data up to split point, test on next period
|
||
|
||
|
||
def create_walk_forward_splits(
|
||
dates: list, n_splits: int = 5, min_train_pct: float = 0.2, test_periods: int = 63
|
||
) -> list[tuple[list, list]]:
|
||
"""Create walk-forward cross-validation splits.
|
||
|
||
Args:
|
||
dates: Sorted unique dates
|
||
n_splits: Number of test folds
|
||
min_train_pct: Minimum training data as fraction of total
|
||
test_periods: Number of periods per test fold
|
||
|
||
Returns:
|
||
List of (train_dates, test_dates) tuples
|
||
"""
|
||
n_dates = len(dates)
|
||
min_train = int(n_dates * min_train_pct)
|
||
|
||
splits = []
|
||
for i in range(n_splits):
|
||
# Test window
|
||
test_start = min_train + i * test_periods
|
||
test_end = min(test_start + test_periods, n_dates)
|
||
|
||
if test_start >= n_dates:
|
||
break
|
||
|
||
train_dates = dates[:test_start]
|
||
test_dates = dates[test_start:test_end]
|
||
|
||
if len(test_dates) > 0:
|
||
splits.append((train_dates, test_dates))
|
||
|
||
return splits
|
||
|
||
|
||
# %%
|
||
# Create splits for our data
|
||
unique_dates = sorted(factor_df["timestamp"].unique().to_list())
|
||
n_splits = N_SPLITS
|
||
test_periods = 63 # ~3 months per fold
|
||
|
||
splits = create_walk_forward_splits(
|
||
unique_dates, n_splits=n_splits, min_train_pct=0.3, test_periods=test_periods
|
||
)
|
||
|
||
print(f"Created {len(splits)} walk-forward splits")
|
||
print("\nSplit structure:")
|
||
for i, (train_dates, test_dates) in enumerate(splits):
|
||
print(f" Fold {i + 1}: Train {len(train_dates)} days, Test {len(test_dates)} days")
|
||
print(f" Train: {train_dates[0]} to {train_dates[-1]}")
|
||
print(f" Test: {test_dates[0]} to {test_dates[-1]}")
|
||
|
||
# %% [markdown]
|
||
# ### Compute Per-Fold IC
|
||
#
|
||
# For each fold, we compute IC on the **test period only**. This mimics how the
|
||
# signal would perform in live trading, where we only see future returns after
|
||
# making predictions.
|
||
|
||
# %%
|
||
# Slice eval_df per fold (forward returns computed in §2)
|
||
fold_results = []
|
||
|
||
for fold_idx, (train_dates, test_dates) in enumerate(splits):
|
||
test_data = eval_df.filter(pl.col("timestamp").is_in(test_dates))
|
||
|
||
if len(test_data) < 100:
|
||
continue
|
||
|
||
# Cross-sectional IC per date, then average
|
||
ic_per_date = []
|
||
for date_df in test_data.partition_by("timestamp"):
|
||
if len(date_df) < 10: # Need enough assets for meaningful correlation
|
||
continue
|
||
corr, _ = spearmanr(date_df["factor"].to_numpy(), date_df["fwd_21d"].to_numpy())
|
||
if not np.isnan(corr):
|
||
ic_per_date.append(corr)
|
||
|
||
if not ic_per_date:
|
||
continue
|
||
|
||
fold_ic = np.mean(ic_per_date)
|
||
|
||
# Quantile spread and monotonicity
|
||
test_with_q = test_data.with_columns(
|
||
quantile=pl.col("factor")
|
||
.rank()
|
||
.over("timestamp")
|
||
.qcut(5, labels=[str(i) for i in range(1, 6)])
|
||
.over("timestamp")
|
||
)
|
||
q_rets = test_with_q.group_by("quantile").agg(pl.col("fwd_21d").mean()).sort("quantile")
|
||
q_vals = q_rets["fwd_21d"].to_list()
|
||
fold_spread = q_vals[-1] - q_vals[0] if len(q_vals) >= 2 else float("nan")
|
||
# Monotonicity: fraction of consecutive quantiles in correct order
|
||
if len(q_vals) >= 2:
|
||
diffs = [q_vals[i + 1] - q_vals[i] for i in range(len(q_vals) - 1)]
|
||
fold_mono = sum(1 for d in diffs if d > 0) / len(diffs)
|
||
else:
|
||
fold_mono = float("nan")
|
||
|
||
fold_results.append(
|
||
{
|
||
"fold": fold_idx + 1,
|
||
"test_start": str(test_dates[0]),
|
||
"test_end": str(test_dates[-1]),
|
||
"n_obs": len(test_data),
|
||
"ic": fold_ic,
|
||
"spread": fold_spread,
|
||
"monotonicity": fold_mono,
|
||
}
|
||
)
|
||
|
||
# %%
|
||
fold_df = pl.DataFrame(fold_results)
|
||
print("\n=== Per-Fold IC Results (21D horizon) ===\n")
|
||
print(fold_df)
|
||
|
||
# %%
|
||
# Summarize fold-level statistics
|
||
fold_ic_mean = fold_df["ic"].mean()
|
||
fold_ic_std = fold_df["ic"].std()
|
||
fold_ic_min = fold_df["ic"].min()
|
||
fold_ic_max = fold_df["ic"].max()
|
||
pct_positive = (fold_df["ic"] > 0).mean() * 100
|
||
|
||
print("\n=== Fold-Level Summary ===")
|
||
print(f"Mean IC: {fold_ic_mean:.4f}")
|
||
print(f"Std IC: {fold_ic_std:.4f}")
|
||
print(f"IC Range: [{fold_ic_min:.4f}, {fold_ic_max:.4f}]")
|
||
print(f"% Folds > 0: {pct_positive:.0f}%")
|
||
print(f"Fold ICIR: {fold_ic_mean / fold_ic_std:.3f}" if fold_ic_std > 0 else "N/A")
|
||
|
||
# %%
|
||
# Visualize fold-level IC distribution (print-ready with fold date labels)
|
||
fig = make_subplots(
|
||
rows=1,
|
||
cols=2,
|
||
subplot_titles=["Per-Fold IC (21D Horizon)", "Fold IC Distribution"],
|
||
horizontal_spacing=0.15,
|
||
)
|
||
|
||
# Use fold test-start dates as x-axis labels for temporal context
|
||
fold_labels = [r["test_start"][:7] for r in fold_results] # YYYY-MM format
|
||
|
||
# Colorblind-safe: blue for positive, orange for negative
|
||
fig.add_trace(
|
||
go.Bar(
|
||
x=fold_labels,
|
||
y=[r["ic"] for r in fold_results],
|
||
marker_color=["#2166ac" if r["ic"] > 0 else "#f97316" for r in fold_results],
|
||
name="Fold IC",
|
||
),
|
||
row=1,
|
||
col=1,
|
||
)
|
||
|
||
# Add global mean line
|
||
fig.add_hline(
|
||
y=fold_ic_mean,
|
||
line_dash="dash",
|
||
line_color="#2166ac",
|
||
line_width=1.5,
|
||
annotation_text=f"Mean={fold_ic_mean:.3f}",
|
||
row=1,
|
||
col=1,
|
||
)
|
||
fig.add_hline(y=0, line_dash="dot", line_color="gray", row=1, col=1)
|
||
|
||
# Histogram of IC values
|
||
fig.add_trace(
|
||
go.Histogram(x=[r["ic"] for r in fold_results], nbinsx=10, marker_color="#2166ac"),
|
||
row=1,
|
||
col=2,
|
||
)
|
||
fig.add_vline(x=0, line_dash="dot", line_color="gray", row=1, col=2)
|
||
fig.add_vline(x=fold_ic_mean, line_dash="dash", line_color="#2166ac", row=1, col=2)
|
||
|
||
fig.update_xaxes(title_text="Test Fold Start", row=1, col=1)
|
||
fig.update_yaxes(title_text="IC (Spearman)", row=1, col=1)
|
||
fig.update_xaxes(title_text="IC", row=1, col=2)
|
||
fig.update_yaxes(title_text="Count", row=1, col=2)
|
||
|
||
fig.update_layout(
|
||
height=400,
|
||
template="plotly_white",
|
||
showlegend=False,
|
||
font=dict(size=12),
|
||
)
|
||
fig.show()
|
||
|
||
# %% [markdown]
|
||
# ### Interpretation: Full-Sample vs Fold-Level IC
|
||
#
|
||
# Both numbers below are cross-sectional means (per-date Spearman, then averaged);
|
||
# they differ only in whether the dates come from the full sample or from disjoint
|
||
# out-of-sample folds:
|
||
#
|
||
# | Approach | IC | Interpretation |
|
||
# |----------|-----|----------------|
|
||
# | Full-sample cross-sectional | Mean per-date IC over all dates | Optimistic; uses every period |
|
||
# | Fold-level mean | Mean per-date IC averaged across OOS folds | More realistic estimate |
|
||
# | Fold-level std | Variation across folds | Measures stability |
|
||
#
|
||
# **Warning signs**:
|
||
# - Fold IC varies wildly (high std) → regime-dependent signal
|
||
# - Many folds with IC ≤ 0 → unreliable signal
|
||
# - Full-sample IC >> Fold mean → possible overfitting
|
||
|
||
# %%
|
||
# Compare full-sample vs fold-level (both cross-sectional means)
|
||
full_sample_ic = result.ic.get("21D", float("nan"))
|
||
|
||
print("\n=== Full-Sample vs Fold-Level Comparison ===")
|
||
print(f"Full-sample cross-sectional IC: {full_sample_ic:.4f}")
|
||
print(f"Fold-level mean IC: {fold_ic_mean:.4f}")
|
||
print(f"Difference: {full_sample_ic - fold_ic_mean:.4f}")
|
||
|
||
if abs(full_sample_ic - fold_ic_mean) > 0.01:
|
||
print("\n[WARNING] Significant difference between full-sample and fold-level IC.")
|
||
print(" This may indicate regime effects or data leakage.")
|
||
else:
|
||
print("\n[PASS] Full-sample and fold-level IC are consistent.")
|
||
|
||
# %% [markdown]
|
||
# ## 7.1 Within-Time Permutation Test
|
||
#
|
||
# The text (Section 7.3) recommends a **within-time permutation test** as a
|
||
# null-distribution benchmark: shuffle asset-label assignments within each
|
||
# cross-section, breaking the feature-label pairing while preserving cross-sectional
|
||
# dependence. If the observed IC exceeds the permutation distribution, the feature
|
||
# ranks the right assets — not just any assets.
|
||
|
||
# %%
|
||
# Permutation test: shuffle labels within each date, recompute IC
|
||
# Pre-compute group indices for vectorized permutation (avoids slow per-date Python loop)
|
||
rng = np.random.default_rng(SEED)
|
||
n_permutations = N_PERMUTATIONS
|
||
|
||
dates_arr = eval_df["timestamp"].to_numpy()
|
||
factors_arr = eval_df["factor"].to_numpy()
|
||
returns_arr = eval_df["fwd_21d"].to_numpy()
|
||
unique_dates_perm = np.unique(dates_arr)
|
||
|
||
# Build date group indices once (avoid repeated masking)
|
||
date_groups = []
|
||
for d in unique_dates_perm:
|
||
idx = np.where(dates_arr == d)[0]
|
||
if len(idx) >= 10:
|
||
date_groups.append(idx)
|
||
|
||
# Pre-compute factor ranks per date (ranks don't change across permutations)
|
||
factor_ranks_by_group = [rankdata(factors_arr[idx]) for idx in date_groups]
|
||
|
||
# %%
|
||
permuted_ics = []
|
||
for _ in range(n_permutations):
|
||
# Shuffle returns within each date, compute rank correlation
|
||
ic_per_date = []
|
||
for i, idx in enumerate(date_groups):
|
||
shuffled = rng.permutation(returns_arr[idx])
|
||
# Spearman = Pearson of ranks
|
||
r_ranks = rankdata(shuffled)
|
||
f_ranks = factor_ranks_by_group[i]
|
||
n = len(f_ranks)
|
||
corr = np.corrcoef(f_ranks, r_ranks)[0, 1]
|
||
if not np.isnan(corr):
|
||
ic_per_date.append(corr)
|
||
if ic_per_date:
|
||
permuted_ics.append(np.mean(ic_per_date))
|
||
|
||
permuted_ics = np.array(permuted_ics)
|
||
observed_ic_mean = fold_ic_mean # Use fold-level mean as the observed statistic
|
||
|
||
# p-value: fraction of permuted ICs >= observed
|
||
p_value_perm = np.mean(permuted_ics >= observed_ic_mean)
|
||
|
||
print("=== Within-Time Permutation Test ===\n")
|
||
print(f"Observed mean IC: {observed_ic_mean:.4f}")
|
||
print(f"Permutation null — mean: {permuted_ics.mean():.4f}, std: {permuted_ics.std():.4f}")
|
||
print(f"Permutation p-value (one-sided): {p_value_perm:.4f}")
|
||
print(f"Permutations: {n_permutations}")
|
||
|
||
# %%
|
||
# Visualize permutation distribution vs observed IC
|
||
fig = go.Figure()
|
||
fig.add_trace(
|
||
go.Histogram(
|
||
x=permuted_ics,
|
||
nbinsx=30,
|
||
name="Permuted IC",
|
||
marker_color="#9ecae1",
|
||
opacity=0.8,
|
||
)
|
||
)
|
||
fig.add_vline(
|
||
x=observed_ic_mean,
|
||
line_dash="solid",
|
||
line_color="#d62728",
|
||
line_width=2,
|
||
annotation_text=f"Observed IC={observed_ic_mean:.4f}",
|
||
)
|
||
fig.update_layout(
|
||
title="Within-Time Permutation Test",
|
||
xaxis_title="Mean IC (permuted)",
|
||
yaxis_title="Count",
|
||
height=350,
|
||
template="plotly_white",
|
||
showlegend=False,
|
||
)
|
||
fig.show()
|
||
|
||
# %% [markdown]
|
||
# **Interpretation**: The permutation test asks whether the observed IC could arise
|
||
# from a random assignment of labels to assets within each cross-section. A p-value
|
||
# near zero confirms that the feature genuinely ranks the right assets at each
|
||
# decision time — the signal is not an artifact of cross-sectional dependence.
|
||
|
||
# %% [markdown]
|
||
# ## 8. Factor Scorecard Output
|
||
#
|
||
# Export a structured summary for downstream use, including both global and
|
||
# fold-level statistics.
|
||
|
||
# %%
|
||
# Build factor scorecard with fold-level stats
|
||
scorecard = {
|
||
"factor_name": "momentum_21d",
|
||
"n_assets": result.n_assets,
|
||
"n_dates": result.n_dates,
|
||
"horizons": {},
|
||
}
|
||
|
||
for period in PERIODS:
|
||
period_key = f"{period}D"
|
||
scorecard["horizons"][period_key] = {
|
||
"ic_mean": round(result.ic.get(period_key, float("nan")), 4),
|
||
"ic_std": round(np.std(result.ic_series.get(period_key, [])), 4)
|
||
if result.ic_series.get(period_key)
|
||
else None,
|
||
"icir": round(result.ic_ir.get(period_key, float("nan")), 3),
|
||
"t_stat": round(result.ic_t_stat.get(period_key, float("nan")), 2),
|
||
"p_value": round(result.ic_p_value.get(period_key, float("nan")), 4),
|
||
"spread": round(result.spread.get(period_key, float("nan")), 4),
|
||
"monotonicity": round(result.monotonicity.get(period_key, float("nan")), 3),
|
||
}
|
||
|
||
# Add fold-level statistics (21D only)
|
||
if fold_results:
|
||
scorecard["fold_evaluation"] = {
|
||
"n_folds": len(fold_results),
|
||
"ic_mean": round(fold_ic_mean, 4),
|
||
"ic_std": round(fold_ic_std, 4),
|
||
"ic_min": round(fold_ic_min, 4),
|
||
"ic_max": round(fold_ic_max, 4),
|
||
"pct_positive_folds": round(pct_positive, 1),
|
||
"fold_icir": round(fold_ic_mean / fold_ic_std, 3) if fold_ic_std > 0 else None,
|
||
}
|
||
|
||
if result.turnover:
|
||
scorecard["turnover"] = {k: round(v, 3) for k, v in result.turnover.items()}
|
||
if result.half_life:
|
||
scorecard["half_life_periods"] = round(result.half_life, 1)
|
||
|
||
# Display scorecard
|
||
print("\n=== Factor Scorecard ===\n")
|
||
print(json.dumps(scorecard, indent=2))
|
||
|
||
# %% [markdown]
|
||
# ## 9. Binary Label Evaluation
|
||
#
|
||
# When labels are binary (e.g., "positive return" vs "negative return"), we evaluate
|
||
# using classification metrics rather than IC. The feature acts as a **score** that
|
||
# separates positives from negatives.
|
||
#
|
||
# **Key metrics:**
|
||
# - **ROC AUC**: Area under ROC curve (threshold-free ranking metric)
|
||
# - **PR AUC**: Area under Precision-Recall curve (better for imbalanced data)
|
||
# - **Confusion matrix**: TP, FP, TN, FN at a chosen threshold
|
||
|
||
# %%
|
||
# Create binary labels from forward returns (reuse eval_df from §2)
|
||
# Positive = return > 0, Negative = return <= 0
|
||
binary_df = eval_df.with_columns(
|
||
pl.when(pl.col("fwd_21d") > 0).then(1).otherwise(0).alias("binary_label")
|
||
)
|
||
|
||
# Use factor as score (higher = predict positive)
|
||
y_true = binary_df["binary_label"].to_numpy()
|
||
y_score = binary_df["factor"].to_numpy()
|
||
|
||
# Handle NaN values; sklearn accepts the native int32/float64 dtypes.
|
||
mask = ~(np.isnan(y_true) | np.isnan(y_score))
|
||
y_true = y_true[mask]
|
||
y_score = y_score[mask]
|
||
|
||
print(f"Binary evaluation: {len(y_true):,} samples")
|
||
print(f"Class balance: {y_true.mean():.1%} positive")
|
||
|
||
# %%
|
||
# Manual argsort+cumsum sweep over the full 466k-row score array.
|
||
# Avoids sklearn 1.6.1's _binary_clf_curve, which raises IndexError on
|
||
# state-dependent runs of this array under econml 0.16's sklearn<1.7
|
||
# pin; the underlying failure mode is uncharacterized, the manual path
|
||
# is bit-for-bit deterministic and faster than the bisect-style sklearn
|
||
# implementation at this size.
|
||
roc_auc = roc_auc_score(y_true, y_score)
|
||
|
||
_order = np.argsort(-y_score, kind="mergesort")
|
||
# int64 promotion guards np.cumsum from int32 overflow at 466k samples.
|
||
_yt_sorted = y_true[_order].astype(np.int64)
|
||
_score_sorted = y_score[_order]
|
||
_n_pos = int(_yt_sorted.sum())
|
||
_n_neg = len(_yt_sorted) - _n_pos
|
||
_tps_cum = np.cumsum(_yt_sorted)
|
||
_fps_cum = np.cumsum(1 - _yt_sorted)
|
||
fpr = np.concatenate([[0.0], _fps_cum / _n_neg])
|
||
tpr = np.concatenate([[0.0], _tps_cum / _n_pos])
|
||
# Thresholds aligned with fpr/tpr via the same _order index; no re-sort.
|
||
# Ties are not collapsed (unlike sklearn) — at 466k unique-or-near-unique
|
||
# float scores the AUC difference is sub-1e-6.
|
||
roc_thresholds = np.concatenate([[np.inf], _score_sorted])
|
||
|
||
# Precision-Recall (manual sweep omits sklearn's trailing (precision=1, recall=0)
|
||
# sentinel; the trapezoidal-AUC difference at 466k points is sub-1e-6)
|
||
_pred_pos = np.arange(1, len(_yt_sorted) + 1)
|
||
precision = _tps_cum / _pred_pos
|
||
recall = _tps_cum / _n_pos
|
||
pr_auc = auc(recall, precision)
|
||
|
||
print("\nThreshold-Free Metrics:")
|
||
print(f" ROC AUC: {roc_auc:.3f}")
|
||
print(f" PR AUC: {pr_auc:.3f}")
|
||
|
||
# Interpretation
|
||
if roc_auc > 0.55:
|
||
print(" Interpretation: AUC above 0.55 — score ranks positives above negatives")
|
||
elif roc_auc > 0.52:
|
||
print(
|
||
" Interpretation: AUC in (0.52, 0.55] — small ranking signal; tradeability not evaluated here"
|
||
)
|
||
else:
|
||
print(" Interpretation: AUC at or near 0.5 — score does not separate the two classes")
|
||
|
||
# %%
|
||
# Visualize ROC and PR curves (print-ready, colorblind-safe)
|
||
fig = make_subplots(rows=1, cols=2, subplot_titles=["ROC Curve", "Precision-Recall Curve"])
|
||
|
||
# ROC curve
|
||
fig.add_trace(
|
||
go.Scatter(
|
||
x=fpr,
|
||
y=tpr,
|
||
mode="lines",
|
||
name=f"ROC (AUC={roc_auc:.3f})",
|
||
line=dict(color="#2166ac", width=2),
|
||
),
|
||
row=1,
|
||
col=1,
|
||
)
|
||
fig.add_trace(
|
||
go.Scatter(
|
||
x=[0, 1],
|
||
y=[0, 1],
|
||
mode="lines",
|
||
name="Random",
|
||
line=dict(dash="dash", color="gray"),
|
||
),
|
||
row=1,
|
||
col=1,
|
||
)
|
||
|
||
# PR curve
|
||
fig.add_trace(
|
||
go.Scatter(
|
||
x=recall,
|
||
y=precision,
|
||
mode="lines",
|
||
name=f"PR (AUC={pr_auc:.3f})",
|
||
line=dict(color="#2166ac", width=2),
|
||
),
|
||
row=1,
|
||
col=2,
|
||
)
|
||
baseline_precision = y_true.mean()
|
||
fig.add_hline(
|
||
y=baseline_precision,
|
||
line_dash="dash",
|
||
line_color="gray",
|
||
row=1,
|
||
col=2,
|
||
annotation_text=f"Prevalence={baseline_precision:.1%}",
|
||
annotation_position="bottom right",
|
||
)
|
||
|
||
fig.update_xaxes(title_text="False Positive Rate", row=1, col=1)
|
||
fig.update_yaxes(title_text="True Positive Rate", row=1, col=1)
|
||
fig.update_xaxes(title_text="Recall", row=1, col=2)
|
||
fig.update_yaxes(title_text="Precision", row=1, col=2)
|
||
|
||
fig.update_layout(
|
||
height=400,
|
||
template="plotly_white",
|
||
showlegend=True,
|
||
font=dict(size=12),
|
||
)
|
||
fig.show()
|
||
|
||
# %%
|
||
# Confusion matrix at median threshold
|
||
threshold = np.median(y_score)
|
||
y_pred = (y_score >= threshold).astype(int)
|
||
|
||
cm = confusion_matrix(y_true, y_pred)
|
||
tn, fp, fn, tp = cm.ravel()
|
||
|
||
print("\nConfusion Matrix (threshold = median):")
|
||
print(f" True Positives: {tp:,}")
|
||
print(f" False Positives: {fp:,}")
|
||
print(f" True Negatives: {tn:,}")
|
||
print(f" False Negatives: {fn:,}")
|
||
|
||
precision_at_thresh = tp / (tp + fp) if (tp + fp) > 0 else 0
|
||
recall_at_thresh = tp / (tp + fn) if (tp + fn) > 0 else 0
|
||
f1 = (
|
||
2 * precision_at_thresh * recall_at_thresh / (precision_at_thresh + recall_at_thresh)
|
||
if (precision_at_thresh + recall_at_thresh) > 0
|
||
else 0
|
||
)
|
||
|
||
print(f"\nAt threshold = {threshold:.4f}:")
|
||
print(f" Precision: {precision_at_thresh:.1%}")
|
||
print(f" Recall: {recall_at_thresh:.1%}")
|
||
print(f" F1 Score: {f1:.3f}")
|
||
|
||
# %% [markdown]
|
||
# ### 9.1 Library Binary Metrics
|
||
#
|
||
# Point estimates of precision/recall are noisy. `ml4t-diagnostic` provides
|
||
# `binary_classification_report()` with Wilson confidence intervals and
|
||
# statistical tests in a single call.
|
||
|
||
# %%
|
||
# Create Polars Series for library (expects 0/1 integer series)
|
||
signals_pl = pl.Series("signal", y_pred)
|
||
labels_pl = pl.Series("label", y_true)
|
||
|
||
report = binary_classification_report(signals_pl, labels_pl, confidence=0.95)
|
||
|
||
print("=== ml4t-diagnostic Binary Classification Report ===\n")
|
||
print(
|
||
f"Precision: {report.precision:.3f} CI: [{report.precision_ci[0]:.3f}, {report.precision_ci[1]:.3f}]"
|
||
)
|
||
print(
|
||
f"Recall: {report.recall:.3f} CI: [{report.recall_ci[0]:.3f}, {report.recall_ci[1]:.3f}]"
|
||
)
|
||
print(f"F1 Score: {report.f1_score:.3f}")
|
||
print(f"Lift: {report.lift:.2f}x")
|
||
print(f"Coverage: {report.coverage:.1%}")
|
||
print(f"\nBinomial p-value: {report.binomial_pvalue:.4f}")
|
||
print(f"Z-test (vs base rate): z={report.z_test_stat:.2f}, p={report.z_test_pvalue:.4f}")
|
||
|
||
# %%
|
||
# Wilson score intervals for specific metrics
|
||
prec_ci = wilson_score_interval(tp, tp + fp, confidence=0.95)
|
||
recall_ci = wilson_score_interval(tp, tp + fn, confidence=0.95)
|
||
|
||
print("\nWilson Score Intervals (95%):")
|
||
print(f" Precision: [{prec_ci[0]:.3f}, {prec_ci[1]:.3f}]")
|
||
print(f" Recall: [{recall_ci[0]:.3f}, {recall_ci[1]:.3f}]")
|
||
print("\nWilson intervals are more accurate than normal approximation CIs,")
|
||
print("especially when proportions are near 0 or 1.")
|
||
|
||
# %% [markdown]
|
||
# ### Binary Evaluation Guidelines
|
||
#
|
||
# | Metric | What It Measures | Trading Interpretation |
|
||
# |--------|------------------|------------------------|
|
||
# | **ROC AUC** | Ranking quality | > 0.55 shows signal |
|
||
# | **PR AUC** | Precision at various recalls | Use when positives are rare |
|
||
# | **Precision** | % of predicted positives correct | Matters for trade entry |
|
||
# | **Recall** | % of actual positives found | Matters for opportunity cost |
|
||
#
|
||
# **Threshold selection** depends on the trading objective:
|
||
# - High precision, low recall: fewer trades, higher win rate
|
||
# - High recall, low precision: capture more opportunities, lower win rate
|
||
# - The optimal threshold depends on costs and capacity constraints
|
||
|
||
# %% [markdown]
|
||
# ### Fold-Aware Binary Evaluation
|
||
#
|
||
# Just as with IC, we should compute ROC AUC per fold to assess out-of-sample
|
||
# classification performance.
|
||
|
||
# %%
|
||
# Compute per-fold ROC AUC
|
||
fold_auc_results = []
|
||
|
||
for fold_idx, (train_dates, test_dates) in enumerate(splits):
|
||
# Filter to test period
|
||
test_binary = binary_df.filter(pl.col("timestamp").is_in(test_dates))
|
||
|
||
if len(test_binary) < 50:
|
||
continue
|
||
|
||
# Extract arrays
|
||
y_true_fold = test_binary["binary_label"].to_numpy()
|
||
y_score_fold = test_binary["factor"].to_numpy()
|
||
|
||
# Handle NaN
|
||
mask = ~(np.isnan(y_true_fold) | np.isnan(y_score_fold))
|
||
y_true_fold = y_true_fold[mask]
|
||
y_score_fold = y_score_fold[mask]
|
||
|
||
if len(np.unique(y_true_fold)) < 2: # Need both classes
|
||
continue
|
||
|
||
try:
|
||
fold_auc = roc_auc_score(y_true_fold, y_score_fold)
|
||
fold_auc_results.append(
|
||
{
|
||
"fold": fold_idx + 1,
|
||
"n_samples": len(y_true_fold),
|
||
"base_rate": y_true_fold.mean(),
|
||
"roc_auc": fold_auc,
|
||
}
|
||
)
|
||
except ValueError:
|
||
pass # Skip if ROC cannot be computed
|
||
|
||
if fold_auc_results:
|
||
fold_auc_df = pl.DataFrame(fold_auc_results)
|
||
print("Per-Fold ROC AUC")
|
||
display(fold_auc_df)
|
||
|
||
# Summary
|
||
auc_mean = np.mean([r["roc_auc"] for r in fold_auc_results])
|
||
auc_std = np.std([r["roc_auc"] for r in fold_auc_results])
|
||
print(f"Fold-level AUC: {auc_mean:.3f} ± {auc_std:.3f}")
|
||
print(f"Global AUC: {roc_auc:.3f}")
|
||
|
||
# %% [markdown]
|
||
# ## Summary
|
||
#
|
||
# ### Key Metrics for Signal Evaluation
|
||
#
|
||
# | Metric | What It Measures | Good Range |
|
||
# |--------|------------------|------------|
|
||
# | **IC** | Cross-sectional predictability | > 0.03 (weak), > 0.05 (good) |
|
||
# | **ICIR** | Risk-adjusted IC (mean/std) | > 0.5 |
|
||
# | **Fold ICIR** | IC stability across OOS folds | > 0.3 |
|
||
# | **Spread** | Top-bottom quantile difference | Depends on costs |
|
||
# | **Monotonicity** | Quantile ordering consistency | > 80% |
|
||
# | **Turnover** | Signal stability | < 50%/period for daily signals |
|
||
# | **Half-life** | Signal decay rate | Matches rebalancing frequency |
|
||
#
|
||
# ### Fold-Aware Evaluation (Critical)
|
||
#
|
||
# Always compute metrics **per fold** using walk-forward validation:
|
||
# 1. Create expanding-window or rolling-window splits
|
||
# 2. Compute IC (or AUC) on each test fold
|
||
# 3. Report the **distribution** of fold-level metrics, not just pooled values
|
||
# 4. Check that global IC ≈ fold-level mean IC (large gaps suggest overfitting)
|
||
#
|
||
# ### API Reference
|
||
#
|
||
# ```python
|
||
# from ml4t.diagnostic.signal import analyze_signal
|
||
#
|
||
# result = analyze_signal(
|
||
# factor_df, # timestamp, symbol, factor
|
||
# prices_df, # timestamp, symbol, price
|
||
# periods=(1, 5, 21), # Forward return horizons
|
||
# quantiles=5, # Number of quantiles
|
||
# ic_method="spearman", # Rank correlation
|
||
# )
|
||
#
|
||
# # Access results
|
||
# result.ic # Mean IC by period
|
||
# result.ic_ir # ICIR by period
|
||
# result.quantile_returns # Returns by quantile
|
||
# result.spread # Top-bottom spread
|
||
# result.monotonicity # Quantile ordering
|
||
# result.turnover # Signal turnover
|
||
# result.summary() # Human-readable summary
|
||
# ```
|
||
#
|
||
# ### Next Notebooks
|
||
#
|
||
# - [`06_ic_inference`](06_ic_inference.ipynb) - HAC adjustment and block bootstrap for IC inference
|
||
# - [`07_multiple_testing`](07_multiple_testing.ipynb) - FDR control when evaluating many factors
|