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

607 lines
18 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
# language: python
# name: python3
# ---
# %% [markdown]
# # Fractional Differencing
#
# **Docker image**: `ml4t`
#
# This notebook demonstrates **fractional differentiation** (FFD), a technique that
# achieves stationarity while preserving as much memory as possible.
#
# **Learning Objectives**:
# - Understand the memory-stationarity tradeoff controlled by $d \in (0, 1)$
# - Compute FFD weights and apply fractional differencing via ml4t-engineer
# - Select $d$ by asset class using bounded grids (no in-sample search)
# - Validate stationarity with ADF and quantify sample loss
#
# **Book Reference**: Chapter 9, Section 9.1 (Diagnostics and Stationarity Features)
#
# **Prerequisites**: `01_visual_diagnostics` for stationarity testing concepts.
# %%
"""Fractional Differencing — achieve stationarity while preserving memory."""
import warnings
from typing import Any, cast
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import polars as pl
from IPython.display import display
from plotly.subplots import make_subplots
from statsmodels.tsa.stattools import adfuller
warnings.filterwarnings("ignore")
from ml4t.engineer.features.fdiff import (
fdiff_diagnostics,
ffdiff,
find_optimal_d,
get_ffd_weights,
)
from data import load_etfs
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
START_DATE = "2015-01-01"
END_DATE = "2024-01-01"
# %%
# Load data
all_etfs = load_etfs()
# %% [markdown]
# ## 1. Load Data
# %%
def load_etf_data(symbol: str, start: str = START_DATE, end: str = END_DATE) -> pl.DataFrame:
"""Load ETF data from cached parquet."""
df = (
all_etfs.filter(pl.col("symbol") == symbol)
.filter(pl.col("timestamp") >= pl.lit(start).str.to_date())
.filter(pl.col("timestamp") <= pl.lit(end).str.to_date())
.sort("timestamp")
)
return df
# Load SPY for demonstration
spy = load_etf_data("SPY")
print(f"SPY: {len(spy)} days")
# %% [markdown]
# ## 2. Bounded d Grid (Default Workflow)
#
# Rather than searching for "optimal d", use **fixed grids by asset class**.
# This is simpler, more robust, and avoids overfitting to in-sample data.
#
# ### Recommended d Values by Asset Class
#
# | Asset Class | Recommended d | Rationale |
# |-------------|---------------|-----------|
# | **US Equities** | 0.4 | Moderate persistence |
# | **Fixed Income** | 0.5 | High persistence (rates) |
# | **Crypto** | 0.5-0.6 | Strong trending |
# | **Commodities** | 0.4 | Similar to equities |
# | **FX** | 0.3-0.4 | Mean-reverting tendency |
#
# These are starting points. The exact value matters less than being consistent
# and avoiding lookahead from searching on the full sample.
# %%
# Asset class d recommendations
ASSET_CLASS_D = {
"equities": 0.4,
"fixed_income": 0.5,
"crypto": 0.5,
"commodities": 0.4,
"fx": 0.35,
}
# For teaching: small fixed grid (not search)
TEACHING_D_GRID = [0.3, 0.4, 0.5, 0.6]
print("Asset Class d Recommendations:")
for asset, d in ASSET_CLASS_D.items():
print(f" {asset:15s}: d = {d}")
# %% [markdown]
# ## 3. FFD with Validity Mask and Sample Loss
#
# The standard output format includes:
# - **transformed**: The FFD series
# - **valid**: Boolean mask (True where FFD is computable)
# - **sample_loss**: Number of observations lost to warmup
# %%
def ffd_with_diagnostics(
series: pl.Series, d: float, threshold: float = 1e-5
) -> dict[str, pl.Series | int | float]:
"""
Apply FFD and return transformed series with validity mask and diagnostics.
Parameters
----------
series : pl.Series
Input series (typically log prices)
d : float
Differentiation order
threshold : float
Weight cutoff threshold
Returns
-------
dict with:
- transformed: FFD series
- valid: Boolean mask
- sample_loss: Number of NaN observations
- d: The d value used
- n_weights: Number of FFD weights
"""
# Get weights to determine warmup period
weights = get_ffd_weights(d, threshold=threshold)
n_weights = len(weights)
# Apply FFD
ffd_series = ffdiff(series, d=d, threshold=threshold)
# Create validity mask
arr = ffd_series.to_numpy()
valid = ~np.isnan(arr)
# Count sample loss
sample_loss = np.sum(~valid)
return {
"transformed": ffd_series,
"valid": pl.Series("valid", valid),
"sample_loss": sample_loss,
"d": d,
"n_weights": n_weights,
}
# %%
# Apply FFD to SPY log prices with recommended d for equities
log_prices = spy["close"].log()
d_equity = ASSET_CLASS_D["equities"]
ffd_result = ffd_with_diagnostics(log_prices, d=d_equity)
ffd_valid = cast(pl.Series, ffd_result["valid"])
ffd_transformed = cast(pl.Series, ffd_result["transformed"])
print(f"FFD Results (d={d_equity}):")
print(f" Total observations: {len(log_prices)}")
print(f" Valid observations: {ffd_valid.sum()}")
print(
f" Sample loss: {ffd_result['sample_loss']} ({100 * ffd_result['sample_loss'] / len(log_prices):.1f}%)"
)
print(f" FFD weights used: {ffd_result['n_weights']}")
# %% [markdown]
# ## 4. Compare d Values from Grid
#
# Show how different d values affect stationarity and memory preservation.
# %%
# Apply grid of d values
grid_results = {}
for d in TEACHING_D_GRID:
result = ffd_with_diagnostics(log_prices, d=d)
transformed = cast(pl.Series, result["transformed"])
valid = cast(pl.Series, result["valid"])
# Quick stationarity check (for display only)
clean = transformed.drop_nulls().to_numpy()
if len(clean) > 50:
adf_stat, adf_pval, _, _, _, _ = adfuller(clean, autolag="AIC")
# Correlation with original (memory preserved)
valid_mask = valid.to_numpy()
orig = log_prices.to_numpy()[valid_mask]
ffd = transformed.to_numpy()[valid_mask]
corr = np.corrcoef(orig, ffd)[0, 1]
grid_results[d] = {
"sample_loss": result["sample_loss"],
"adf_pval": adf_pval,
"correlation": corr,
"n_weights": result["n_weights"],
}
grid_df = pd.DataFrame(
[
{
"d": d,
"sample_loss": res["sample_loss"],
"adf_pval": res["adf_pval"],
"stationary": res["adf_pval"] < 0.05,
"corr_with_orig": res["correlation"],
"n_weights": res["n_weights"],
}
for d, res in grid_results.items()
]
)
display(grid_df)
# %% [markdown]
# ## 5. Visualize FFD Weights
#
# FFD weights determine how much memory is preserved. Lower d = longer memory.
# %%
# Visualize weights for different d values
fig = go.Figure()
for d in TEACHING_D_GRID:
weights = get_ffd_weights(d, threshold=1e-5)
fig.add_trace(
go.Scatter(
x=list(range(len(weights))),
y=weights,
mode="lines+markers",
name=f"d={d}",
marker=dict(size=4),
)
)
fig.update_layout(
height=400,
title="FFD Weights by d Value",
xaxis_title="Lag (k)",
yaxis_title="Weight (w_k)",
# Cap x-axis at the first 100 lags — beyond that, weights decay below the
# 1e-5 threshold and the curves crawl along zero, compressing the visible
# decay structure near k=0 where the differences across d live.
xaxis=dict(range=[0, 100]),
)
fig.show()
# Weight counts
print("\nWeight Counts (threshold=1e-5):")
for d in TEACHING_D_GRID:
weights = get_ffd_weights(d, threshold=1e-5)
print(f" d={d}: {len(weights)} weights")
# %% [markdown]
# ## 6. Feature Engineering Output Format
#
# Standard format for downstream ML: transformed series + validity mask.
# %%
# Create feature table with FFD at recommended d
spy_features = spy.select(["timestamp", "close"]).with_columns(
[
pl.col("close").log().alias("log_close"),
pl.col("close").pct_change().alias("return_1d"),
]
)
# Add FFD with recommended d
d_use = ASSET_CLASS_D["equities"]
ffd_result = ffd_with_diagnostics(spy_features["log_close"], d=d_use)
ffd_transformed = cast(pl.Series, ffd_result["transformed"])
ffd_valid = cast(pl.Series, ffd_result["valid"])
spy_features = spy_features.with_columns(
[
ffd_transformed.alias(f"ffd_{d_use}"),
ffd_valid.alias("ffd_valid"),
]
)
# Show sample loss prominently
print(f"Feature Engineering Output (d={d_use}):")
print(f" Total rows: {spy_features.height}")
print(f" Valid rows: {ffd_valid.sum()}")
print(f" Sample loss: {ffd_result['sample_loss']} observations")
print()
# %% [markdown]
# The feature table below shows the last 10 valid rows. Note the `ffd_valid`
# column — downstream ML pipelines should filter on this mask to exclude the
# warmup period where FFD weights require more history than is available.
# %%
print("Feature Table (valid rows):")
spy_features.filter(pl.col("ffd_valid")).tail(10)
# %% [markdown]
# ## 7. Multi-Asset Application
#
# Apply fixed d values by asset class (no search).
# %%
# ETF symbols with asset class mapping
ETF_ASSETS = {
"SPY": "equities",
"QQQ": "equities",
"IWM": "equities",
"TLT": "fixed_income",
"GLD": "commodities",
"EFA": "equities",
"EEM": "equities",
}
etf_ffd_results = {}
for symbol, asset_class in ETF_ASSETS.items():
data = load_etf_data(symbol)
if data.height < 100:
continue
d = ASSET_CLASS_D[asset_class]
log_prices = data["close"].log()
result = ffd_with_diagnostics(log_prices, d=d)
# Quick stationarity verification
clean = cast(pl.Series, result["transformed"]).drop_nulls().to_numpy()
if len(clean) > 50:
_, adf_pval, _, _, _, _ = adfuller(clean, autolag="AIC")
etf_ffd_results[symbol] = {
"asset_class": asset_class,
"d": d,
"sample_loss": result["sample_loss"],
"sample_loss_pct": 100 * result["sample_loss"] / data.height,
"adf_pval": adf_pval,
"stationary": adf_pval < 0.05,
}
# %% [markdown]
# When building a mixed-portfolio feature set, different asset classes will have
# different optimal d values. This is acceptable — each security's FFD features
# use its own d, and the ML model learns from the resulting feature distributions.
# Consistency within each security over time matters more than uniformity across
# securities.
# %%
multi_df = pd.DataFrame([{"symbol": sym, **res} for sym, res in etf_ffd_results.items()])
display(multi_df)
# %% [markdown]
# ## 8. Visualize FFD Transformation
# %%
# Compare original, returns, and FFD
d_plot = ASSET_CLASS_D["equities"]
ffd_series = ffdiff(spy["close"].log(), d=d_plot)
fig = make_subplots(
rows=3,
cols=1,
shared_xaxes=True,
subplot_titles=["Log Price (d=0, non-stationary)", "Returns (d=1)", f"FFD (d={d_plot})"],
vertical_spacing=0.08,
)
dates = spy["timestamp"].to_list()
log_prices_arr = spy["close"].log().to_numpy()
returns_arr = spy["close"].pct_change().to_numpy()
ffd_arr = ffd_series.to_numpy()
# Use last 500 points for visibility
n = 500
fig.add_trace(go.Scatter(x=dates[-n:], y=log_prices_arr[-n:], name="Log Price"), row=1, col=1)
fig.add_trace(go.Scatter(x=dates[-n:], y=returns_arr[-n:], name="Returns"), row=2, col=1)
fig.add_trace(go.Scatter(x=dates[-n:], y=ffd_arr[-n:], name="FFD"), row=3, col=1)
fig.update_layout(height=600, title="Comparing Differentiation Methods")
fig.show()
# %% [markdown]
# ---
#
# ## 9. [OPTIONAL] Walk-Forward d Search
#
# > **WARNING: Research Helper Only**
# >
# > If you need to search for d, do it with **walk-forward validation**:
# > estimate d on training data only, then apply to test data.
# > This avoids lookahead bias from using the full sample.
# %%
def find_d_walk_forward(
series: pl.Series,
train_end_idx: int,
d_grid: list[float] | None = None,
adf_threshold: float = 0.05,
) -> dict[str, Any]:
"""Find the smallest $d$ on the grid that makes the series stationary.
Estimated on training data only — no leakage from the held-out tail. The
smallest stationary $d$ is the López de Prado convention: keep as much
long-run dependence as the stationarity diagnostic allows.
"""
if d_grid is None:
d_grid = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
train_series = series.head(train_end_idx)
d_grid = sorted(d_grid)
for d in d_grid:
ffd_train = ffdiff(train_series, d=d)
clean = ffd_train.drop_nulls().to_numpy()
if len(clean) < 50:
continue
_, adf_pval, _, _, _, _ = adfuller(clean, autolag="AIC")
if adf_pval < adf_threshold:
valid = ~np.isnan(ffd_train.to_numpy())
orig = train_series.to_numpy()[valid]
ffd = ffd_train.to_numpy()[valid]
corr = np.corrcoef(orig, ffd)[0, 1]
return {
"optimal_d": d,
"train_adf_pval": adf_pval,
"train_correlation": corr,
}
# No d on the grid produced a stationary series — fall back to first differences.
return {"optimal_d": 1.0, "train_adf_pval": float("nan"), "train_correlation": 0.0}
# %%
# [OPTIONAL] Walk-forward example
# Split: 80% train, 20% test
train_pct = 0.8
train_end = int(len(spy) * train_pct)
print("Walk-forward d search:")
print(f" Training period: {spy['timestamp'][0]} to {spy['timestamp'][train_end - 1]}")
print(f" Test period: {spy['timestamp'][train_end]} to {spy['timestamp'][-1]}")
print()
wf_result = find_d_walk_forward(spy["close"].log(), train_end_idx=train_end)
print("Results (estimated on training data ONLY):")
print(f" Selected d: {wf_result['optimal_d']}")
print(f" Train ADF p-value: {wf_result['train_adf_pval']:.4f}")
print(f" Train correlation with original: {wf_result['train_correlation']:.4f}")
print()
# Apply to test data (out-of-sample)
test_series = spy["close"].log().tail(len(spy) - train_end)
ffd_test = ffdiff(test_series, d=wf_result["optimal_d"])
clean_test = ffd_test.drop_nulls().to_numpy()
if len(clean_test) > 50:
_, test_pval, _, _, _, _ = adfuller(clean_test, autolag="AIC")
print("Out-of-sample verification:")
print(f" Test ADF p-value: {test_pval:.4f}")
print(f" Stationary in test: {'Yes' if test_pval < 0.05 else 'No'}")
# %% [markdown]
# ### ml4t-engineer: Automated d Selection and Diagnostics
#
# The manual walk-forward search above builds intuition for the
# memory-stationarity tradeoff. For production use, `find_optimal_d()`
# automates the grid search and `fdiff_diagnostics()` provides a
# comprehensive diagnostic summary.
# %%
# find_optimal_d: automated grid search
log_prices_series = spy["close"].log()
optimal = find_optimal_d(log_prices_series, d_range=(0.0, 1.0), step=0.05)
print("=== ml4t-engineer: find_optimal_d ===")
print(f"Optimal d: {optimal['optimal_d']:.2f}")
print(f"ADF p-value: {optimal['adf_pvalue']:.4f}")
print(f"Correlation with original: {optimal['correlation']:.4f}")
# %%
# fdiff_diagnostics: detailed analysis at a specific d
diag = fdiff_diagnostics(log_prices_series, d=optimal["optimal_d"])
print("=== ml4t-engineer: fdiff_diagnostics ===")
print(f"d: {diag['d']:.2f}")
print(f"ADF statistic: {diag['adf_statistic']:.4f}")
print(f"ADF p-value: {diag['adf_pvalue']:.4f}")
print(f"Correlation: {diag['correlation']:.4f}")
print(f"Number of weights: {diag['n_weights']}")
print(f"Weight sum: {diag['weight_sum']:.4f}")
# %% [markdown]
# **Note**: For the recommended workflow, use **fixed d by asset class** (Section 2).
# `find_optimal_d()` is a convenience for exploratory analysis — it searches the
# full sample, so wrap it in a walk-forward scheme to avoid lookahead bias.
# %% [markdown]
# ## 10. Distribution Comparison
# %%
# Compare distributions of different transformations
d_compare = ASSET_CLASS_D["equities"]
ffd_compare = ffdiff(spy["close"].log(), d=d_compare)
fig = make_subplots(
rows=1,
cols=3,
subplot_titles=["Log Price (d=0)", "Returns (d=1)", f"FFD (d={d_compare})"],
)
log_vals = spy["close"].log().drop_nulls().to_numpy()
ret_vals = spy["close"].pct_change().drop_nulls().to_numpy()
ffd_vals = ffd_compare.drop_nulls().to_numpy()
fig.add_trace(go.Histogram(x=log_vals, nbinsx=50, name="Log Price"), row=1, col=1)
fig.add_trace(go.Histogram(x=ret_vals, nbinsx=50, name="Returns"), row=1, col=2)
fig.add_trace(go.Histogram(x=ffd_vals, nbinsx=50, name="FFD"), row=1, col=3)
fig.update_layout(height=350, title="Distribution Comparison", showlegend=False)
fig.show()
# %% [markdown]
# **Finding**: The log price distribution (left) is multimodal and non-stationary.
# Returns (center) are approximately symmetric with fat tails. The FFD series (right)
# preserves more of the original series' shape than returns while achieving
# stationarity — the key benefit of fractional differencing.
# %% [markdown]
# ---
#
# ## Summary
#
# ### Default Workflow (Recommended)
#
# 1. **Use fixed d by asset class** - no search, no lookahead
# 2. **Output format**: transformed series + validity mask + sample loss count
# 3. **Verify stationarity** with quick ADF check (but don't optimize on it)
#
# ### Asset Class d Recommendations
#
# | Asset Class | d |
# |-------------|---|
# | US Equities | 0.4 |
# | Fixed Income | 0.5 |
# | Crypto | 0.5 |
# | Commodities | 0.4 |
# | FX | 0.35 |
#
# ### Note on Sample Loss
#
# `ml4t.engineer.features.fdiff.ffdiff` applies truncated weights at the start
# of the series rather than NaN-padding a warmup region. The feature is
# therefore non-null from observation 0 — the diagnostic tables above show
# `Sample Loss = 0` for every ETF. Classical FFD as described in López de Prado
# (2018) drops the warmup explicitly. Choose the convention deliberately when
# building features: the truncated form keeps every observation but the
# earliest values use only a partial weight set, while the warmup-dropping
# form discards them.
#
# ### ml4t-engineer Functions
#
# - **`ffdiff(series, d)`**: Apply fractional differentiation
# - **`get_ffd_weights(d, threshold)`**: Get FFD weight vector
# - **`find_optimal_d(series)`**: Automated grid search for minimum stationary d
# - **`fdiff_diagnostics(series, d)`**: ADF, correlation, weight analysis at given d
#
# ### Key Points
#
# 1. **Always report sample loss** - FFD loses early observations
# 2. **Use validity mask** for downstream ML pipelines
# 3. **For walk-forward search** (optional): estimate d on training data only
# 4. **For stationarity testing details**: see `01_visual_diagnostics`
#
# **Next**: See `04_kalman_filter` for signal transform features and
# `05_spectral_features` for frequency-domain features.