项目文件夹

文件
2026-07-13 13:26:28 +08:00

987 行
34 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# ---
# 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]
# # Panel Features: Pairwise and Cross-Sectional Transforms
#
# **Docker image**: `ml4t`
#
# This notebook demonstrates panel-level temporal features: pairwise
# relationships (cointegration, Kalman hedge ratios, O-U half-life) and
# cross-sectional transforms (ranking, relative features, universe
# aggregation).
#
# Panel features matter because temporal features computed in isolation
# (volatility, momentum, regime probabilities) gain signal when placed
# in cross-sectional context — a 25% conditional volatility means
# different things for a utility stock and a biotech.
#
# **Learning Objectives**:
# - Rank temporal features cross-sectionally for relative positioning
# - Compute relative temporal features (vs. sector, vs. market)
# - Test for cointegration using Engle-Granger and Johansen methods
# - Estimate dynamic hedge ratios with Kalman filter
# - Compute Ornstein-Uhlenbeck half-life for mean-reversion speed
#
# **Book Reference**: Chapter 9, Section 9.6 (Cross-Sectional and Panel Features)
#
# **Prerequisites**: `04_kalman_filter` for Kalman filter mechanics,
# `11_hmm_regimes` for regime features used in universe aggregation.
# %%
"""Panel Features — pairwise and cross-sectional transforms for multi-asset temporal features."""
import warnings
warnings.filterwarnings("ignore", category=FutureWarning)
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import polars as pl
# Kalman filter for dynamic hedge ratio
from filterpy.kalman import KalmanFilter
from IPython.display import display
from ml4t.engineer.features.cross_asset import (
beta_to_market,
co_integration_score,
correlation_regime_indicator,
rolling_correlation,
)
from sklearn.linear_model import LinearRegression
# Cointegration tests
from statsmodels.tsa.stattools import adfuller, coint
from statsmodels.tsa.vector_ar.vecm import coint_johansen
from data import load_etfs
from utils.paths import get_case_study_dir
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
MAX_SYMBOLS = 0 # 0 = all symbols
START_DATE = "2015-01-01"
END_DATE = "2024-12-31"
# %% [markdown]
# ## Load Data
#
# We use ETF pairs from the ETF Universe dataset to demonstrate pairs trading.
# Classic cointegrated pairs include:
# - **GLD/SLV** - Gold vs Silver (precious metals)
# - **XLE/USO** - Energy sector vs Crude Oil
# - **QQQ/SMH** - Nasdaq 100 vs Semiconductors (tech exposure)
# %%
# Load ETF universe data
etf_data = load_etfs()
if MAX_SYMBOLS > 0:
symbols = etf_data["symbol"].unique().sort().to_list()[:MAX_SYMBOLS]
etf_data = etf_data.filter(pl.col("symbol").is_in(symbols))
# Filter date range
etf_data = etf_data.filter(
pl.col("timestamp").is_between(pl.lit(START_DATE).str.to_date(), pl.lit(END_DATE).str.to_date())
)
# Define pairs to test for cointegration
# Primary pair: XLE/USO (energy sector vs crude oil) with structural relationship
# Fallback pair: SPY/QQQ (available in all datasets for testing)
available_symbols = etf_data["symbol"].unique().to_list()
if "XLE" in available_symbols and "USO" in available_symbols:
PAIR_Y = "XLE" # Energy Sector ETF
PAIR_X = "USO" # Crude Oil ETF
else:
# Fallback to symbols available in test fixtures
print("Using fallback pair SPY/QQQ (XLE/USO not available)")
PAIR_Y = "SPY"
PAIR_X = "QQQ"
# Extract prices for the pair
# %%
pair_y = (
etf_data.filter(pl.col("symbol") == PAIR_Y)
.select(["timestamp", "close"])
.rename({"close": "Y"})
.sort("timestamp")
)
pair_x = (
etf_data.filter(pl.col("symbol") == PAIR_X)
.select(["timestamp", "close"])
.rename({"close": "X"})
.sort("timestamp")
)
# Join on date (inner join to align dates)
df_pl = pair_y.join(pair_x, on="timestamp", how="inner")
# Convert to pandas for statsmodels/sklearn
df = df_pl.to_pandas().set_index("timestamp")
df.index = pd.DatetimeIndex(df.index)
print(f"Pair: {PAIR_Y}/{PAIR_X}")
print(f"Data: {len(df)} observations from {df.index.min()} to {df.index.max()}")
print(f"{PAIR_Y} range: ${df['Y'].min():.2f} - ${df['Y'].max():.2f}")
print(f"{PAIR_X} range: ${df['X'].min():.2f} - ${df['X'].max():.2f}")
# %% [markdown]
# ## Cointegration Testing
#
# Two standard approaches:
# - **Engle-Granger**: Two-step test - regress Y on X, then test residuals for stationarity
# - **Johansen**: System-based test for multiple cointegrating relationships
#
# We apply both tests - if they agree, we have stronger evidence of cointegration.
# %%
# Engle-Granger cointegration test
eg_stat, eg_pval, _ = coint(df["Y"], df["X"], trend="c")[:3]
print("=== Engle-Granger Cointegration Test ===")
print(f"Test statistic: {eg_stat:.4f}")
print(f"P-value: {eg_pval:.4f}")
print(f"Conclusion: {'Cointegrated' if eg_pval < 0.05 else 'Not cointegrated'} at 5% level")
# %%
# Johansen cointegration test
joh_result = coint_johansen(df[["Y", "X"]], det_order=0, k_ar_diff=1)
trace_stats = joh_result.lr1
crit_values = joh_result.cvt[:, 1] # 95% critical values
print("\n=== Johansen Cointegration Test ===")
print(f"Trace statistic (r=0): {trace_stats[0]:.4f} (critical: {crit_values[0]:.4f})")
print(f"Trace statistic (r≤1): {trace_stats[1]:.4f} (critical: {crit_values[1]:.4f})")
# Reject if trace stat > critical value
johansen_sig = trace_stats[0] > crit_values[0]
print(f"Conclusion: {'Cointegrated' if johansen_sig else 'Not cointegrated'} at 5% level")
# Cointegrating vector (normalized)
coint_vector = joh_result.evec[:, 0]
hedge_ratio_joh = -coint_vector[1] / coint_vector[0]
print(f"\nImplied hedge ratio from Johansen: {hedge_ratio_joh:.4f}")
# %% [markdown]
# When both tests agree on cointegration, we have stronger evidence of a
# long-run equilibrium relationship. When they disagree, treat the pair
# with caution — the relationship may be fragile or sensitive to the
# sample period. The Johansen-implied hedge ratio often differs from OLS
# because the two methods weight observations differently.
# %% [markdown]
# ## Spread Construction
#
# ### Static Hedge Ratio (OLS)
#
# The simplest approach: regress Y on X to find the hedge ratio $\beta$.
# Spread $= Y - \beta X$
# %%
# OLS hedge ratio
ols = LinearRegression()
ols.fit(df[["X"]], df["Y"])
hedge_ratio_ols = ols.coef_[0]
intercept_ols = ols.intercept_
print(f"OLS Hedge Ratio: {hedge_ratio_ols:.4f}")
print(f"OLS Intercept: {intercept_ols:.4f}")
# Compute spread
df["spread_ols"] = df["Y"] - hedge_ratio_ols * df["X"]
# Test spread stationarity
adf_stat, adf_pval, _, _, _, _ = adfuller(df["spread_ols"], autolag="AIC")
print(f"\nSpread ADF test: stat={adf_stat:.4f}, p-value={adf_pval:.4f}")
print(f"Spread is {'stationary' if adf_pval < 0.05 else 'non-stationary'}")
# %% [markdown]
# ### Dynamic Hedge Ratio (Kalman Filter)
#
# The hedge ratio may change over time. A Kalman filter provides:
# - Adaptive estimation as new data arrives
# - Smooth transitions in the hedge ratio
# - Natural handling of non-stationarity in the relationship
# %%
def kalman_hedge_ratio(y: np.ndarray, x: np.ndarray) -> tuple:
"""
Estimate time-varying hedge ratio using Kalman filter.
Returns:
hedge_ratios: Array of time-varying hedge ratios
intercepts: Array of time-varying intercepts
"""
n = len(y)
# State: [intercept, hedge_ratio]
kf = KalmanFilter(dim_x=2, dim_z=1)
# Transition matrix (random walk for states)
kf.F = np.eye(2)
# Measurement function: y_t = [1, x_t] @ [alpha, beta]
# We'll update H at each step
# Initial state
kf.x = np.array([[0.0], [1.0]]) # Start with hedge ratio = 1
# Covariance matrices
kf.P = np.eye(2) * 1.0 # Initial state covariance
kf.R = np.array([[1e-3]]) # Measurement noise (small)
kf.Q = np.eye(2) * 1e-5 # Process noise (slow-changing states)
hedge_ratios = np.zeros(n)
intercepts = np.zeros(n)
for t in range(n):
# Update measurement matrix for current x value
kf.H = np.array([[1.0, x[t]]])
# Predict
kf.predict()
# Update with measurement y_t
kf.update(np.array([[y[t]]]))
# Store estimates
intercepts[t] = kf.x[0, 0]
hedge_ratios[t] = kf.x[1, 0]
return hedge_ratios, intercepts
# Apply Kalman filter
hedge_ratios_kf, intercepts_kf = kalman_hedge_ratio(df["Y"].values, df["X"].values)
df["hedge_ratio_kf"] = hedge_ratios_kf
df["spread_kf"] = df["Y"] - df["hedge_ratio_kf"] * df["X"]
print(f"Kalman hedge ratio range: [{hedge_ratios_kf.min():.4f}, {hedge_ratios_kf.max():.4f}]")
print(f"Final hedge ratio: {hedge_ratios_kf[-1]:.4f}")
# %% [markdown]
# ## Mean-Reversion Half-Life
#
# The half-life measures how quickly the spread reverts to its mean.
# Estimated from an AR(1) model: spread_t = α + ρ * spread_{t-1} + ε
# Half-life = -ln(2) / ln(ρ)
# %%
def estimate_half_life(spread: pd.Series) -> float:
"""Estimate mean-reversion half-life from spread series."""
spread = spread.dropna()
spread_lag = spread.shift(1).dropna()
spread_diff = spread.diff().dropna()
# Align indices
spread_lag = spread_lag.iloc[:-1] if len(spread_lag) > len(spread_diff) else spread_lag
# Regress spread_diff on spread_lag
X = spread_lag.values.reshape(-1, 1)
y = spread_diff.values[: len(X)]
reg = LinearRegression()
reg.fit(X, y)
phi = reg.coef_[0]
# Half-life = -ln(2) / ln(1 + phi)
# For small phi: half-life ≈ -ln(2) / phi
if phi >= 0:
return np.inf # No mean reversion
half_life = -np.log(2) / phi
return max(1, half_life)
half_life_ols = estimate_half_life(df["spread_ols"])
half_life_kf = estimate_half_life(df["spread_kf"])
print(f"Half-life (OLS spread): {half_life_ols:.1f} days")
print(f"Half-life (Kalman spread): {half_life_kf:.1f} days")
# %% [markdown]
# The half-life estimate uses full-sample OLS on the spread — in a
# walk-forward context, only past data should be used. The Kalman spread
# typically yields shorter half-lives because the adaptive hedge ratio
# removes low-frequency drift from the spread, leaving a faster-reverting residual.
# %% [markdown]
# ## Trading Signals: Z-Score and Bollinger Bands
#
# Entry/exit rules based on spread deviation from mean:
# - **Long spread** (buy Y, sell X): Z-score < -2 (spread too low)
# - **Short spread** (sell Y, buy X): Z-score > +2 (spread too high)
# - **Exit**: Z-score crosses zero (mean reversion complete)
# %%
# Z-score and Bollinger bands from Kalman spread
spread = df["spread_kf"]
lookback = max(int(2 * half_life_kf), 20) # At least 20 days
rolling_mean = spread.rolling(window=lookback).mean()
rolling_std = spread.rolling(window=lookback).std()
df["z_score"] = (spread - rolling_mean) / rolling_std
df["spread_mean"] = rolling_mean
df["upper_band"] = rolling_mean + 2 * rolling_std
df["lower_band"] = rolling_mean - 2 * rolling_std
print(f"Lookback window: {lookback} days (2x half-life)")
# %% [markdown]
# The z-score normalizes the spread by its rolling mean and standard
# deviation — it is the natural signal for mean-reverting spreads because
# cointegration implies the spread is stationary around a fixed level.
# Entry at $\pm 2\sigma$ captures significant deviations while filtering noise.
# %%
# Trading signals: enter at ±2σ, exit at mean crossing
df["signal"] = 0
df.loc[df["z_score"] < -2, "signal"] = 1 # Long spread
df.loc[df["z_score"] > 2, "signal"] = -1 # Short spread
# Position tracking (hold until mean reversion)
signals = df["signal"].values
z_scores = df["z_score"].values
positions = [0] * len(df)
in_trade = 0
for i in range(1, len(df)):
if signals[i] != 0:
in_trade = signals[i]
elif in_trade != 0:
if (in_trade > 0 and z_scores[i] > 0) or (in_trade < 0 and z_scores[i] < 0):
in_trade = 0
positions[i] = in_trade
df["position"] = positions
print(f"Number of trades: {(df['signal'] != 0).sum()}")
print(f"Days in position: {(df['position'] != 0).sum()}")
# %% [markdown]
# ## Visualize Trading System
# %%
fig, axes = plt.subplots(4, 1, figsize=(14, 14), sharex=True)
# Price series
ax = axes[0]
ax.plot(df.index, df["Y"], label=PAIR_Y, linewidth=0.8)
ax.plot(df.index, df["X"], label=PAIR_X, linewidth=0.8)
ax.set_ylabel("Price ($)")
ax.set_title(f"{PAIR_Y}/{PAIR_X} Price Series")
ax.legend()
# Hedge ratio evolution
ax = axes[1]
ax.axhline(hedge_ratio_ols, color="#d62728", linestyle="--", label=f"OLS: {hedge_ratio_ols:.3f}")
ax.plot(df.index, df["hedge_ratio_kf"], label="Kalman", linewidth=0.8)
ax.set_ylabel("Hedge Ratio")
ax.set_title("Dynamic vs Static Hedge Ratio")
ax.legend()
# Spread with Bollinger bands
ax = axes[2]
ax.plot(df.index, df["spread_kf"], label="Spread", linewidth=0.8)
ax.plot(df.index, df["spread_mean"], color="#d62728", linestyle="--", linewidth=0.8, label="Mean")
ax.fill_between(
df.index, df["lower_band"], df["upper_band"], alpha=0.2, color="gray", label="±2σ bands"
)
ax.set_ylabel("Spread")
ax.set_title("Spread with Bollinger Bands")
ax.legend()
# Z-score with entry/exit zones
ax = axes[3]
ax.plot(df.index, df["z_score"], linewidth=0.8)
ax.axhline(2, color="#d62728", linestyle="--", alpha=0.5, label="Short entry")
ax.axhline(-2, color="#1f77b4", linestyle="--", alpha=0.5, label="Long entry")
ax.axhline(0, color="black", linestyle="-", alpha=0.3)
ax.fill_between(df.index, -0.5, 0.5, alpha=0.1, color="gray", label="Exit zone")
ax.set_ylabel("Z-Score")
ax.set_title("Z-Score with Entry/Exit Thresholds")
ax.legend()
plt.tight_layout()
plt.show()
# %% [markdown]
# ## Illustrative Backtest
#
# A compact backtest demonstrates how cointegration features translate into
# tradable signals. This ignores transaction costs and slippage — see
# Chapters 18–19 for proper strategy evaluation with realistic cost models.
# %%
# Spread returns (long Y, short X when position = 1)
df["ret_y"] = df["Y"].pct_change()
df["ret_x"] = df["X"].pct_change()
df["spread_ret"] = df["position"].shift(1) * (df["ret_y"] - df["ret_x"])
df["spread_ret"] = df["spread_ret"].fillna(0)
df["cumulative_ret"] = (1 + df["spread_ret"]).cumprod() - 1
total_return = df["cumulative_ret"].iloc[-1]
ann_return = (1 + total_return) ** (252 / len(df)) - 1
volatility = df["spread_ret"].std() * np.sqrt(252)
sharpe = ann_return / volatility if volatility > 0 else 0
rolling_max = (1 + df["cumulative_ret"]).cummax()
max_dd = ((1 + df["cumulative_ret"]) / rolling_max - 1).min()
backtest_summary = pd.DataFrame(
{
"Total Return": [f"{total_return:.1%}"],
"Ann. Return": [f"{ann_return:.1%}"],
"Ann. Vol": [f"{volatility:.1%}"],
"Sharpe": [f"{sharpe:.2f}"],
"Max DD": [f"{max_dd:.1%}"],
}
)
display(backtest_summary)
# %% [markdown]
# ## Summary: Primary Pair Analysis
# %%
summary_df = pd.DataFrame(
{
"Metric": [
"Observations",
"EG p-value",
"Johansen sig.",
"OLS hedge ratio",
"Kalman hedge ratio (final)",
"Half-life (days)",
],
"Value": [
f"{len(df):,}",
f"{eg_pval:.4f}",
str(johansen_sig),
f"{hedge_ratio_ols:.4f}",
f"{hedge_ratios_kf[-1]:.4f}",
f"{half_life_kf:.1f}",
],
}
)
display(summary_df)
# %% [markdown]
# ## Cointegration Screening: Multiple ETF Pairs
#
# In practice, we screen multiple pairs to find cointegrated relationships.
# Not all pairs that seem related are actually cointegrated.
# %%
# Screen multiple ETF pairs for cointegration
CANDIDATE_PAIRS = [
("GLD", "SLV", "Gold vs Silver"),
("XLE", "USO", "Energy vs Crude Oil"),
("QQQ", "SMH", "Nasdaq 100 vs Semis"),
("SPY", "VTI", "S&P 500 vs Total Mkt"),
("TLT", "IEF", "Long vs Mid Treasuries"),
("EEM", "VWO", "EM (iShares vs Vanguard)"),
]
screening_results = []
# %%
for pair_y, pair_x, description in CANDIDATE_PAIRS:
y_data = (
etf_data.filter(pl.col("symbol") == pair_y).select(["timestamp", "close"]).sort("timestamp")
)
x_data = (
etf_data.filter(pl.col("symbol") == pair_x).select(["timestamp", "close"]).sort("timestamp")
)
if len(y_data) < 252 or len(x_data) < 252:
continue
y_df = y_data.rename({"close": "Y"})
x_df = x_data.rename({"close": "X"})
pair_df = y_df.join(x_df, on="timestamp", how="inner").to_pandas().set_index("timestamp")
if len(pair_df) < 252:
continue
# Engle-Granger test
eg_stat, eg_pval_pair, _ = coint(pair_df["Y"], pair_df["X"], trend="c")[:3]
# Johansen test
joh = coint_johansen(pair_df[["Y", "X"]], det_order=0, k_ar_diff=1)
joh_sig = joh.lr1[0] > joh.cvt[0, 1]
# Hedge ratio and half-life
ols_temp = LinearRegression()
ols_temp.fit(pair_df[["X"]], pair_df["Y"])
hedge = ols_temp.coef_[0]
hl = estimate_half_life(pair_df["Y"] - hedge * pair_df["X"])
screening_results.append(
{
"Pair": f"{pair_y}/{pair_x}",
"Description": description,
"Obs": len(pair_df),
"EG p-value": eg_pval_pair,
"Johansen": "Yes" if joh_sig else "No",
"Cointegrated": eg_pval_pair < 0.05,
"Hedge Ratio": hedge,
"Half-life (d)": hl,
}
)
# %%
# Display screening results as a formatted table
screening_df = pd.DataFrame(screening_results)
display_df = screening_df[
["Pair", "Description", "EG p-value", "Johansen", "Hedge Ratio", "Half-life (d)"]
].copy()
display_df["EG p-value"] = display_df["EG p-value"].map(lambda x: f"{x:.4f}")
display_df["Hedge Ratio"] = display_df["Hedge Ratio"].map(lambda x: f"{x:.3f}")
display_df["Half-life (d)"] = display_df["Half-life (d)"].map(
lambda x: f"{x:.1f}" if x < 1000 else "N/A"
)
display(display_df)
# %% [markdown]
# Economic relatedness does not guarantee cointegration — several
# apparently related pairs fail the Engle-Granger test, and the two
# tests sometimes disagree. Half-lives range from days to months,
# reflecting the speed at which different pair relationships mean-revert.
# Only pairs with short half-lives (days to weeks) are practical for
# trading at daily frequency.
# %% [markdown]
# ## Save Pairs Trading Features for Downstream Chapters
#
# Spread signals and hedge ratios are consumed by:
# - Chapter 18: Strategy simulation (pairs trading backtest)
# - Chapter 19: Portfolio construction (market-neutral portfolios)
# %%
# Save pairs trading data to output directory for ALL cointegrated pairs
MODEL_DIR = get_case_study_dir("etfs") / "models" / "time_series"
MODEL_DIR.mkdir(parents=True, exist_ok=True)
# %% [markdown]
# ### Generate Signals for Cointegrated Pairs
#
# Package spread signals and hedge ratios for all cointegrated pairs,
# saving for downstream strategy evaluation in Chapters 18–19.
# %%
def generate_pair_signals(pair_y: str, pair_x: str) -> pl.DataFrame | None:
"""Generate Kalman-filtered spread signals for a single pair."""
y_data = (
etf_data.filter(pl.col("symbol") == pair_y).select(["timestamp", "close"]).sort("timestamp")
)
x_data = (
etf_data.filter(pl.col("symbol") == pair_x).select(["timestamp", "close"]).sort("timestamp")
)
if len(y_data) < 252 or len(x_data) < 252:
return None
y_df = y_data.rename({"close": "Y"})
x_df = x_data.rename({"close": "X"})
pair_df = y_df.join(x_df, on="timestamp", how="inner").to_pandas().set_index("timestamp")
pair_df.index = pd.DatetimeIndex(pair_df.index)
if len(pair_df) < 252:
return None
# Kalman filter hedge ratio and spread
kf_hedges, _ = kalman_hedge_ratio(pair_df["Y"].values, pair_df["X"].values)
pair_df["spread_kf"] = pair_df["Y"] - kf_hedges * pair_df["X"]
# Z-score from half-life based lookback
hl = estimate_half_life(pair_df["spread_kf"])
lb = max(int(2 * hl), 20)
rm = pair_df["spread_kf"].rolling(window=lb).mean()
rs = pair_df["spread_kf"].rolling(window=lb).std()
pair_df["z_score"] = (pair_df["spread_kf"] - rm) / rs
pair_df["signal"] = 0
pair_df.loc[pair_df["z_score"] < -2, "signal"] = 1
pair_df.loc[pair_df["z_score"] > 2, "signal"] = -1
return pl.DataFrame(
{
"timestamp": pair_df.index.values,
"pair": f"{pair_y}/{pair_x}",
"asset_y": pair_y,
"asset_x": pair_x,
"hedge_ratio": kf_hedges,
"spread": pair_df["spread_kf"].values,
"z_score": pair_df["z_score"].values,
"signal": pair_df["signal"].values,
}
).drop_nulls()
# %%
# Apply to all cointegrated pairs
all_signals = []
cointegrated_pairs = [r for r in screening_results if r["Cointegrated"]]
print(f"Processing {len(cointegrated_pairs)} cointegrated pairs...")
for r in cointegrated_pairs:
pair_y, pair_x = r["Pair"].split("/")
signals = generate_pair_signals(pair_y, pair_x)
if signals is not None:
all_signals.append(signals)
# Also include the primary pair if not already included
if f"{PAIR_Y}/{PAIR_X}" not in [r["Pair"] for r in cointegrated_pairs]:
primary_signals = pl.DataFrame(
{
"timestamp": df.index.values,
"pair": f"{PAIR_Y}/{PAIR_X}",
"asset_y": PAIR_Y,
"asset_x": PAIR_X,
"hedge_ratio": df["hedge_ratio_kf"].values,
"spread": df["spread_kf"].values,
"z_score": df["z_score"].values,
"signal": df["signal"].values,
}
).drop_nulls()
all_signals.append(primary_signals)
print(f" {PAIR_Y}/{PAIR_X}: {len(primary_signals)} obs (primary)")
# %%
# Combine and save
if all_signals:
combined_signals = pl.concat(all_signals)
output_path = MODEL_DIR / "pairs_trading_signals.parquet"
combined_signals.write_parquet(output_path)
print(f"\n[OK] Saved pairs trading signals to {output_path}")
print(f" Shape: {combined_signals.shape}")
print(f" Pairs: {combined_signals['pair'].unique().to_list()}")
print(
f" Date range: {combined_signals['timestamp'].min()} to {combined_signals['timestamp'].max()}"
)
# Per-pair summary
print("\n Per-pair summary:")
summary = combined_signals.group_by("pair").agg(pl.len().alias("n_rows")).sort("pair")
for row in summary.iter_rows(named=True):
print(f" {row['pair']}: {row['n_rows']:,} rows")
else:
print("\nWARNING: No pairs trading signals generated")
# %% [markdown]
# ## ml4t-engineer: Cross-Asset Features as Polars Expressions
#
# The manual pairwise analysis above uses statsmodels and numpy for each pair.
# `ml4t-engineer` provides cross-asset features as Polars expressions for
# multi-pair pipelines: rolling correlation, beta, dispersion, and
# cointegration scoring.
# %%
# Demonstrate cross-asset features on a pair
gld = etf_data.filter(pl.col("symbol") == "GLD").select(["timestamp", "close"]).sort("timestamp")
slv = etf_data.filter(pl.col("symbol") == "SLV").select(["timestamp", "close"]).sort("timestamp")
pair_df = (
gld.rename({"close": "gld_close"})
.join(slv.rename({"close": "slv_close"}), on="timestamp", how="inner")
.with_columns(
gld_ret=pl.col("gld_close").pct_change(),
slv_ret=pl.col("slv_close").pct_change(),
)
.drop_nulls()
)
# Cross-asset features in one pipeline
pair_features = pair_df.with_columns(
corr=rolling_correlation("gld_ret", "slv_ret", window=60),
beta=beta_to_market("slv_ret", "gld_ret", window=60),
coint_score=co_integration_score("gld_close", "slv_close", window=120),
)
# Correlation regime indicators (returns dict)
corr_regime = correlation_regime_indicator("corr")
pair_features = pair_features.with_columns(**corr_regime)
print("=== ml4t-engineer: Cross-Asset Features (GLD/SLV) ===")
for col in ["corr", "beta", "coint_score"]:
vals = pair_features[col].drop_nulls()
_m, _s = vals.mean(), vals.std()
print(
f" {col:<14}: mean={_m:.4f}, std={_s:.4f}"
if _m is not None
else f" {col:<14}: insufficient data"
)
# %% [markdown]
# The library expressions compose naturally in `with_columns()`, making it
# straightforward to compute cross-asset features across many pairs in a
# single Polars pipeline. `correlation_regime_indicator()` adds binary
# regime flags based on rolling correlation levels — useful for
# conditional feature engineering.
# %% [markdown]
# ## Cross-Sectional Ranking of Temporal Features
#
# Raw temporal features (momentum, conditional volatility, regime
# probabilities) vary in scale across assets. Cross-sectional ranking
# converts them to universe-relative signals — essential for panel models
# that predict cross-sectional returns.
# %%
# Compute temporal features for a multi-asset ETF universe
RANK_SYMBOLS = ["SPY", "QQQ", "IWM", "EFA", "EEM", "TLT", "GLD", "XLE", "XLF", "XLV"]
rank_symbols = [s for s in RANK_SYMBOLS if s in available_symbols]
panel = (
etf_data.filter(pl.col("symbol").is_in(rank_symbols))
.select(["timestamp", "symbol", "close"])
.sort(["symbol", "timestamp"])
)
# Temporal features: momentum (60d return) and volatility (60d rolling std of returns)
panel = (
panel.with_columns(
ret=pl.col("close").pct_change().over("symbol"),
)
.with_columns(
momentum_60d=pl.col("ret").rolling_mean(60).over("symbol"),
vol_60d=pl.col("ret").rolling_std(60).over("symbol"),
)
.drop_nulls()
)
print(f"Panel: {panel['symbol'].n_unique()} assets, {len(panel):,} obs")
# %%
# Cross-sectional rank, percentile, and z-score at each date
panel = panel.with_columns(
rank_momentum=pl.col("momentum_60d").rank().over("timestamp"),
rank_vol=pl.col("vol_60d").rank().over("timestamp"),
pct_momentum=(pl.col("momentum_60d").rank().over("timestamp") - 1)
/ (pl.col("momentum_60d").count().over("timestamp") - 1),
zscore_momentum=(
(pl.col("momentum_60d") - pl.col("momentum_60d").mean().over("timestamp"))
/ pl.col("momentum_60d").std().over("timestamp")
),
)
# Show a snapshot for one date
snapshot_date = panel["timestamp"].unique().sort().to_list()[-252] # ~1 year ago
snapshot = (
panel.filter(pl.col("timestamp") == snapshot_date)
.select(["symbol", "momentum_60d", "rank_momentum", "pct_momentum", "zscore_momentum"])
.sort("rank_momentum")
)
print(f"\nCross-sectional snapshot ({snapshot_date}):")
display(snapshot.to_pandas())
# %%
# Visualize rank evolution over time for selected assets
fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
highlight = ["SPY", "GLD", "XLE"]
for sym in highlight:
sym_data = panel.filter(pl.col("symbol") == sym).to_pandas()
axes[0].plot(sym_data["timestamp"], sym_data["pct_momentum"], label=sym, linewidth=0.8)
axes[1].plot(sym_data["timestamp"], sym_data["rank_vol"], label=sym, linewidth=0.8)
axes[0].set_ylabel("Momentum Percentile")
axes[0].set_title("Cross-Sectional Momentum Percentile Over Time")
axes[0].legend()
axes[0].axhline(0.5, color="gray", linestyle="--", alpha=0.3)
axes[1].set_ylabel("Volatility Rank")
axes[1].set_title("Cross-Sectional Volatility Rank Over Time")
axes[1].legend()
plt.tight_layout()
plt.show()
# %% [markdown]
# Ranks are bounded, stationary, and immune to outliers. A high
# momentum rank (top of universe) followed by rank decay signals
# momentum exhaustion — a feature that absolute momentum values
# cannot express because their scale drifts with market conditions.
# %% [markdown]
# ## Relative Temporal Features
#
# Many temporal features gain signal when expressed relative to a
# benchmark — isolating idiosyncratic dynamics from systematic exposure.
# We demonstrate market-relative features (vs SPY). Sector-relative
# features follow the same pattern with a sector ETF as benchmark
# (e.g., XLE momentum minus XLF momentum isolates energy-specific signal).
# %%
# Compute relative features: asset vs. market (SPY as proxy)
spy_features = (
panel.filter(pl.col("symbol") == "SPY")
.select(["timestamp", "momentum_60d", "vol_60d"])
.rename({"momentum_60d": "mkt_momentum", "vol_60d": "mkt_vol"})
)
panel_rel = panel.join(spy_features, on="timestamp", how="inner")
panel_rel = panel_rel.with_columns(
momentum_vs_market=pl.col("momentum_60d") - pl.col("mkt_momentum"),
vol_vs_market=pl.col("vol_60d") / pl.col("mkt_vol"),
)
# Compare absolute vs relative for one asset
xle = panel_rel.filter(pl.col("symbol") == "XLE").to_pandas()
fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
axes[0].plot(xle["timestamp"], xle["momentum_60d"], label="Absolute momentum", linewidth=0.8)
axes[0].plot(
xle["timestamp"], xle["momentum_vs_market"], label="Momentum vs. market", linewidth=0.8
)
axes[0].axhline(0, color="gray", linestyle="--", alpha=0.3)
axes[0].set_ylabel("60-Day Momentum")
axes[0].set_title("XLE: Absolute vs. Relative Momentum")
axes[0].legend()
# Absolute vol (~0.005 daily) and the vol-ratio (~1-3.5) live on different scales,
# so use twin y-axes to keep both visible.
ax_abs = axes[1]
ax_rel = ax_abs.twinx()
(l_abs,) = ax_abs.plot(
xle["timestamp"], xle["vol_60d"], label="Absolute volatility", linewidth=0.8, color="#1f77b4"
)
(l_rel,) = ax_rel.plot(
xle["timestamp"],
xle["vol_vs_market"],
label="Vol / Market vol",
linewidth=0.8,
color="#d62728",
)
ax_rel.axhline(1.0, color="gray", linestyle="--", alpha=0.3)
ax_abs.set_ylabel("Absolute Volatility (60-day)", color="#1f77b4")
ax_rel.set_ylabel("Vol / Market Vol", color="#d62728")
ax_abs.tick_params(axis="y", labelcolor="#1f77b4")
ax_rel.tick_params(axis="y", labelcolor="#d62728")
ax_abs.set_title("XLE: Absolute vs. Relative Volatility")
ax_abs.legend(handles=[l_abs, l_rel], loc="upper left")
plt.tight_layout()
plt.show()
# %% [markdown]
# Relative momentum removes the market component — XLE's energy-sector
# bets become visible only after subtracting the broad equity trend.
# Relative volatility (vol / market vol) distinguishes periods where an
# asset is genuinely more volatile from periods where the entire market
# is stressed. Values above 1.0 indicate the asset carries more risk
# than the market; values below 1.0 indicate relative calm.
# %% [markdown]
# ## Multi-Asset Regime Aggregation (Illustrative)
#
# Individual regime classifications (from `11_hmm_regimes`) gain
# power when aggregated across the universe. We approximate per-asset
# regime probabilities from realized volatility percentiles to
# demonstrate the aggregation mechanics. This is exploratory — the
# full-history ranking introduces look-ahead. In production, use
# HMM-filtered regime probabilities estimated within walk-forward folds.
# %%
# Illustrative regime probabilities from rolling volatility (proxy for HMM crisis probs).
# WARNING: This ranks each asset's volatility over its FULL history — a look-ahead
# shortcut for demonstrating the aggregation mechanics. In production, use per-asset
# HMM filtered probabilities estimated inside walk-forward folds (see 11_hmm_regimes).
vol_panel = panel.select(["timestamp", "symbol", "vol_60d"]).with_columns(
crisis_prob=(
pl.col("vol_60d").rank().over(["symbol"]) / pl.col("vol_60d").count().over(["symbol"])
),
)
# Universe-level aggregations at each timestamp
regime_agg = (
vol_panel.group_by("timestamp")
.agg(
universe_crisis_prob=pl.col("crisis_prob").mean(),
crisis_breadth=(pl.col("crisis_prob") > 0.7).mean(), # fraction above 70th pct
regime_dispersion=pl.col("crisis_prob").std(),
)
.sort("timestamp")
)
# Plot against SPY returns
spy_ret = (
etf_data.filter(pl.col("symbol") == "SPY")
.select(["timestamp", "close"])
.sort("timestamp")
.with_columns(spy_cumret=(pl.col("close") / pl.col("close").first() - 1) * 100)
)
agg_pd = regime_agg.join(
spy_ret.select(["timestamp", "spy_cumret"]), on="timestamp", how="inner"
).to_pandas()
# %%
fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)
axes[0].plot(agg_pd["timestamp"], agg_pd["spy_cumret"], linewidth=0.8, color="gray")
axes[0].set_ylabel("SPY Cumulative Return (%)")
axes[0].set_title("Market Context")
axes[1].plot(agg_pd["timestamp"], agg_pd["crisis_breadth"], linewidth=0.8, color="#d62728")
axes[1].fill_between(agg_pd["timestamp"], 0, agg_pd["crisis_breadth"], alpha=0.2, color="#d62728")
axes[1].set_ylabel("Crisis Breadth")
axes[1].set_title("Fraction of Assets in High-Volatility Regime")
axes[2].plot(agg_pd["timestamp"], agg_pd["regime_dispersion"], linewidth=0.8, color="#1f77b4")
axes[2].set_ylabel("Regime Dispersion")
axes[2].set_title("Cross-Sectional Dispersion of Regime Probabilities")
plt.tight_layout()
plt.show()
# %% [markdown]
# Crisis breadth spikes during market selloffs (visible as SPY drawdowns),
# confirming that aggregating individual regime indicators produces
# a useful system-wide stress signal. Regime dispersion is low during
# broad crises (all assets move together) and high during sector
# rotations (differentiated behavior) — making it a complement to
# crisis breadth rather than a substitute.
# %% [markdown]
# ## Key Takeaways
#
# 1. **Cross-sectional ranking normalizes temporal features** — ranks are
# scale-invariant, outlier-robust, and stationary, making heterogeneous
# assets comparable for panel models
# 2. **Relative features decompose alpha from beta** — asset momentum minus
# sector momentum isolates idiosyncratic signal from systematic exposure
# 3. **Two cointegration tests for robustness** — Engle-Granger and Johansen
# may disagree; treat pairs with caution when they do
# 4. **Kalman filter adapts** — dynamic hedge ratios track structural shifts
# that a static OLS estimate misses
# 5. **Half-life guides lookback** — use 2x half-life for rolling z-score
# windows; short half-lives are more practical for trading
# 6. **Screen multiple pairs** — economic relatedness does not guarantee
# cointegration; the `ml4t-engineer` library scales this analysis
# with Polars-native expressions
#
# **Previous**: `13_regime_as_feature` for regime features in ML pipelines.
# **Book**: Chapter 9, Section 9.6 discusses cross-sectional ranking,
# relative features, and panel aggregation in depth.