# --- # 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