649 lines
23 KiB
Python
649 lines
23 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]
|
||
# # Path Signatures for Time Series Feature Engineering
|
||
#
|
||
# **Docker image**: `ml4t-py312`
|
||
#
|
||
# > **Docker required**: This notebook uses `esig`, which is an x86-only package
|
||
# > not included in the default environment. Run with:
|
||
# > ```bash
|
||
# > docker compose --profile py312 run --rm py312 python 09_model_based_features/06_path_signatures.py
|
||
# > ```
|
||
#
|
||
# Path signatures provide a mathematical framework for capturing sequential information
|
||
# in time series. Unlike lagged features that compress history into fixed windows, signatures
|
||
# encode the complete path dynamics — direction changes, reversals, and higher-order patterns.
|
||
#
|
||
# **Learning Objectives**:
|
||
# - Understand path signatures as feature extraction for ordered data
|
||
# - Compute signatures using the esig library with time augmentation
|
||
# - Apply depth truncation and log-signatures for dimensionality control
|
||
# - Compare signature features against traditional lag features for predictability
|
||
#
|
||
# **Book Reference**: Chapter 9, Section 9.2 (Signal Transforms and Spectral Features)
|
||
#
|
||
# **Prerequisites**: `04_kalman_filter` and `05_spectral_features` for other
|
||
# signal transform approaches.
|
||
|
||
# %%
|
||
"""Path Signatures for Time Series Feature Engineering — extract sequential features using signature transforms."""
|
||
|
||
import importlib
|
||
import warnings
|
||
|
||
warnings.filterwarnings("ignore")
|
||
|
||
import matplotlib.pyplot as plt
|
||
import numpy as np
|
||
import polars as pl
|
||
from ml4t.diagnostic.metrics import pooled_ic
|
||
from sklearn.ensemble import GradientBoostingRegressor
|
||
from sklearn.metrics import r2_score
|
||
|
||
from data import load_etfs
|
||
from utils.reproducibility import set_global_seeds
|
||
|
||
# %% tags=["parameters"]
|
||
# Production defaults — Papermill injects overrides for CI
|
||
WINDOW_SIZE = 20
|
||
FORECAST_HORIZON = 5
|
||
SIGNATURE_DEPTH = 3
|
||
SEED = 42
|
||
|
||
# %%
|
||
set_global_seeds(SEED)
|
||
|
||
# %%
|
||
# `esig` ships only in the py312 image (x86 only); fail loud if we're
|
||
# running outside it.
|
||
try:
|
||
esig = importlib.import_module("esig")
|
||
except ImportError as exc:
|
||
raise ImportError(
|
||
"`esig` is not available in the current image.\n"
|
||
"This notebook runs in the `ml4t-py312` image:\n"
|
||
" docker compose --profile py312 run --rm py312 \\\n"
|
||
" python 09_model_based_features/06_path_signatures.py"
|
||
) from exc
|
||
|
||
# %% [markdown]
|
||
# ## What Are Path Signatures?
|
||
#
|
||
# A **path signature** is a mathematical object that summarizes a continuous path's shape.
|
||
# For a $d$-dimensional path $X: [0,T] \rightarrow \mathbb{R}^d$, the signature collects
|
||
# all iterated integrals up to a given depth:
|
||
#
|
||
# $$S(X)^{i_1, \ldots, i_k} = \int_{0 < t_1 < \cdots < t_k < T} dX_{t_1}^{i_1} \cdots dX_{t_k}^{i_k}$$
|
||
#
|
||
# At **depth 1**, the signature records the total displacement along each coordinate —
|
||
# equivalent to net return. At **depth 2**, cross-terms appear: $S^{1,2}$ captures the
|
||
# "area" swept between dimensions 1 and 2, encoding whether dimension 1 moved before or
|
||
# after dimension 2 (lead-lag structure). Higher depths capture progressively finer
|
||
# path geometry.
|
||
#
|
||
# **Key properties for feature engineering**:
|
||
# - **Reparameterization invariance**: Signatures capture *what* happened, not *when* — the
|
||
# same price trajectory traversed quickly or slowly yields identical features.
|
||
# - **Universality**: Different paths produce different signatures (the signature separates paths).
|
||
# - **Truncation**: The full signature is infinite-dimensional. In practice, truncation at depth
|
||
# $K$ (typically 2–4) retains the most informative terms.
|
||
#
|
||
# For finance: a price path rising from 100 to 110 then falling to 105 has a different
|
||
# signature than one that falls then rises, even if both end at 105.
|
||
|
||
# %% [markdown]
|
||
# ### Truncation Depth and Feature Interpretation
|
||
#
|
||
# Each truncation depth adds a new "layer" of path structure:
|
||
#
|
||
# | Depth | Terms | Interpretation | Example (price + volume path) |
|
||
# |-------|-------|---------------|-------------------------------|
|
||
# | 1 | $d$ | Total displacement (net changes) | Net return, net volume change |
|
||
# | 2 | $d + d^2$ | Pairwise interactions (lead-lag, area) | Did volume lead or lag the price move? |
|
||
# | 3 | $d + d^2 + d^3$ | Third-order interactions | Acceleration patterns, reversal sequences |
|
||
#
|
||
# **Log-signatures** provide a more compact alternative. The log-signature lives in the
|
||
# free Lie algebra and removes algebraic redundancies present in the full signature.
|
||
# For a $d$-dimensional path at depth $K$, the log-signature has dimension
|
||
# $\sum_{k=1}^{K} \frac{1}{k} \sum_{j|k} \mu(k/j) \, d^j$ (Möbius function),
|
||
# which is substantially smaller than the full signature's $\sum_{k=1}^{K} d^k$ terms.
|
||
# In practice, log-signatures are preferred for regression tasks: more compact, less
|
||
# redundant, and equally expressive.
|
||
|
||
# %% [markdown]
|
||
# ### Time Augmentation
|
||
#
|
||
# For financial data, **time-augmented paths** are essential. Without time as an explicit
|
||
# coordinate, a path that rises from 100 to 110 and one that falls from 100 to 90 differ
|
||
# only in sign — but a path rising then falling (ending at 100) and one falling then rising
|
||
# (also ending at 100) produce *identical* depth-1 signatures. Adding a monotonically
|
||
# increasing time coordinate $t \in [0, 1]$ breaks this ambiguity: the depth-2 cross-term
|
||
# between time and price captures whether the path rose early or late.
|
||
#
|
||
# The augmented path takes the form $(t, \text{price}_t, \text{volume}_t, \ldots)$. For a
|
||
# 5-day window on an ETF with price rising then falling, the depth-1 signature captures
|
||
# net return $\approx 0$; the depth-2 cross-term between time and price captures whether
|
||
# the peak occurred early or late in the window — information invisible to standard lag
|
||
# features. The depth-2 cross-term between price and volume captures whether volume led or
|
||
# lagged the reversal.
|
||
|
||
# %% [markdown]
|
||
# ## Load Data
|
||
|
||
# %%
|
||
# Load ETF universe (filtered to major index ETFs for demonstration)
|
||
symbols = ["SPY", "QQQ", "IWM", "EFA"]
|
||
df = load_etfs(symbols=symbols)
|
||
df = df.sort(["symbol", "timestamp"])
|
||
|
||
# Check date range
|
||
print(f"Assets: {df['symbol'].unique().to_list()}")
|
||
print(f"Date range: {df['timestamp'].min()} to {df['timestamp'].max()}")
|
||
print(f"Total rows: {len(df):,}")
|
||
|
||
# %% [markdown]
|
||
# ## Prepare Price Paths
|
||
#
|
||
# For signatures, we need to convert discrete price series into paths.
|
||
# We'll use overlapping windows to create many training examples.
|
||
|
||
# %%
|
||
# WINDOW_SIZE, FORECAST_HORIZON, SIGNATURE_DEPTH set in parameters cell
|
||
|
||
|
||
def create_paths_and_targets(
|
||
symbol_df: pl.DataFrame, window_size: int = 20, horizon: int = 5
|
||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||
"""
|
||
Create overlapping windows for signature computation.
|
||
|
||
Returns:
|
||
paths: Array of shape (n_windows, window_size, n_features)
|
||
targets: Forward returns
|
||
dates: End dates for each window
|
||
"""
|
||
# Compute returns and normalize prices
|
||
symbol_df = symbol_df.sort("timestamp")
|
||
symbol_df = symbol_df.with_columns(
|
||
ret=pl.col("close").pct_change(),
|
||
log_ret=pl.col("close").log().diff(),
|
||
norm_vol=(pl.col("volume") - pl.col("volume").mean()) / pl.col("volume").std(),
|
||
).drop_nulls()
|
||
|
||
# Compute forward returns for targets
|
||
symbol_df = symbol_df.with_columns(
|
||
fwd_ret=pl.col("close").pct_change(horizon).shift(-horizon),
|
||
)
|
||
|
||
# Convert to numpy for windowing
|
||
timestamps = symbol_df["timestamp"].to_numpy()
|
||
closes = symbol_df["close"].to_numpy()
|
||
returns = symbol_df["ret"].to_numpy()
|
||
fwd_returns = symbol_df["fwd_ret"].to_numpy()
|
||
|
||
# Create overlapping windows
|
||
n = len(symbol_df)
|
||
n_windows = n - window_size - horizon + 1
|
||
|
||
if n_windows <= 0:
|
||
return np.array([]), np.array([]), np.array([])
|
||
|
||
# Each path has (time, price, return) - 3D path for richer signatures
|
||
paths = []
|
||
targets = []
|
||
dates = []
|
||
|
||
for i in range(n_windows):
|
||
# Normalize time to [0, 1]
|
||
t = np.linspace(0, 1, window_size).reshape(-1, 1)
|
||
|
||
# Normalize price within window (start at 0)
|
||
window_closes = closes[i : i + window_size]
|
||
norm_price = (window_closes - window_closes[0]) / window_closes[0]
|
||
norm_price = norm_price.reshape(-1, 1)
|
||
|
||
# Cumulative returns
|
||
window_rets = returns[i : i + window_size]
|
||
cum_ret = np.cumsum(window_rets).reshape(-1, 1)
|
||
|
||
# Combine into path: (time, price, cumulative return)
|
||
path = np.hstack([t, norm_price, cum_ret])
|
||
paths.append(path)
|
||
|
||
# Target: forward return at window end
|
||
target_idx = i + window_size - 1
|
||
targets.append(fwd_returns[target_idx])
|
||
dates.append(timestamps[target_idx])
|
||
|
||
paths = np.array(paths)
|
||
targets = np.array(targets)
|
||
dates = np.array(dates)
|
||
|
||
# Remove NaN targets
|
||
valid_mask = ~np.isnan(targets)
|
||
return paths[valid_mask], targets[valid_mask], dates[valid_mask]
|
||
|
||
|
||
# Create paths for SPY
|
||
spy = df.filter(pl.col("symbol") == "SPY")
|
||
paths, targets, dates = create_paths_and_targets(spy, WINDOW_SIZE, FORECAST_HORIZON)
|
||
print(f"Created {len(paths)} paths of shape {paths.shape[1:]}")
|
||
print(f"Target stats: mean={targets.mean():.4f}, std={targets.std():.4f}")
|
||
|
||
# %% [markdown]
|
||
# ## Compute Path Signatures
|
||
#
|
||
# The `esig` library computes signatures efficiently. We use:
|
||
# - **Depth 3**: Captures up to third-order interactions between path components
|
||
# - **Log signature**: A more compact representation that avoids redundancy
|
||
|
||
|
||
# %%
|
||
def compute_signatures(paths: np.ndarray, depth: int = 3) -> np.ndarray:
|
||
"""Compute path signatures for a batch of paths."""
|
||
n_paths = len(paths)
|
||
signatures = []
|
||
|
||
for i in range(n_paths):
|
||
path = paths[i]
|
||
# esig expects paths as 2D arrays (n_points, n_dims)
|
||
sig = esig.stream2sig(path, depth)
|
||
signatures.append(sig)
|
||
|
||
return np.array(signatures)
|
||
|
||
|
||
# %%
|
||
def compute_logsignatures(paths: np.ndarray, depth: int = 3) -> np.ndarray:
|
||
"""Compute log signatures (more compact) for a batch of paths."""
|
||
n_paths = len(paths)
|
||
logsigs = []
|
||
|
||
for i in range(n_paths):
|
||
path = paths[i]
|
||
logsig = esig.stream2logsig(path, depth)
|
||
logsigs.append(logsig)
|
||
|
||
return np.array(logsigs)
|
||
|
||
|
||
# Compute both signature types
|
||
signatures = compute_signatures(paths, SIGNATURE_DEPTH)
|
||
logsignatures = compute_logsignatures(paths, SIGNATURE_DEPTH)
|
||
|
||
print(f"Signature dimension (depth {SIGNATURE_DEPTH}): {signatures.shape[1]}")
|
||
print(f"Log-signature dimension (depth {SIGNATURE_DEPTH}): {logsignatures.shape[1]}")
|
||
|
||
# %% [markdown]
|
||
# ### Computational Complexity
|
||
#
|
||
# The signature dimension grows exponentially with depth: for a $d$-dimensional path
|
||
# truncated at depth $K$, the full signature has $\sum_{k=1}^{K} d^k$ terms. Our
|
||
# 3-dimensional path (time, price, cumulative return) at depth 3 produces
|
||
# $3 + 9 + 27 = 39$ features. A 5-dimensional path (e.g., multi-asset) at depth 3
|
||
# would produce $5 + 25 + 125 = 155$ features per window.
|
||
#
|
||
# Log-signatures are substantially more compact: the same 3D path at depth 3 yields
|
||
# only 14 log-signature terms (vs. 39 full signature terms). This compactness makes
|
||
# log-signatures the pragmatic default for production use.
|
||
#
|
||
# **Practical guideline**: Truncation at depth 2 is sufficient for most daily-frequency
|
||
# applications. Depth 3 adds marginal value for intraday data where third-order
|
||
# interactions (acceleration patterns) carry signal. Depth 4+ rarely justifies the
|
||
# exponential feature growth and overfitting risk.
|
||
|
||
# %% [markdown]
|
||
# ## Baseline: Traditional Lag Features
|
||
#
|
||
# For comparison, we create standard technical features from the same windows.
|
||
|
||
|
||
# %%
|
||
def create_lag_features(paths: np.ndarray) -> np.ndarray:
|
||
"""
|
||
Create traditional lag-based features from price windows.
|
||
|
||
Features:
|
||
- Window return
|
||
- Volatility (std of returns)
|
||
- First-half vs second-half momentum
|
||
- Min/max relative to end
|
||
- Skewness proxy
|
||
"""
|
||
n_paths = len(paths)
|
||
features = []
|
||
|
||
for i in range(n_paths):
|
||
path = paths[i]
|
||
price = path[:, 1] # Normalized price column
|
||
cum_ret = path[:, 2] # Cumulative return column
|
||
|
||
# Total window return
|
||
total_ret = price[-1] - price[0]
|
||
|
||
# Volatility (std of increments)
|
||
increments = np.diff(price)
|
||
vol = np.std(increments)
|
||
|
||
# Momentum: second half vs first half
|
||
mid = len(price) // 2
|
||
first_half_ret = price[mid] - price[0]
|
||
second_half_ret = price[-1] - price[mid]
|
||
momentum = second_half_ret - first_half_ret
|
||
|
||
# Drawdown and runup
|
||
max_price = np.max(price)
|
||
min_price = np.min(price)
|
||
max_relative = max_price - price[-1]
|
||
min_relative = price[-1] - min_price
|
||
|
||
# Skewness proxy (mean - median)
|
||
skew_proxy = np.mean(price) - np.median(price)
|
||
|
||
# Path roughness (sum of absolute increments)
|
||
roughness = np.sum(np.abs(increments))
|
||
|
||
feat = [
|
||
total_ret,
|
||
vol,
|
||
momentum,
|
||
max_relative,
|
||
min_relative,
|
||
skew_proxy,
|
||
roughness,
|
||
]
|
||
features.append(feat)
|
||
|
||
return np.array(features)
|
||
|
||
|
||
lag_features = create_lag_features(paths)
|
||
print(f"Lag features dimension: {lag_features.shape[1]}")
|
||
|
||
# %% [markdown]
|
||
# ## Predictive Comparison: Signatures vs Lags
|
||
#
|
||
# We compare three feature sets for predicting 5-day forward returns using
|
||
# a simple GBM as a test harness. This is **feature evaluation, not model
|
||
# building** — we use a single learner to measure relative feature
|
||
# informativeness. Model selection and tuning are covered in Chapter 12.
|
||
#
|
||
# Feature sets:
|
||
# 1. Traditional lag features
|
||
# 2. Path signatures
|
||
# 3. Combined (signatures + lags)
|
||
|
||
# %%
|
||
# Split data temporally (avoid look-ahead)
|
||
train_size = int(0.7 * len(paths))
|
||
val_size = int(0.15 * len(paths))
|
||
|
||
X_lag_train = lag_features[:train_size]
|
||
X_lag_val = lag_features[train_size : train_size + val_size]
|
||
X_lag_test = lag_features[train_size + val_size :]
|
||
|
||
X_sig_train = logsignatures[:train_size]
|
||
X_sig_val = logsignatures[train_size : train_size + val_size]
|
||
X_sig_test = logsignatures[train_size + val_size :]
|
||
|
||
y_train = targets[:train_size]
|
||
y_val = targets[train_size : train_size + val_size]
|
||
y_test = targets[train_size + val_size :]
|
||
|
||
# Combined features
|
||
X_comb_train = np.hstack([X_lag_train, X_sig_train])
|
||
X_comb_val = np.hstack([X_lag_val, X_sig_val])
|
||
X_comb_test = np.hstack([X_lag_test, X_sig_test])
|
||
|
||
print(f"Train: {len(y_train)}, Val: {len(y_val)}, Test: {len(y_test)}")
|
||
|
||
# %%
|
||
# Train models
|
||
models = {}
|
||
|
||
|
||
def train_and_evaluate(X_train, X_val, X_test, y_train, y_val, y_test, name):
|
||
"""Train GradientBoosting and return metrics."""
|
||
model = GradientBoostingRegressor(
|
||
n_estimators=100, max_depth=3, learning_rate=0.1, random_state=42
|
||
)
|
||
model.fit(X_train, y_train)
|
||
|
||
train_r2 = r2_score(y_train, model.predict(X_train))
|
||
val_r2 = r2_score(y_val, model.predict(X_val))
|
||
test_r2 = r2_score(y_test, model.predict(X_test))
|
||
|
||
# Information coefficient (rank correlation)
|
||
test_pred = model.predict(X_test)
|
||
ic = pooled_ic(y_test, test_pred)
|
||
|
||
return {"train_r2": train_r2, "val_r2": val_r2, "test_r2": test_r2, "ic": ic, "model": model}
|
||
|
||
|
||
results = {
|
||
"Lag Features": train_and_evaluate(
|
||
X_lag_train, X_lag_val, X_lag_test, y_train, y_val, y_test, "Lag"
|
||
),
|
||
"Log-Signatures": train_and_evaluate(
|
||
X_sig_train, X_sig_val, X_sig_test, y_train, y_val, y_test, "Sig"
|
||
),
|
||
"Combined": train_and_evaluate(
|
||
X_comb_train, X_comb_val, X_comb_test, y_train, y_val, y_test, "Combined"
|
||
),
|
||
}
|
||
|
||
# %% [markdown]
|
||
# ## Results Comparison
|
||
|
||
# %%
|
||
print("=" * 60)
|
||
print("Predictive Performance Comparison (5-day forward returns)")
|
||
print("=" * 60)
|
||
print(f"{'Feature Set':<20} {'Train R²':>10} {'Val R²':>10} {'Test R²':>10} {'Test IC':>10}")
|
||
print("-" * 60)
|
||
for name, res in results.items():
|
||
print(
|
||
f"{name:<20} {res['train_r2']:>10.4f} {res['val_r2']:>10.4f} {res['test_r2']:>10.4f} {res['ic']:>10.4f}"
|
||
)
|
||
print("-" * 60)
|
||
|
||
# %% [markdown]
|
||
# **Interpretation**:
|
||
#
|
||
# Out-of-sample $R^2$ is near zero for every feature set (lag features: $-0.010$,
|
||
# log-signatures: $-0.016$, combined: $+0.015$), which is typical for daily
|
||
# return prediction. Rank correlation is more informative on this single-split
|
||
# evaluation: combined features achieve a test IC of $0.136$, versus $0.093$
|
||
# for lag features alone and $0.091$ for log-signatures alone. Signatures and
|
||
# lag features are therefore complementary on this dataset; the notebook does
|
||
# not evaluate turnover, capacity, or transaction costs, so it does not
|
||
# establish tradeability of the IC gap.
|
||
|
||
# %% [markdown]
|
||
# ## Visualize Signature Feature Importance
|
||
|
||
# %%
|
||
# Feature importance for combined model
|
||
combined_model = results["Combined"]["model"]
|
||
n_lag = lag_features.shape[1]
|
||
n_sig = logsignatures.shape[1]
|
||
|
||
importances = combined_model.feature_importances_
|
||
lag_imp = importances[:n_lag].sum()
|
||
sig_imp = importances[n_lag:].sum()
|
||
|
||
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
|
||
|
||
# Importance by category
|
||
ax = axes[0]
|
||
ax.bar(["Lag Features", "Signatures"], [lag_imp, sig_imp], color=["steelblue", "darkorange"])
|
||
ax.set_ylabel("Total Feature Importance")
|
||
ax.set_title("Feature Category Importance in Combined Model")
|
||
|
||
# Top individual features
|
||
ax = axes[1]
|
||
feature_names = [f"Lag_{i}" for i in range(n_lag)] + [f"Sig_{i}" for i in range(n_sig)]
|
||
top_k = 15
|
||
top_idx = np.argsort(importances)[-top_k:][::-1]
|
||
ax.barh(
|
||
[feature_names[i] for i in top_idx],
|
||
importances[top_idx],
|
||
color=["steelblue" if i < n_lag else "darkorange" for i in top_idx],
|
||
)
|
||
ax.set_xlabel("Feature Importance")
|
||
ax.set_title(f"Top {top_k} Individual Features")
|
||
ax.invert_yaxis()
|
||
|
||
plt.tight_layout()
|
||
plt.show()
|
||
|
||
# %% [markdown]
|
||
# ## Multi-Asset Signature Analysis
|
||
#
|
||
# Signatures can capture cross-asset dynamics by computing signatures on multi-asset paths.
|
||
|
||
|
||
# %%
|
||
def create_multi_asset_paths(
|
||
df: pl.DataFrame, symbols: list[str], window_size: int = 20
|
||
) -> tuple[np.ndarray, list]:
|
||
"""
|
||
Create paths where each path has multiple asset returns as dimensions.
|
||
"""
|
||
# Pivot to wide format
|
||
wide = df.pivot(on="symbol", index="timestamp", values="close").sort("timestamp").drop_nulls()
|
||
|
||
# Compute returns
|
||
for sym in symbols:
|
||
wide = wide.with_columns(
|
||
((pl.col(sym) - pl.col(sym).shift(1)) / pl.col(sym).shift(1)).alias(f"{sym}_ret")
|
||
)
|
||
|
||
wide = wide.drop_nulls()
|
||
|
||
# Create windows
|
||
n = len(wide)
|
||
n_windows = n - window_size
|
||
|
||
if n_windows <= 0:
|
||
return np.array([]), []
|
||
|
||
paths = []
|
||
end_dates = []
|
||
|
||
ret_cols = [f"{sym}_ret" for sym in symbols]
|
||
|
||
for i in range(n_windows):
|
||
window = wide[i : i + window_size]
|
||
|
||
# Time augmentation
|
||
t = np.linspace(0, 1, window_size).reshape(-1, 1)
|
||
|
||
# Cumulative returns for each asset
|
||
asset_paths = []
|
||
for col in ret_cols:
|
||
cum_ret = window[col].cum_sum().to_numpy().reshape(-1, 1)
|
||
asset_paths.append(cum_ret)
|
||
|
||
# Combine: (time, asset1_cumret, asset2_cumret, ...)
|
||
path = np.hstack([t] + asset_paths)
|
||
paths.append(path)
|
||
end_dates.append(window["timestamp"][-1])
|
||
|
||
return np.array(paths), end_dates
|
||
|
||
|
||
# Create multi-asset paths
|
||
multi_paths, multi_dates = create_multi_asset_paths(df, symbols, WINDOW_SIZE)
|
||
if len(multi_paths) > 0:
|
||
print(f"Multi-asset paths: {len(multi_paths)} windows, dimension {multi_paths.shape[2]}")
|
||
|
||
# Compute signatures
|
||
multi_sigs = compute_logsignatures(multi_paths, depth=2) # Lower depth due to higher dim
|
||
print(f"Multi-asset log-signature dimension: {multi_sigs.shape[1]}")
|
||
else:
|
||
print("Insufficient data for multi-asset paths")
|
||
|
||
# %% [markdown]
|
||
# ## When to Use Signatures
|
||
#
|
||
# **Signatures improve over baseline lag features when path shape genuinely matters**:
|
||
# intraday microstructure (execution timing, order flow patterns), options expiration
|
||
# dynamics (gamma hedging creates distinctive intraday paths), and momentum decay
|
||
# patterns (distinguishing gradual trends from sharp reversals).
|
||
#
|
||
# **For daily equity data with trend-following objectives, the marginal gain over
|
||
# well-constructed lag features is often modest.** Test signatures when simpler features
|
||
# plateau and when execution timing depends on path characteristics.
|
||
#
|
||
# **Signatures excel when**:
|
||
# - Path shape matters more than specific values (pattern recognition)
|
||
# - You need features invariant to speed of execution (reparameterization invariance)
|
||
# - Higher-order interactions between path components are informative
|
||
# - You're building features for ML models (tree-based, neural networks)
|
||
#
|
||
# **Signatures may not help when**:
|
||
# - The signal is purely in levels/returns (simple momentum)
|
||
# - Computational budget is very tight (cost is $O(n \times d^K)$ per window)
|
||
# - Interpretability is paramount (individual signature terms are abstract)
|
||
#
|
||
# **Multi-asset signatures** capture lead-lag dynamics across instruments — for example,
|
||
# whether tech momentum (QQQ) consistently leads broad-market moves (SPY). This
|
||
# cross-asset application connects to the panel features in §9.6.
|
||
#
|
||
# **Rolling computation** over trailing windows maintains causality: at time $t$, compute
|
||
# the signature over $[t - W, t]$ only. Typical windows range from 5 to 60 days.
|
||
#
|
||
# **Practical tip**: Start with depth-2 log-signatures. Higher depths rarely help and
|
||
# increase overfitting risk. For multi-asset paths, reduce depth to 2 to keep feature
|
||
# counts manageable.
|
||
|
||
# %% [markdown]
|
||
# ## Summary
|
||
|
||
# %%
|
||
summary = {
|
||
"Data": "SPY ETF daily prices",
|
||
"Window size": f"{WINDOW_SIZE} days",
|
||
"Forecast horizon": f"{FORECAST_HORIZON} days",
|
||
"Signature depth": SIGNATURE_DEPTH,
|
||
"Signature dimension": signatures.shape[1],
|
||
"Log-signature dimension": logsignatures.shape[1],
|
||
"Lag features dimension": lag_features.shape[1],
|
||
"Best IC (Test)": f"{max(r['ic'] for r in results.values()):.4f}",
|
||
"Best model": max(results.keys(), key=lambda k: results[k]["ic"]),
|
||
}
|
||
|
||
print("\n=== Summary ===")
|
||
for key, value in summary.items():
|
||
print(f"{key}: {value}")
|
||
|
||
# %% [markdown]
|
||
# ## Key Takeaways
|
||
#
|
||
# 1. **Path signatures encode sequential structure** that traditional features miss
|
||
# 2. **Time augmentation is critical** - without it, rising and falling paths look identical
|
||
# 3. **Log-signatures are preferred** in practice (more compact, less redundant)
|
||
# 4. **Depth 2-3 is usually sufficient** - higher depths risk overfitting
|
||
# 5. **Signatures complement, don't replace** traditional features
|
||
# 6. **Cross-asset signatures** can capture lead-lag and correlation dynamics
|
||
#
|
||
# **Previous**: `04_kalman_filter` for Kalman-based features,
|
||
# `05_spectral_features` for frequency-domain features.
|
||
# **Next**: `07_arima_features` for time-domain forecasting features.
|
||
# Ch12 uses these enriched features in gradient boosting models.
|