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

641 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---
# 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]
# # Macro Data Alignment: Multi-Frequency Integration
#
# **Chapter 4: Fundamental and Alternative Data**
# **Docker image**: `ml4t`
# **Section Reference**: Section 4.3 (Fundamentals Across the Asset-Class Spectrum)
#
# ## Purpose
#
# Macroeconomic data presents unique challenges for trading models: different release cadences
# (monthly CPI, weekly claims, quarterly GDP), revision histories, and the critical requirement
# of point-in-time correctness. This notebook demonstrates how to align multi-frequency macro
# data for daily trading models using pre-downloaded FRED data.
#
# ## Learning Objectives
#
# After completing this notebook, you will be able to:
# - Load and explore pre-downloaded FRED macro data
# - Understand release lag and its impact on point-in-time correctness
# - Align multi-frequency data to a common daily timeline
# - Create stationary macro features (z-scores, regimes, momentum)
# - Use ALFRED vintage data for true point-in-time backtesting
#
# ## Prerequisites
#
# - Run `06_fred_macro_eda` first for basic FRED data orientation
# - Run the macro data download script: `python data/macro/download.py`
#
# ## Cross-References
#
# - **Upstream**: `06_fred_macro_eda` (basic FRED intro), `data/macro/download.py`
# - **Downstream**: Chapter 8 `macro_regime_features.py`
# - **Related**: [`09_onchain_fundamentals`](09_onchain_fundamentals.ipynb) (crypto fundamentals)
# %%
"""Macro Data Alignment — align multi-frequency macro data for daily trading models with PIT correctness."""
import warnings
from datetime import date
import plotly.graph_objects as go
import polars as pl
from plotly.subplots import make_subplots
from data import load_etfs, load_macro
from utils import ML4T_DATA_PATH
from utils.reproducibility import set_global_seeds
warnings.filterwarnings("ignore")
print(f"Data directory: {ML4T_DATA_PATH}")
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
SEED = 42
# %%
set_global_seeds(SEED)
# %% [markdown]
# ## 1. The Multi-Frequency Challenge
#
# Different macro series are released at different frequencies:
#
# | Series | Frequency | Release Lag | Example |
# |--------|-----------|-------------|---------|
# | **S&P 500** | Daily | Real-time | Market close |
# | **Initial Claims** | Weekly | 5 days | Thursday for prior week |
# | **CPI** | Monthly | ~15 days | Mid-month for prior month |
# | **GDP** | Quarterly | 30+ days | Advance, preliminary, final |
# | **10Y Treasury** | Daily | Real-time | Market rate |
#
# The key challenge: **How do you use monthly CPI in a daily trading model without look-ahead bias?**
# %%
# Frequency overview
frequencies = pl.DataFrame(
{
"series": ["S&P 500", "Initial Claims", "CPI", "GDP", "10Y Treasury"],
"frequency": ["Daily", "Weekly", "Monthly", "Quarterly", "Daily"],
"typical_lag_days": [0, 5, 15, 30, 0],
"observations_per_year": [252, 52, 12, 4, 252],
}
)
frequencies
# %% [markdown]
# ## 2. Loading Pre-Downloaded FRED Data
#
# The download script (`data/macro/download.py`) fetches macro data from FRED and
# saves two files:
#
# - **`fred_macro.parquet`**: Daily-aligned with forward-fill (ready for models)
# - **`fred_macro_raw.parquet`**: Native frequencies (for point-in-time analysis)
#
# Key series in the dataset:
#
# | Series ID | Description | Use Case |
# |-----------|-------------|----------|
# | `t10y2y` | 10Y-2Y Treasury Spread | Yield curve slope, recession indicator |
# | `vixcls` | VIX Volatility Index | Market fear gauge |
# | `unrate` | Unemployment Rate | Labor market health |
# | `cpiaucsl` | Consumer Price Index | Inflation |
# | `dff` | Federal Funds Rate | Monetary policy |
# | `icsa` | Initial Jobless Claims | Weekly economic pulse |
# %%
# Load pre-downloaded macro data
macro_df = load_macro()
# Normalize column names to lowercase (test fixture uses uppercase)
macro_df = macro_df.rename({c: c.lower() for c in macro_df.columns})
print(f"Loaded macro data: {macro_df.shape[0]:,} rows × {macro_df.shape[1]} columns")
print(f"Date range: {macro_df['timestamp'].min()} to {macro_df['timestamp'].max()}")
print(f"Columns: {macro_df.columns}")
# %%
macro_df.tail(10)
# %%
print("Data availability (non-null observations):")
for col in macro_df.columns:
if col != "timestamp":
non_null = macro_df[col].drop_nulls().len()
pct = 100 * non_null / len(macro_df)
print(f" {col:12s}: {non_null:>6,} ({pct:>5.1f}%)")
# %% [markdown]
# ## 3. Point-in-Time: Understanding Release Lag
#
# **The critical insight**: Macro data is NOT available on the observation date.
#
# Example: January CPI measures prices during January, but is released ~February 12th.
# A model running on February 1st should NOT use January CPI—it wasn't public yet.
#
# This is why we need **release dates** (when data became known) vs **observation dates**
# (the period the data describes).
# %%
# Release lag configuration (typical values)
RELEASE_LAGS = {
"dff": 0, # Real-time (daily)
"dgs10": 0, # Real-time (daily)
"dgs2": 0, # Real-time (daily)
"t10y2y": 0, # Real-time (calculated from daily)
"vixcls": 0, # Real-time (market-derived)
"icsa": 5, # Weekly claims: ~5 day lag
"walcl": 7, # Fed balance sheet: ~7 day lag
"cpiaucsl": 15, # CPI: ~15 days after month end
"cpilfesl": 15, # Core CPI: ~15 days
"pcepi": 25, # PCE: ~25 days
"unrate": 5, # Employment report: ~5 days after month
"payems": 5, # Nonfarm payrolls: ~5 days
"civpart": 5, # Labor force participation: ~5 days
"indpro": 15, # Industrial production: ~15 days
"m2sl": 20, # Money supply: ~20 days
"gdp": 30, # GDP: ~30 days (advance estimate)
"gdpc1": 30, # Real GDP: ~30 days
}
print("Release Lags (days after observation period):")
for series, lag in sorted(RELEASE_LAGS.items(), key=lambda x: x[1]):
print(f" {series:12s}: {lag:>3} days")
# %%
def apply_release_lag(df: pl.DataFrame, lag_days: dict[str, int]) -> pl.DataFrame:
"""
Apply release lag to create point-in-time correct alignment.
The forward-filled data already represents what's known at each date,
but we shift by release lag to account for publication delay.
For daily series (lag=0), no change needed.
For monthly series (lag>0), the value shouldn't appear until release date.
NOTE: This uses row-based shift, which assumes continuous daily data with
no gaps. For data with gaps (weekends, holidays), a 15-row shift may not
equal 15 calendar days. For production systems requiring exact calendar-day
precision, use date arithmetic instead:
df.with_columns(
pl.col("timestamp") + pl.duration(days=lag)
)
For this educational notebook, the approximation is acceptable since:
1. Forward-filled macro data has no gaps (every trading day filled)
2. The lag values are approximate anyway (release schedules vary)
"""
result = df.clone()
for col, lag in lag_days.items():
if col in df.columns and lag > 0:
# Shift values forward by lag rows (value appears later)
# Assumes continuous daily data without gaps
result = result.with_columns(pl.col(col).shift(lag).alias(col))
return result
pit_df = apply_release_lag(macro_df, RELEASE_LAGS)
pit_df.tail(10)
# %% [markdown]
# ## 4. ALFRED: True Point-in-Time with Vintage Data
#
# **ALFRED (Archival FRED)** provides historical vintages—what values were known at specific
# points in time. This is critical because macro data gets **revised after initial release**.
#
# ### Why Revisions Matter
#
# | Series | Initial Release | Typical Revision | Example |
# |--------|-----------------|------------------|---------|
# | **GDP** | Advance estimate ~30 days | ±1-2% | Q3 2023: 4.9% → 5.2% |
# | **Nonfarm Payrolls** | First Friday | ±50K jobs | Mar 2024: 303K → 315K |
# | **CPI** | ~15 days | Rare, seasonal adj. | Minor adjustments |
#
# Using **final revised data** in backtests creates **look-ahead bias**—your model uses
# information that wasn't available when the trading decision was made.
#
# In production, you would use ALFRED-style queries (or a wrapper like `ml4t-data`) to download
# a series as it existed on a given vintage date. This notebook illustrates the *conceptual*
# divergence with a small revision example; the concrete API mechanics depend on your data access
# layer and whether you store `realtime_start`/`realtime_end` style metadata.
#
# Example API pattern:
# ```python
# provider.fetch_ohlcv("GDP", start, end, vintage_date="2023-10-26")
# ```
# %%
# Demonstrate revision impact (educational example)
# In practice, use FREDProvider.fetch_ohlcv(..., vintage_date=...) for real vintage data
revision_example = pl.DataFrame(
{
"quarter": ["Q3 2023", "Q3 2023", "Q3 2023", "Q3 2023"],
"release_type": ["Advance", "Second", "Third", "Annual Revision"],
"release_date": ["2023-10-26", "2023-11-29", "2023-12-21", "2024-03-28"],
"gdp_growth_annualized": [4.9, 5.2, 4.9, 5.2],
}
)
revision_example
# %% [markdown]
# Using the final value (5.2%) in a backtest as of October 2023 would be
# look-ahead bias — only the advance estimate (4.9%) was known then.
#
# ### Visualizing Signal Divergence from Revisions
#
# Let's create a concrete example showing how using revised data vs. original
# release data leads to different trading signals. This demonstrates the
# "performance gap" that PIT-incorrect backtests hide. With an "exceptional
# growth" rule (GDP > 5%), the 2023 Q3 revision is the telling case: the advance
# estimate (4.9%) does not fire the signal, but the final value (5.2%) does — so
# a PIT-incorrect backtest reading the final value would take a trade that was
# never actionable in real time.
# %%
# Create revision history based on documented 2023 GDP revisions
# In production, use: FREDProvider.fetch_ohlcv("GDP", vintage_date=...)
gdp_revisions = pl.DataFrame(
{
"quarter": ["Q1", "Q2", "Q3", "Q4"],
"advance_estimate": [1.3, 2.1, 4.9, 3.3], # What traders knew at release
"final_value": [2.0, 2.1, 5.2, 3.4], # What backtests often use incorrectly
}
)
# Simple signal: "Exceptional growth" = GDP > 5.0%
gdp_signals = gdp_revisions.with_columns(
(pl.col("advance_estimate") > 5.0).alias("signal_advance"),
(pl.col("final_value") > 5.0).alias("signal_final"),
)
signal_match = (
gdp_signals.select(pl.col("signal_advance") == pl.col("signal_final")).to_series().sum()
)
print(f"Signal agreement: {signal_match}/4 quarters")
gdp_signals
# %%
# Visualize the divergence
fig_revision = go.Figure()
fig_revision.add_trace(
go.Bar(
name="Advance Estimate (PIT-Correct)",
x=gdp_revisions["quarter"].to_list(),
y=gdp_revisions["advance_estimate"].to_list(),
marker_color="#2E4057", # ML4T blue
text=[f"{v:.1f}%" for v in gdp_revisions["advance_estimate"].to_list()],
textposition="outside",
)
)
fig_revision.add_trace(
go.Bar(
name="Final Value (Look-Ahead Bias)",
x=gdp_revisions["quarter"].to_list(),
y=gdp_revisions["final_value"].to_list(),
marker_color="#048A81", # Teal
text=[f"{v:.1f}%" for v in gdp_revisions["final_value"].to_list()],
textposition="outside",
)
)
fig_revision.add_hline(
y=5.0,
line_dash="dash",
line_color="#8B4513",
annotation_text="Signal Threshold (5%)",
annotation_position="right",
)
# %%
fig_revision.update_layout(
title="GDP Growth - Advance vs Final Estimates (2023)",
barmode="group",
yaxis_title="GDP Growth (%)",
xaxis_title="Quarter",
template="plotly_white",
height=450,
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="center", x=0.5),
)
fig_revision.show()
# %% [markdown]
# ## 5. Creating Macro Features
#
# Raw macro values are often non-stationary (trending over time). For ML models, we create
# features that are:
#
# - **Stationary**: Changes, growth rates, z-scores
# - **Regime-indicative**: Threshold-based classifications
# - **Momentum-based**: Recent changes vs longer-term trends
# %%
def create_macro_features(df: pl.DataFrame) -> pl.DataFrame:
"""Create trading features from aligned macro data."""
result = df.clone()
# Yield Curve Features (T10Y2Y)
if "t10y2y" in df.columns:
result = result.with_columns(
[
# Level
pl.col("t10y2y").alias("yield_curve_spread"),
# Regime classification
pl.when(pl.col("t10y2y") < 0)
.then(pl.lit("inverted"))
.when(pl.col("t10y2y") < 0.5)
.then(pl.lit("flat"))
.when(pl.col("t10y2y") < 1.5)
.then(pl.lit("normal"))
.otherwise(pl.lit("steep"))
.alias("yield_curve_regime"),
# Momentum (20-day change)
(pl.col("t10y2y") - pl.col("t10y2y").shift(20)).alias("yield_curve_momentum_20d"),
# Rolling z-score (252-day)
(
(pl.col("t10y2y") - pl.col("t10y2y").rolling_mean(252))
/ pl.col("t10y2y").rolling_std(252)
).alias("yield_curve_zscore_252d"),
]
)
# VIX Volatility Features
if "vixcls" in df.columns:
result = result.with_columns(
[
pl.col("vixcls").alias("vix"),
# Regime classification
pl.when(pl.col("vixcls") < 15)
.then(pl.lit("low_vol"))
.when(pl.col("vixcls") < 25)
.then(pl.lit("normal_vol"))
.when(pl.col("vixcls") < 35)
.then(pl.lit("elevated_vol"))
.otherwise(pl.lit("high_vol"))
.alias("volatility_regime"),
# Momentum (5-day change)
(pl.col("vixcls") - pl.col("vixcls").shift(5)).alias("vix_momentum_5d"),
# Rolling z-score (63-day / 3-month)
(
(pl.col("vixcls") - pl.col("vixcls").rolling_mean(63))
/ pl.col("vixcls").rolling_std(63)
).alias("vix_zscore_63d"),
]
)
# Unemployment Features
if "unrate" in df.columns:
result = result.with_columns(
[
pl.col("unrate").alias("unemployment_rate"),
# Regime
pl.when(pl.col("unrate") < 4.0)
.then(pl.lit("tight"))
.when(pl.col("unrate") < 6.0)
.then(pl.lit("normal"))
.otherwise(pl.lit("recession"))
.alias("labor_market_regime"),
# 1-month change (21 trading days)
(pl.col("unrate") - pl.col("unrate").shift(21)).alias("unemployment_change_1m"),
# 12-month change
(pl.col("unrate") - pl.col("unrate").shift(252)).alias("unemployment_change_1y"),
]
)
# CPI/Inflation Features
# Important: CPI is a monthly series in levels. If it's been forward-filled to daily,
# using shift(252) (trading days) is NOT a true year-over-year calculation.
# A simple approximation on daily grids is 365 calendar days.
if "cpiaucsl" in df.columns:
result = result.with_columns(
[
# Year-over-year inflation (365 calendar days for monthly forward-filled data)
(pl.col("cpiaucsl") / pl.col("cpiaucsl").shift(365) - 1).alias("inflation_yoy"),
# Inflation regime
pl.when((pl.col("cpiaucsl") / pl.col("cpiaucsl").shift(365) - 1) < 0.02)
.then(pl.lit("low"))
.when((pl.col("cpiaucsl") / pl.col("cpiaucsl").shift(365) - 1) < 0.04)
.then(pl.lit("moderate"))
.otherwise(pl.lit("high"))
.alias("inflation_regime"),
]
)
# Initial Claims Features
# Note: If weekly claims have been forward-filled to daily, rolling windows over days
# are smoothing proxies, not true "4-week averages" over weekly observations.
if "icsa" in df.columns:
result = result.with_columns(
[
pl.col("icsa").alias("initial_claims"),
# Smoothing proxy on daily grid; for a true 4-week average, compute on weekly data first.
pl.col("icsa").rolling_mean(20).alias("claims_smooth_20d"),
# Regime
pl.when(pl.col("icsa") < 250000)
.then(pl.lit("strong"))
.when(pl.col("icsa") < 350000)
.then(pl.lit("normal"))
.otherwise(pl.lit("weak"))
.alias("claims_regime"),
]
)
return result
# Create features
macro_features = create_macro_features(pit_df)
# List new features
original_cols = set(macro_df.columns)
feature_cols = [c for c in macro_features.columns if c not in original_cols and c != "timestamp"]
print(f"Created {len(feature_cols)} macro features:")
for col in sorted(feature_cols):
print(f" - {col}")
# %%
# Display sample of macro features
display_cols = ["timestamp", "yield_curve_spread", "yield_curve_regime", "vix", "volatility_regime"]
if "unemployment_rate" in macro_features.columns:
display_cols.extend(["unemployment_rate", "labor_market_regime"])
macro_features.select([c for c in display_cols if c in macro_features.columns]).tail(10)
# %% [markdown]
# ## 6. Visualizing Macro Regimes
# %%
# Prepare data for visualization (2020 onwards for readability)
VIZ_START_DATE = date(2020, 1, 1)
viz_df = (
macro_features.filter(pl.col("timestamp") >= VIZ_START_DATE)
.filter(pl.col("yield_curve_spread").is_not_null())
.to_pandas()
)
# Build the three-panel macro-indicator figure in a SINGLE cell so the
# inline backend cannot capture a yield-curve-only intermediate render
# (feedback_split_cell_figure_bug).
fig = make_subplots(
rows=3,
cols=1,
shared_xaxes=True,
subplot_titles=("Yield Curve Spread (10Y-2Y)", "Unemployment Rate", "VIX Volatility Index"),
vertical_spacing=0.08,
)
# Yield curve
fig.add_trace(
go.Scatter(
x=viz_df["timestamp"],
y=viz_df["yield_curve_spread"],
mode="lines",
name="10Y-2Y Spread",
line={"color": "#1E3A5F"},
),
row=1,
col=1,
)
fig.add_hline(y=0, line_dash="dash", line_color="red", row=1, col=1)
# Unemployment
if "unemployment_rate" in viz_df.columns:
fig.add_trace(
go.Scatter(
x=viz_df["timestamp"],
y=viz_df["unemployment_rate"],
mode="lines",
name="Unemployment",
line={"color": "#2E5A3F"},
),
row=2,
col=1,
)
# VIX
if "vix" in viz_df.columns:
fig.add_trace(
go.Scatter(
x=viz_df["timestamp"],
y=viz_df["vix"],
mode="lines",
name="VIX",
line={"color": "#5A2E3F"},
),
row=3,
col=1,
)
fig.add_hline(y=20, line_dash="dash", line_color="gray", row=3, col=1)
fig.update_layout(
height=700,
title_text="Macro Regime Indicators (2020-Present)",
showlegend=False,
)
fig.update_yaxes(title_text="Spread (%)", row=1, col=1)
fig.update_yaxes(title_text="Rate (%)", row=2, col=1)
fig.update_yaxes(title_text="Index", row=3, col=1)
fig.show()
# %% [markdown]
# ## 7. Regime Distribution Analysis
# %%
# Analyze regime distributions
print("Regime Distributions (2020+):\n")
regime_cols = [c for c in macro_features.columns if c.endswith("_regime")]
analysis_df = macro_features.filter(pl.col("timestamp") >= VIZ_START_DATE)
for col in regime_cols:
if col in analysis_df.columns:
counts = (
analysis_df.filter(pl.col(col).is_not_null())
.group_by(col)
.len()
.sort("len", descending=True)
)
total = counts["len"].sum()
print(f"{col}:")
for row in counts.iter_rows():
pct = 100 * row[1] / total
print(f" {row[0]:15s}: {row[1]:>5,} ({pct:>5.1f}%)")
print()
# %% [markdown]
# ## 8. Combining with Price Data
#
# The ultimate goal: use macro features as inputs to trading strategies. The pre-aligned
# daily macro data can be joined directly with price data.
# %%
def combine_with_prices(
macro_df: pl.DataFrame, prices_df: pl.DataFrame, date_col: str = "timestamp"
) -> pl.DataFrame:
"""Combine macro features with price data via left join on date."""
return prices_df.join(macro_df, on=date_col, how="left")
# Load real SPY closes and align to the macro window
price_data = (
load_etfs(symbols=["SPY"], start_date=str(VIZ_START_DATE))
.sort("timestamp")
.with_columns(spy_return=pl.col("close").pct_change())
.select(timestamp="timestamp", spy_close="close", spy_return="spy_return")
)
# Combine: SPY trading days on the left, daily-aligned macro features attached
combined = combine_with_prices(macro_features, price_data)
cols = ["timestamp", "spy_close", "spy_return", "yield_curve_regime", "volatility_regime"]
if "labor_market_regime" in combined.columns:
cols.append("labor_market_regime")
combined.select([c for c in cols if c in combined.columns]).tail(10)
# %% [markdown]
# ## 9. Key Takeaways
#
# | Concept | Implementation |
# |---------|---------------|
# | **Point-in-Time** | Apply release lag; use ALFRED vintage queries for historical accuracy |
# | **Frequency Alignment** | Pre-download handles forward-fill; apply additional lag for release delay |
# | **Stationarity** | Transform levels to changes, growth rates, and z-scores |
# | **Regimes** | Threshold-based classifications for interpretable signals |
#
# **Common Pitfalls**:
# - Using observation date instead of release date → look-ahead bias
# - Using final revised values instead of vintage data → look-ahead bias
# - Raw levels instead of stationary transforms → spurious correlations
# - Ignoring release calendar → using information before it's public