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

602 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 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # ARIMA Feature Extraction
#
# **Docker image**: `ml4t`
#
# This notebook demonstrates ARIMA as a **feature extractor** rather than a
# standalone forecaster. The output is the ARIMA point forecast, evaluated as a
# predictive feature (IC/RMSE) across orders and symbols.
#
# **Learning Objectives**:
# - Select ARIMA order using ACF/PACF and information criteria (AIC/BIC)
# - Build ARIMA point forecasts and evaluate them as features (IC, RMSE)
# - Compare AR, AIC-selected, and naive baselines across the ETF universe
# - Diagnose why simple ARIMA mean models extract limited signal from daily returns
#
# **Book Reference**: Chapter 9, Section 9.3 (Volatility Features)
#
# **Prerequisites**: `01_visual_diagnostics` for stationarity testing,
# `03_fractional_differencing` for memory-preserving transforms.
# %% [markdown]
# ## 1. Setup and Imports
# %%
"""ARIMA Feature Extraction — build ARIMA point forecasts and evaluate them as features (IC/RMSE)."""
import warnings
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import polars as pl
from IPython.display import display
from ml4t.diagnostic.evaluation.autocorrelation import analyze_autocorrelation
from ml4t.diagnostic.evaluation.stationarity import analyze_stationarity
from ml4t.diagnostic.metrics import pooled_ic
from plotly.subplots import make_subplots
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.stattools import acf, adfuller, pacf
warnings.filterwarnings("ignore", category=FutureWarning)
from data import load_etfs
from utils.paths import get_case_study_dir
from utils.reproducibility import set_global_seeds
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
MAX_SYMBOLS = 0 # 0 = all symbols
START_DATE = "2015-01-01"
END_DATE = "2024-12-01"
TEST_START = "2024-01-01"
SEED = 42
# %%
set_global_seeds(SEED)
# Configuration
# ETF symbols
etf_data = load_etfs()
ALL_SYMBOLS = etf_data["symbol"].unique().sort().to_list()
SYMBOLS = ALL_SYMBOLS[:MAX_SYMBOLS] if MAX_SYMBOLS > 0 else ALL_SYMBOLS
# For single-symbol demonstration (used in educational sections)
SYMBOL = "SPY"
print("ARIMA Baseline Configuration:")
print(f" Symbols: {len(SYMBOLS)} ({', '.join(SYMBOLS)})")
print(f" Train: {START_DATE} to {TEST_START}")
print(f" Test: {TEST_START} to {END_DATE}")
# %% [markdown]
# ## 2. Load ETF Price Data
#
# Load from ETF universe for cross-chapter consistency.
# %%
# Filter ETF data to date range (once, for all symbols)
start_dt = pl.col("timestamp") >= pl.lit(START_DATE).str.to_date()
end_dt = pl.col("timestamp") <= pl.lit(END_DATE).str.to_date()
etf_data = etf_data.filter(start_dt & end_dt)
print(f" ETF data: {len(etf_data):,} observations across {etf_data['symbol'].n_unique()} assets")
def get_symbol_data(symbol: str) -> pd.DataFrame:
"""Extract single symbol from pre-loaded ETF data.
Uses the already-loaded etf_data for efficiency.
"""
data = (
etf_data.filter(pl.col("symbol") == symbol)
.sort("timestamp")
.with_columns(returns=pl.col("close").pct_change())
.drop_nulls()
)
# Convert to pandas with date index (statsmodels expects pandas)
# NOTE: statsmodels ARIMA requires pandas DataFrame with DatetimeIndex
df = data.select(["timestamp", "close", "returns"]).to_pandas()
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
return df
print(f"Loading {SYMBOL} data from ETF universe...")
df = get_symbol_data(SYMBOL)
print(f" Observations: {len(df)}")
print(f" Date range: {df.index.min().date()} to {df.index.max().date()}")
# %% [markdown]
# ## 3. Stationarity Testing
#
# ARIMA requires stationary series. We test prices (non-stationary) and returns (stationary).
# %%
print("Augmented Dickey-Fuller Test for Stationarity:")
print("-" * 50)
# Test prices
adf_price = adfuller(df["close"].dropna())
print(f"Prices: ADF={adf_price[0]:.4f}, p={adf_price[1]:.4f}")
print(f" {'STATIONARY' if adf_price[1] < 0.05 else 'NON-STATIONARY'}")
# Test returns
adf_ret = adfuller(df["returns"].dropna())
print(f"Returns: ADF={adf_ret[0]:.4f}, p={adf_ret[1]:.4f}")
print(f" {'STATIONARY' if adf_ret[1] < 0.05 else 'NON-STATIONARY'}")
print("\n→ We model RETURNS (stationary), not prices.")
# %% [markdown]
# ## 4. ACF/PACF Analysis
#
# ACF helps identify MA order (q), PACF helps identify AR order (p).
# %%
def plot_acf_pacf(series: pd.Series, title: str):
"""Plot ACF and PACF for a series."""
acf_vals = acf(series.dropna(), nlags=20)
pacf_vals = pacf(series.dropna(), nlags=20)
fig = make_subplots(rows=1, cols=2, subplot_titles=("ACF", "PACF"))
# ACF
fig.add_trace(
go.Bar(x=list(range(len(acf_vals))), y=acf_vals, name="ACF"),
row=1,
col=1,
)
# PACF
fig.add_trace(
go.Bar(x=list(range(len(pacf_vals))), y=pacf_vals, name="PACF"),
row=1,
col=2,
)
# Significance bands
n = len(series)
sig = 1.96 / np.sqrt(n)
for col in [1, 2]:
fig.add_hline(y=sig, line_dash="dash", line_color="red", row=1, col=col)
fig.add_hline(y=-sig, line_dash="dash", line_color="red", row=1, col=col)
fig.update_layout(height=300, title_text=title, showlegend=False)
return fig
fig = plot_acf_pacf(df["returns"], f"{SYMBOL} Returns ACF/PACF")
fig.show()
print("\nACF/PACF Interpretation:")
print(" - Returns show little autocorrelation (efficient market)")
print(" - Low ACF/PACF suggests AR(0) or AR(1) may suffice")
print(" - This is typical: returns are hard to predict from past returns")
# %% [markdown]
# ### ml4t-diagnostic: Automated Order Suggestion
#
# The manual ACF/PACF interpretation above requires visual inspection.
# `analyze_autocorrelation()` examines significant lags programmatically
# and suggests an ARIMA order — a useful sanity check before grid search.
# %%
stat_check = analyze_stationarity(df["returns"].dropna().values)
acf_analysis = analyze_autocorrelation(df["returns"].dropna().values)
print("=== ml4t-diagnostic: Pre-Modeling Diagnostics ===")
print(f"Stationarity: {stat_check.consensus} (agreement: {stat_check.agreement_score:.2f})")
print(f"Suggested ARIMA order: {acf_analysis.suggested_arima_order}")
# %% [markdown]
# The diagnostic confirms returns are stationary (no differencing needed, d=0)
# and suggests a low-order ARIMA — consistent with the efficient market
# expectation of minimal autocorrelation in returns.
# %% [markdown]
# ## 5. Train/Test Split
# %%
# Split at TEST_START
test_start_dt = pd.Timestamp(TEST_START)
train = df[df.index < test_start_dt]
test = df[df.index >= test_start_dt]
print(f"Train: {len(train)} obs ({train.index.min().date()} to {train.index.max().date()})")
print(f"Test: {len(test)} obs ({test.index.min().date()} to {test.index.max().date()})")
# %% [markdown]
# ## 6. Baseline Models
# %%
results = {}
# Model 1: Naive (predict last value = 0 for returns)
def naive_forecast(train_ret, n_forecast):
"""Naive forecast: predict 0 (random walk for prices = 0 for returns)."""
return np.zeros(n_forecast)
naive_pred = naive_forecast(train["returns"], len(test))
naive_ic = pooled_ic(test["returns"].values, naive_pred)
naive_rmse = np.sqrt(np.mean((test["returns"].values - naive_pred) ** 2))
results["Naive (0)"] = {"ic": naive_ic, "rmse": naive_rmse, "predictions": naive_pred}
print("Model 1: Naive (predict 0)")
print(f" IC: {naive_ic:.4f}, RMSE: {naive_rmse:.6f}")
# %%
# Model 2: Naive (predict mean)
def mean_forecast(train_ret, n_forecast):
"""Predict historical mean."""
return np.full(n_forecast, train_ret.mean())
mean_pred = mean_forecast(train["returns"], len(test))
mean_ic = pooled_ic(test["returns"].values, mean_pred)
mean_rmse = np.sqrt(np.mean((test["returns"].values - mean_pred) ** 2))
results["Mean"] = {"ic": mean_ic, "rmse": mean_rmse, "predictions": mean_pred}
print("\nModel 2: Historical Mean")
print(f" IC: {mean_ic:.4f}, RMSE: {mean_rmse:.6f}")
# %% [markdown]
# ## 7. AR Models
# %%
# Model 3: AR(1)
print("\nModel 3: AR(1)")
ar1 = ARIMA(train["returns"], order=(1, 0, 0))
ar1_fit = ar1.fit()
ar1_pred = ar1_fit.forecast(steps=len(test))
ar1_ic = pooled_ic(test["returns"].values, ar1_pred.values)
ar1_rmse = np.sqrt(np.mean((test["returns"].values - ar1_pred.values) ** 2))
results["AR(1)"] = {"ic": ar1_ic, "rmse": ar1_rmse, "predictions": ar1_pred.values}
print(f" AR(1) coef: {ar1_fit.params.get('ar.L1', ar1_fit.params.iloc[1]):.4f}")
print(f" IC: {ar1_ic:.4f}, RMSE: {ar1_rmse:.6f}")
# Model 4: AR(5)
print("\nModel 4: AR(5)")
ar5 = ARIMA(train["returns"], order=(5, 0, 0))
ar5_fit = ar5.fit()
ar5_pred = ar5_fit.forecast(steps=len(test))
ar5_ic = pooled_ic(test["returns"].values, ar5_pred.values)
ar5_rmse = np.sqrt(np.mean((test["returns"].values - ar5_pred.values) ** 2))
results["AR(5)"] = {"ic": ar5_ic, "rmse": ar5_rmse, "predictions": ar5_pred.values}
print(f" IC: {ar5_ic:.4f}, RMSE: {ar5_rmse:.6f}")
# %% [markdown]
# ## 8. ARIMA Model Selection
#
# We search over a grid of (p, d, q) and select based on AIC/BIC.
# %%
print("\nModel Selection: Grid Search")
print("-" * 50)
best_aic = np.inf
best_order = None
best_model = None
# Grid search (limited for speed)
p_range = range(0, 4)
q_range = range(0, 3)
d = 0 # Returns are already stationary
model_results = []
for p in p_range:
for q in q_range:
try:
model = ARIMA(train["returns"], order=(p, d, q))
fit = model.fit()
model_results.append({"p": p, "q": q, "aic": fit.aic, "bic": fit.bic})
if fit.aic < best_aic:
best_aic = fit.aic
best_order = (p, d, q)
best_model = fit
except (ValueError, np.linalg.LinAlgError):
continue
if model_results:
model_df = pd.DataFrame(model_results).sort_values("aic")
print("Top 5 models by AIC:")
display(model_df.head())
print(f"Best model: ARIMA{best_order} (AIC: {best_aic:.2f})")
# %%
# Forecast with best model
if best_model:
best_pred = best_model.forecast(steps=len(test))
best_ic = pooled_ic(test["returns"].values, best_pred.values)
best_rmse = np.sqrt(np.mean((test["returns"].values - best_pred.values) ** 2))
results[f"ARIMA{best_order}"] = {
"ic": best_ic,
"rmse": best_rmse,
"predictions": best_pred.values,
}
print(f"\nBest ARIMA{best_order}:")
print(f" IC: {best_ic:.4f}, RMSE: {best_rmse:.6f}")
# %% [markdown]
# ## 9. Rolling Forecast (Walk-Forward)
#
# For a more realistic evaluation, we use rolling 1-step-ahead forecasts.
# %%
print("\nRolling 1-Step-Ahead Forecast (Walk-Forward):")
print("-" * 50)
# Rolling forecast with AR(1)
rolling_preds = []
rolling_actuals = []
# Initial training window
window_size = len(train)
full_returns = df["returns"].values
# Rolling forecast iterations
n_rolling = len(test)
for i in range(n_rolling):
# Fit on expanding window
train_window = full_returns[: window_size + i]
# Quick AR(1) fit; statsmodels can fail on rank-deficient or non-stationary
# windows — fall back to zero forecast in that rare case.
try:
model = ARIMA(train_window, order=(1, 0, 0))
fit = model.fit()
pred = fit.forecast(steps=1)[0]
except (ValueError, np.linalg.LinAlgError):
pred = 0.0
rolling_preds.append(pred)
rolling_actuals.append(full_returns[window_size + i])
rolling_preds = np.array(rolling_preds)
rolling_actuals = np.array(rolling_actuals)
rolling_ic = pooled_ic(rolling_actuals, rolling_preds)
rolling_rmse = np.sqrt(np.mean((rolling_actuals - rolling_preds) ** 2))
results["AR(1) Rolling"] = {"ic": rolling_ic, "rmse": rolling_rmse, "predictions": rolling_preds}
print(f"AR(1) Rolling IC: {rolling_ic:.4f}, RMSE: {rolling_rmse:.6f}")
# %% [markdown]
# ## 10. Results Comparison
# %%
summary_rows = [{"model": name, "ic": r["ic"], "rmse": r["rmse"]} for name, r in results.items()]
summary_df = pd.DataFrame(summary_rows)
display(summary_df)
# Best model by IC (skip NaN entries — constant predictions yield undefined IC)
finite = summary_df[summary_df["ic"].notna()]
if not finite.empty:
best_row = finite.loc[finite["ic"].idxmax()]
print(
f"Best by IC (excluding constant predictions): {best_row['model']} ({best_row['ic']:.4f})"
)
# %% [markdown]
# ## 11. Visualization
# %%
# Plot comparison
models_to_plot = ["Naive (0)", "AR(1)", f"ARIMA{best_order}" if best_order else "AR(1)"]
colors = {"Naive (0)": "gray", "AR(1)": "steelblue", f"ARIMA{best_order}": "coral"}
fig = make_subplots(
rows=2,
cols=1,
subplot_titles=("Actual vs Predicted Returns (Sample)", "Prediction Distributions"),
)
# Sample of actual vs predicted
n_plot = min(100, len(test))
dates = test.index[:n_plot]
fig.add_trace(
go.Scatter(
x=list(range(n_plot)),
y=test["returns"].values[:n_plot],
name="Actual",
line=dict(color="black"),
),
row=1,
col=1,
)
for model_name in models_to_plot:
if model_name in results:
fig.add_trace(
go.Scatter(
x=list(range(n_plot)),
y=results[model_name]["predictions"][:n_plot],
name=model_name,
line=dict(dash="dot"),
),
row=1,
col=1,
)
# %%
# Histograms
fig.add_trace(
go.Histogram(x=test["returns"].values, name="Actual", opacity=0.5, nbinsx=50),
row=2,
col=1,
)
fig.add_trace(
go.Histogram(x=results["AR(1)"]["predictions"], name="AR(1) Pred", opacity=0.5, nbinsx=50),
row=2,
col=1,
)
fig.update_layout(height=500, title_text="ARIMA Baseline: Predictions vs Actual", barmode="overlay")
fig.show()
# %% [markdown]
# **Reading the table.** AR(1) on this train/test split lands a small positive
# IC (≈ +0.07), which is the most that any single-asset ARIMA model produces
# here. The AIC-best ARIMA — `ARIMA(3, 0, 2)` — flips sign and lands a small
# *negative* IC; AR(5) is also negative. This is a useful illustration of two
# things at once: longer AR/MA orders overfit in-sample at the cost of OOS
# rank correlation, and the AIC-best model is not the IC-best model. The
# Naive (0) and Mean baselines have constant predictions, so their IC is
# undefined (NaN) — they are RMSE benchmarks, not rank-correlation
# benchmarks.
#
# **Why ARIMA struggles with returns on this dataset**:
# 1. **Low autocorrelation**: the ACF of daily SPY returns is statistically
# indistinguishable from zero at most lags, leaving little linear structure
# for an ARIMA mean model to exploit
# 2. **Heteroskedasticity**: variance changes over time, so a constant-variance
# mean model is misspecified (GARCH addresses this in `08_garch_volatility`)
#
# **When ARIMA works**:
# - Trending series (moving averages, cumulative metrics)
# - Seasonal patterns (explicit or multiplicative)
# - Non-financial time series (weather, sales, etc.)
# %% [markdown]
# ## 13. Multi-Symbol ARIMA Forecasting
#
# Process all symbols in parallel and collect predictions for downstream chapters.
# %%
print("\n" + "=" * 60)
print("MULTI-SYMBOL ARIMA FORECASTING")
print("=" * 60)
def run_arima_for_symbol(symbol: str) -> tuple[pl.DataFrame | None, dict]:
"""Run ARIMA forecasting for a single symbol.
Returns (predictions DataFrame or None, summary dict) — summary is always
populated with the symbol and an `ic` (NaN if skipped).
"""
summary = {"symbol": symbol, "n_predictions": 0, "ic": float("nan"), "status": "ok"}
symbol_df = get_symbol_data(symbol)
if len(symbol_df) < 252:
summary["status"] = "insufficient_data"
return None, summary
test_start_dt = pd.Timestamp(TEST_START)
train_data = symbol_df[symbol_df.index < test_start_dt]
test_data = symbol_df[symbol_df.index >= test_start_dt]
if len(train_data) < 100 or len(test_data) < 10:
summary["status"] = "insufficient_split"
return None, summary
try:
model = ARIMA(train_data["returns"], order=(1, 0, 0))
fit = model.fit()
preds = fit.forecast(steps=len(test_data))
except (ValueError, np.linalg.LinAlgError) as exc:
summary["status"] = f"fit_failed: {type(exc).__name__}"
return None, summary
ic = pooled_ic(test_data["returns"].values, preds.values)
summary["n_predictions"] = len(test_data)
summary["ic"] = ic
pred_df = pl.DataFrame(
{
"timestamp": test_data.index.values,
"symbol": symbol,
"y_true": test_data["returns"].values,
"y_pred": preds.values,
"model_id": "arima_ar1",
"fold_id": 0,
"horizon": "1d",
"dataset": "etf",
}
)
return pred_df, summary
# Process all symbols
all_predictions = []
symbol_summaries = []
print(f"Processing {len(SYMBOLS)} symbols...")
for symbol in SYMBOLS:
pred_df, summary = run_arima_for_symbol(symbol)
symbol_summaries.append(summary)
if pred_df is not None:
all_predictions.append(pred_df)
ic_summary_df = pd.DataFrame(symbol_summaries)
display(ic_summary_df.sort_values("ic", ascending=False, na_position="last"))
# Combine predictions
if all_predictions:
multi_symbol_df = pl.concat(all_predictions)
print(
f"Total predictions: {len(multi_symbol_df):,} rows across {len(all_predictions)} symbols. "
f"Mean IC: {ic_summary_df['ic'].mean():+.4f}, "
f"share positive: {(ic_summary_df['ic'] > 0).mean():.1%}"
)
else:
multi_symbol_df = pl.DataFrame()
print("No predictions generated")
# %% [markdown]
# ## 14. Save Predictions for Downstream Chapters
#
# Output standardized predictions file for cross-model comparison.
# Schema: date, asset, y_true, y_pred, model_id, fold_id, horizon, dataset
# %%
# Save ARIMA predictions
MODEL_DIR = get_case_study_dir("etfs") / "models" / "time_series"
MODEL_DIR.mkdir(parents=True, exist_ok=True)
if len(multi_symbol_df) > 0:
output_path = MODEL_DIR / "arima_predictions.parquet"
multi_symbol_df.write_parquet(output_path)
print(f"Saved multi-symbol predictions to {output_path}")
print(f" Shape: {multi_symbol_df.shape}")
print(f" Assets: {multi_symbol_df['symbol'].n_unique()}")
print(
f" Date range: {multi_symbol_df['timestamp'].min()} to {multi_symbol_df['timestamp'].max()}"
)
else:
print("WARNING: No predictions to save")
print("ARIMA baseline demonstration complete")