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

757 lines
24 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]
# # Slow Features and Context: Fundamentals, Macro, Calendar
#
# **Chapter 8: Feature Engineering**
# **Section Reference**: 8.4 - Contextual and Slow-Moving Features
# **Docker image**: `ml4t`
#
# ## Purpose
#
# This notebook covers **slow-moving features** that provide context for faster signals:
#
# 1. **Fundamentals**: Value, quality, growth factors from financial statements
# 2. **Macro**: Economic indicators, yield curves, credit spreads, risk regimes
# 3. **Calendar**: Cyclical encodings for seasonal patterns
#
# ## Key Principle
#
# Slow features update infrequently (quarterly, monthly, or by schedule) but
# condition daily decisions. The binding constraint is **data integrity** —
# ensuring each observation reflects only what was knowable at decision time.
#
# ## Data Policy
#
# All examples use **real data** (SEC XBRL, FRED macro).
#
# ## References
#
# - Fama and French (1992, 1993): Value, size, profitability factors
# - Cochrane (2011): "Presidential Address: Discount Rates" — factor zoo
# - Harvey, Liu, and Zhu (2016): "...and the Cross-Section of Expected Returns"
#
# ## Case Study Mapping
#
# | Case Study | Relevant Features |
# |------------|-------------------|
# | ETFs (`etfs`) | Calendar encodings, macro regimes |
# | US Firm Characteristics (`us_firm_characteristics`) | All fundamental factors |
# | S&P 500 Equity+Options (`sp500_equity_option_analytics`) | Macro + VIX regime |
# %%
"""Slow Features and Context: Fundamentals, Macro, Calendar — contextual features that condition faster signals."""
from __future__ import annotations
import warnings
from datetime import date, datetime, timedelta
import polars as pl
warnings.filterwarnings("ignore")
from data import load_macro as _load_macro_canonical
from data import load_sec_xbrl_fundamentals
# %% tags=["parameters"]
SEED = 42
CALENDAR_START_DATE = "2015-01-01"
# %% [markdown]
# ---
#
# # Part 1: Fundamental Factors
#
# Fundamental factors update quarterly but inform daily trading decisions.
#
# **Key challenges**:
# - Point-in-time accuracy (use announcement date, not period end)
# - Forward-filling to daily frequency
# - Factor staleness between announcements
# %% [markdown]
# ## 1.1 Load Fundamental Data
#
# ### Scope: scaffolding for the construction mechanics, not a real-data value pipeline
#
# `load_fundamentals()` reads SEC XBRL filings. XBRL publishes accounting numbers
# (book equity, earnings, operating cash flow, capex) but does **not** publish
# market capitalization — that comes from market prices on the announcement
# date. To keep the value-factor cells below executable on the XBRL output
# alone, this notebook approximates `market_cap = 2 × book_value`. This is a
# **scaffolding** value: it lets the downstream `compute_value_factors()` cell
# show the mechanics of book-to-market, earnings yield, and cash-flow yield,
# but the resulting numbers are **not** the real-data factor values.
#
# The lookahead-safe, real-data version (XBRL fundamentals joined to daily
# adjusted prices on the announcement date, with point-in-time discipline) is
# demonstrated in the `us_firm_characteristics` case study and uses the
# `load_firm_characteristics()` loader from `data/equities/loader.py`. See
# Chapter 11's case study pipeline (`case_studies/us_firm_characteristics/`)
# and the Chen-Pelger-Zhu (2020) panel for the production version.
# %%
# Denominator safety constant (used by all factor computations)
EPSILON = 1e-10
# %%
# Map lowercase us-gaap concepts to the shorter names used downstream.
# The XBRL loader exposes one column per us-gaap concept in lowercase.
_XBRL_RENAMES = {
"stockholdersequity": "book_value",
"netincomeloss": "earnings",
"revenues": "revenue",
"netcashprovidedbyusedinoperatingactivities": "operating_cf",
"longtermdebt": "total_debt",
"paymentstoacquirepropertyplantandequipment": "capex",
}
def load_fundamentals() -> pl.DataFrame:
"""Load SEC XBRL fundamentals and normalize to factor-friendly names.
Note: `market_cap` remains a SCAFFOLDING approximation (2× book value)
because XBRL does not publish market capitalization. Production systems
should join with actual price data on the announcement date.
"""
df = load_sec_xbrl_fundamentals().rename(_XBRL_RENAMES)
# `assets` preserves its lowercase concept name; alias for downstream code.
df = df.with_columns(
[
# Market cap approximation — SCAFFOLDING only (XBRL has no market cap)
(pl.col("book_value") * 2.0).alias("market_cap"),
pl.col("assets").alias("total_assets"),
]
)
# Accruals (earnings - operating CF)
if "operating_cf" in df.columns:
df = df.with_columns(
pl.when(pl.col("operating_cf").is_not_null())
.then(pl.col("earnings") - pl.col("operating_cf"))
.otherwise(0.0)
.alias("accruals")
)
return df
fundamentals = load_fundamentals()
print(f"Fundamental data: {len(fundamentals):,} rows, {fundamentals['symbol'].n_unique()} symbols")
fundamentals.head(5)
# %% [markdown]
# ## 1.2 Value Factors
#
# Value factors identify stocks trading at discounts relative to fundamentals.
#
# > **Reminder**: every factor below has `market_cap` in the denominator and
# > `market_cap` is the `2 × book_value` scaffolding from §1.1. The cell
# > demonstrates the *construction* of book-to-market, earnings yield, and
# > cash-flow yield; the *values* are not the real-data factor values. See
# > the `us_firm_characteristics` case study for the production version.
# %%
def compute_value_factors(df: pl.DataFrame) -> pl.DataFrame:
"""
Compute value factors with denominator clipping for safety.
"""
return df.with_columns(
[
# Book-to-Market
(pl.col("book_value") / pl.col("market_cap").clip(EPSILON, None)).alias(
"book_to_market"
),
# Earnings yield
(pl.col("earnings") / pl.col("market_cap").clip(EPSILON, None)).alias("earnings_yield"),
# Cash flow yield
(pl.col("operating_cf") / pl.col("market_cap").clip(EPSILON, None)).alias("cf_yield"),
# FCF yield
(
(pl.col("operating_cf") - pl.col("capex"))
/ pl.col("market_cap").clip(EPSILON, None)
).alias("fcf_yield"),
]
)
value_df = compute_value_factors(fundamentals)
print("Value factors computed:")
value_df.select(["symbol", "fiscal_quarter_end", "book_to_market", "earnings_yield"]).tail(10)
# %% [markdown]
# **Interpretation**: A book-to-market ratio of 0.5 means the stock trades at
# 2x its book value — the market assigns significant intangible/growth premium.
# Earnings yield is the inverse of the P/E ratio, making higher values more
# "value-oriented."
# %% [markdown]
# ## 1.3 Quality Factors
#
# Quality factors identify financially healthy companies.
# %%
def compute_quality_factors(df: pl.DataFrame) -> pl.DataFrame:
"""
Compute quality factors with denominator safety.
"""
return df.with_columns(
[
# ROE
(pl.col("earnings") / pl.col("book_value").clip(EPSILON, None)).alias("roe"),
# ROA
(pl.col("earnings") / pl.col("total_assets").clip(EPSILON, None)).alias("roa"),
# Accruals ratio (lower = better quality)
(pl.col("accruals") / pl.col("total_assets").clip(EPSILON, None)).alias(
"accruals_ratio"
),
# Leverage
(pl.col("total_debt") / pl.col("total_assets").clip(EPSILON, None)).alias(
"debt_to_assets"
),
]
)
quality_df = compute_quality_factors(value_df)
print("Quality factors computed:")
quality_df.select(["symbol", "fiscal_quarter_end", "roe", "roa", "accruals_ratio"]).tail(10)
# %% [markdown]
# ## 1.4 Daily Alignment with Correct ASOF Join
#
# **Critical**: Both DataFrames must be sorted by the join keys.
#
# ```python
# # WRONG: Only sorting by date
# daily_df.join_asof(fundamental_df.sort("timestamp"), on="timestamp")
#
# # CORRECT: Sort both by [symbol, date]
# daily_df.sort(["symbol", "timestamp"]).join_asof(
# fundamental_df.sort(["symbol", "announcement_date"]),
# left_on="timestamp",
# right_on="announcement_date",
# by="symbol",
# )
# ```
# %%
def align_factors_to_daily(
factor_df: pl.DataFrame,
daily_dates: pl.DataFrame,
announcement_col: str = "announcement_date",
) -> pl.DataFrame:
"""
Align quarterly factors to daily frequency using ASOF join.
CRITICAL: Both frames must be sorted by join keys.
"""
# Ensure sorting on both frames (REQUIRED for join_asof)
factor_sorted = factor_df.sort(["symbol", announcement_col])
daily_sorted = daily_dates.sort(["symbol", "timestamp"])
# ASOF join: each day gets most recent announced values
aligned = daily_sorted.join_asof(
factor_sorted,
left_on="timestamp",
right_on=announcement_col,
by="symbol",
strategy="backward",
)
return aligned
# Create daily dates for alignment demo
symbols = quality_df["symbol"].unique().to_list()
daily_dates = (
pl.DataFrame(
{"timestamp": pl.date_range(date(2024, 1, 1), date(2024, 12, 31), "1d", eager=True)}
)
.filter(pl.col("timestamp").dt.weekday() < 5) # Business days
.join(pl.DataFrame({"symbol": symbols}), how="cross")
)
aligned = align_factors_to_daily(
quality_df.select(
["symbol", "announcement_date", "fiscal_quarter_end", "roe", "book_to_market"]
),
daily_dates,
)
print(f"Daily aligned: {len(aligned):,} rows")
aligned.filter(pl.col("symbol") == symbols[0]).head(10)
# %% [markdown]
# ### 1.5 Fake Sample Size Warning
#
# Forward-filling quarterly data to daily frequency inflates the apparent
# sample size. Each unique fundamental observation appears ~63 times (one
# quarter of trading days), but carries the same information.
# %%
# Count unique fundamental observations vs total daily rows
if len(aligned) > 0:
n_daily = len(aligned)
# Approximate unique observations: distinct (symbol, fiscal_quarter_end) pairs
n_unique = (
aligned.drop_nulls(["fiscal_quarter_end"])
.select(["symbol", "fiscal_quarter_end"])
.unique()
.shape[0]
)
inflation_ratio = n_daily / max(n_unique, 1)
print(f"Daily rows: {n_daily:,}")
print(f"Unique observations: {n_unique:,}")
print(f"Inflation ratio: {inflation_ratio:.0f}x")
print(
"\nEach fundamental observation is repeated ~63 times via forward-fill."
"\nThis inflates t-statistics if not accounted for."
"\nSee Section 7.2 on uniqueness weighting for the correction."
)
# %% [markdown]
# ---
#
# # Part 2: Macro Features
#
# Macro data comes at mixed frequencies (daily, weekly, monthly, quarterly).
#
# **Key considerations**:
# - **Publication lag**: Monthly data has 2-4 week delay
# - **Revisions**: Initial estimates are often revised
# - **Forward-fill carefully**: Limit to avoid stale data
# %% [markdown]
# ## 2.1 Load Macro Data
#
# > **Publication Lag Warning**: Macro data has significant publication delays.
# > Conservative approach: Lag monthly data by 30+ days.
# %%
macro = _load_macro_canonical()
print(f"Macro data: {len(macro):,} rows")
print(f"Columns: {[c for c in macro.columns if c != 'timestamp'][:10]}")
# %% [markdown]
# ## 2.2 Trend Features with Publication Lag
#
# > **Conservative Lagging**: For monthly data, add 30-day lag to ensure
# > the data was actually available at the trading date.
# %%
def create_macro_trend_features(
df: pl.DataFrame,
cols: list[str],
windows: list[int] = [21, 63, 252],
conservative_lag: int = 0, # Days to lag for publication delay
) -> pl.DataFrame:
"""
Create trend features from macro data.
Args:
df: Macro data
cols: Columns to process
windows: Rolling window sizes
conservative_lag: Days to lag for publication delay safety
"""
# Apply conservative lag if specified
if conservative_lag > 0:
lag_exprs = [
pl.col(c).shift(conservative_lag).alias(f"{c}_lagged") for c in cols if c in df.columns
]
df = df.with_columns(lag_exprs)
cols = [f"{c}_lagged" for c in cols if c in df.columns]
feature_exprs = []
for col in cols:
if col not in df.columns:
continue
for w in windows:
# Z-score
feature_exprs.append(
(
(pl.col(col) - pl.col(col).rolling_mean(w))
/ pl.col(col).rolling_std(w).clip(EPSILON, None)
).alias(f"{col}_zscore_{w}d")
)
# Rate of change
feature_exprs.append(pl.col(col).pct_change(w).alias(f"{col}_roc_{w}d"))
return df.with_columns(feature_exprs)
# Apply to VIX (daily, no lag needed)
daily_cols = ["vixcls", "dgs10", "t10y2y"]
macro_features = create_macro_trend_features(
macro,
[c for c in daily_cols if c in macro.columns],
windows=[21, 63],
)
print(f"Macro features: {len(macro_features.columns)} columns")
# %% [markdown]
# **Interpretation**: Z-scored macro data measures whether the current indicator
# level is unusual relative to its recent history. A VIX z-score of +2 means
# fear is elevated relative to the last 21 or 63 days — this conditions how
# momentum and carry signals perform.
# %% [markdown]
# ## 2.3 Monthly Features with Correct Forward-Fill
#
# **Fix**: Use forward-filled version for YoY/3m changes, not raw monthly.
# %%
def create_monthly_features(
df: pl.DataFrame,
monthly_cols: list[str],
conservative_lag: int = 30, # Monthly data publication delay
) -> pl.DataFrame:
"""
Create features from monthly macro data.
Uses forward-filled version for change calculations.
Applies conservative lag for publication delay.
"""
feature_exprs = []
for col in monthly_cols:
if col not in df.columns:
continue
# Forward-fill with limit (avoid very stale data)
ffill_col = f"{col}_ffill"
df = df.with_columns(
pl.col(col).shift(conservative_lag).forward_fill(limit=45).alias(ffill_col)
)
# YoY change (using forward-filled, lagged version)
feature_exprs.append(pl.col(ffill_col).pct_change(252).alias(f"{col}_yoy"))
# 3-month change
feature_exprs.append(pl.col(ffill_col).pct_change(63).alias(f"{col}_3m_chg"))
if feature_exprs:
df = df.with_columns(feature_exprs)
return df
# Example: unemployment (monthly)
if "unrate" in macro.columns:
macro_features = create_monthly_features(macro_features, ["unrate"], conservative_lag=30)
# %% [markdown]
# ## 2.4 Relative Value Features
#
# **Naming fix**: Rolling median ≠ percentile rank. Be precise.
# %%
def create_relative_value_features(df: pl.DataFrame) -> pl.DataFrame:
"""
Create relative value features with correct naming.
Note: rolling_median is NOT a percentile rank - it's the median value.
Percentile rank would be: rank(current) / count (0-100 scale).
"""
feature_exprs = []
# Credit spread (if available)
if "bamlc0a0cm" in df.columns:
feature_exprs.append(pl.col("bamlc0a0cm").alias("credit_spread"))
# Term spread (if available)
if "t10y2y" in df.columns:
feature_exprs.append(pl.col("t10y2y").alias("term_spread"))
if feature_exprs:
df = df.with_columns(feature_exprs)
# Rolling MEDIAN (not percentile - be precise about naming)
median_cols = ["vixcls", "credit_spread", "term_spread"]
median_exprs = [
pl.col(c).rolling_median(252).alias(f"{c}_rolling_median_252d")
for c in median_cols
if c in df.columns
]
if median_exprs:
df = df.with_columns(median_exprs)
return df
macro_features = create_relative_value_features(macro_features)
# %% [markdown]
# ## 2.4b Yield-Curve Slope Feature
#
# The yield-curve slope (10Y-2Y spread) is loaded as `t10y2y`, but the text
# specifies additional processing: a 5-day EMA for smoothing and a 250-day
# z-score for regime-relative positioning.
# %%
# Yield-curve slope: EMA smoothing + rolling z-score
macro_features = macro_features.with_columns(
pl.col("t10y2y").ewm_mean(span=5, ignore_nulls=True).alias("yc_slope_ema5"),
).with_columns(
[
(
(pl.col("yc_slope_ema5") - pl.col("yc_slope_ema5").rolling_mean(250))
/ pl.col("yc_slope_ema5").rolling_std(250).clip(EPSILON, None)
).alias("yc_slope_zscore_250d"),
]
)
print("Yield-curve slope feature:")
macro_features.select(["timestamp", "t10y2y", "yc_slope_ema5", "yc_slope_zscore_250d"]).tail(5)
# %% [markdown]
# **Interpretation**: The z-score centers the slope relative to its recent history.
# Values above +2 indicate an unusually steep curve (risk-on, growth expectations);
# below -2 indicates inversion (recession signal). The EMA removes daily noise
# without introducing significant lag.
# %% [markdown]
# ## 2.5 Risk Regime Features
# %%
def create_risk_regime_features(df: pl.DataFrame) -> pl.DataFrame:
"""Create risk regime indicators."""
feature_exprs = []
# VIX regime (thresholds: <15 low, 15-25 normal, >25 high)
if "vixcls" in df.columns:
feature_exprs.append(
pl.when(pl.col("vixcls") < 15)
.then(0)
.when(pl.col("vixcls") < 25)
.then(1)
.otherwise(2)
.alias("vix_regime")
)
# VIX ratio to 252-day max
feature_exprs.append(
(pl.col("vixcls") / pl.col("vixcls").rolling_max(252).clip(EPSILON, None)).alias(
"vix_relative_to_max"
)
)
# Credit regime
if "credit_spread" in df.columns:
feature_exprs.append(
pl.when(pl.col("credit_spread") < 1.0)
.then(0)
.when(pl.col("credit_spread") < 2.0)
.then(1)
.otherwise(2)
.alias("credit_regime")
)
return df.with_columns(feature_exprs) if feature_exprs else df
macro_features = create_risk_regime_features(macro_features)
print("Risk regime features:")
macro_features.select([c for c in macro_features.columns if "regime" in c or "relative" in c]).tail(
5
)
# %% [markdown]
# ---
#
# # Part 3: Calendar and Seasonal Encodings
#
# Calendar features encode **predictable clocks**: sessions, day-of-week,
# month-of-year, and scheduled events. The key principle is to encode
# **phase and proximity**, not outcomes.
# %% [markdown]
# ## 3.1 Cyclical Encoding
#
# Encoding month as an integer (1-12) implies an ordinal relationship
# (December > January). Cyclical sin/cos encoding removes this artifact:
#
# $$x_{\sin} = \sin\left(\frac{2\pi \cdot m}{12}\right), \quad x_{\cos} = \cos\left(\frac{2\pi \cdot m}{12}\right)$$
# %%
from ml4t.engineer.features.ml import cyclical_encode
from data import load_etfs
etfs = load_etfs()
spy = etfs.filter(pl.col("symbol") == "SPY").sort("timestamp")
calendar_start_dt = datetime.fromisoformat(CALENDAR_START_DATE)
spy = spy.filter(pl.col("timestamp") >= calendar_start_dt)
# Cyclical encoding for month
month_encoded = cyclical_encode(pl.col("timestamp").dt.month(), period=12, name_prefix="month")
cal_df = spy.with_columns(**month_encoded)
# Day-of-week encoding (Monday=1, Friday=5)
dow_encoded = cyclical_encode(pl.col("timestamp").dt.weekday(), period=5, name_prefix="dow")
cal_df = cal_df.with_columns(**dow_encoded)
print("Calendar encodings (last 10 rows):")
cal_df.select(["timestamp", "month_sin", "month_cos", "dow_sin", "dow_cos"]).tail(10)
# %% [markdown]
# **Usage**: Calendar features are primarily **state variables** for conditioning.
# For example, momentum signals may behave differently in January (tax-loss selling
# reversal) versus other months. Time-to-event encodings (e.g., days to next
# earnings, days to FOMC) follow the same pattern.
#
# **Note**: Volatility state features (vol ratio, percentile, decile) and
# price-derived regime indicators (variance ratio, fractal efficiency) are
# covered in `01_price_volume_features` since they derive from price data.
# Signal × state interactions and feasibility overlays are in
# `06_robustness_sensitivity`.
# %% [markdown]
# ## 3.2 Time-to-Event Encoding
#
# Time-to-event measures proximity to a known future event (earnings, FOMC,
# rebalance). The text specifies:
#
# $$d_{t,a} = \min(T_{\text{next}} - t, \; H_{\max})$$
#
# where $T_{\text{next}}$ is the next event date and $H_{\max}$ caps the
# feature to avoid extreme values far from events.
# %%
# Synthetic earnings calendar for demonstration
# Real systems would load from SEC EDGAR filing dates
earnings_dates = []
for symbol in ["AAPL", "MSFT", "GOOGL"]:
# Quarterly earnings approximately 45 days after quarter end
for q_end in [
date(2023, 3, 31),
date(2023, 6, 30),
date(2023, 9, 30),
date(2023, 12, 31),
date(2024, 3, 31),
date(2024, 6, 30),
date(2024, 9, 30),
date(2024, 12, 31),
]:
ann_date = q_end + timedelta(days=45)
earnings_dates.append({"symbol": symbol, "earnings_date": ann_date})
earnings_cal = pl.DataFrame(earnings_dates).sort(["symbol", "earnings_date"])
# %%
# Create daily dates and compute time-to-event features
daily = (
pl.DataFrame(
{"timestamp": pl.date_range(date(2023, 1, 1), date(2024, 12, 31), "1d", eager=True)}
)
.filter(pl.col("timestamp").dt.weekday() < 5)
.join(pl.DataFrame({"symbol": ["AAPL", "MSFT", "GOOGL"]}), how="cross")
.sort(["symbol", "timestamp"])
)
# Rolling forward join: for each date, find next earnings date
H_MAX = 63 # Cap at 63 trading days
daily_with_events = daily.join_asof(
earnings_cal.sort(["symbol", "earnings_date"]),
left_on="timestamp",
right_on="earnings_date",
by="symbol",
strategy="forward",
).with_columns(
[
(pl.col("earnings_date") - pl.col("timestamp"))
.dt.total_days()
.clip(0, H_MAX)
.alias("days_to_earnings"),
]
)
# %%
# Bin into pre/post windows
daily_with_events = daily_with_events.with_columns(
pl.when(pl.col("days_to_earnings") <= 2)
.then(pl.lit("pre_2d"))
.when(pl.col("days_to_earnings") <= 5)
.then(pl.lit("pre_5d"))
.when(pl.col("days_to_earnings") > H_MAX - 1)
.then(pl.lit("far"))
.otherwise(pl.lit("normal"))
.alias("event_proximity")
)
print("Time-to-event features:")
# Show a sample around an earnings date
sample_symbol = "AAPL"
print(
daily_with_events.filter(
(pl.col("symbol") == sample_symbol)
& (pl.col("timestamp").is_between(date(2023, 4, 25), date(2023, 5, 20)))
).select(["timestamp", "symbol", "days_to_earnings", "event_proximity"])
)
# %% [markdown]
# **Interpretation**: Time-to-event serves as a **state variable** — a label
# that partitions trading days into discrete proximity windows
# (pre-2d, pre-5d, normal, far). These windows feed downstream signal × state
# interactions (see `06_robustness_sensitivity` for the IC-conditioning
# pattern); this notebook covers only the encoding step.
# %% [markdown]
# ## Summary
#
# ### Fundamentals
# - **Value**: Book-to-market, earnings yield, CF yield
# - **Quality**: ROE, ROA, accruals ratio
# - **Alignment**: ASOF join with both frames sorted by `[symbol, date]`
# - **Scaffolding**: Market cap approximation is for teaching only
#
# ### Macro
# - **Publication lag**: Add 30-day lag for monthly data
# - **Forward-fill**: Use filled version for YoY/3m changes
# - **Naming**: Rolling median $\neq$ percentile rank (be precise)
# - **Risk regimes**: VIX thresholds, credit regime from spread levels
#
# ### Calendar
# - **Cyclical encoding**: sin/cos for month, day-of-week, time-to-event
# - **Phase, not outcome**: Encode timing, not post-event realized moves
#
# ### Key Patterns
#
# | Feature Type | Update Freq | Alignment | Use Case |
# |--------------|-------------|-----------|----------|
# | Fundamentals | Quarterly | ASOF by announcement | Factor signals |
# | Macro | Daily/Monthly | Forward-fill + lag | Context, regime |
# | Calendar | Deterministic | Direct encoding | Seasonality |
#
# ### Next Notebooks
#
# - `05_feature_selection` — Feature selection and deduplication (§8.6)
# - `06_robustness_sensitivity` — Regime conditioning, interactions (§8.6)