A quantitative research project that forecasts equity log-returns by combining autoregressive price dynamics with a broad macro feature matrix sourced from multiple government data agencies. Three forecasting paradigms are evaluated under strict walk-forward conditions, with emphasis on statistical validity, no look-ahead bias, and honest out-of-sample measurement.
Seven models currently implemented (all walk-forward tested over the final 252 trading days). Training window differs by family. The window choices are listed in the walk-forward section further down.
| # | Model | Class | Mean equation | Variance | Training window |
|---|---|---|---|---|---|
| 1 | ARIMA-GARCH (baseline) | Econometric | ARMA(p*,q*) on returns, no exog | GARCH(1,1) | Expanding |
| 2 | XGBoost, price-only | ML (tree) | 7 price features (5 AR lags + rolling vol + ewma vol) | n/a | Expanding |
| 3 | XGBoost + Macro | ML (tree) | 7 price + 21 macro features (shift-1) | n/a | Expanding |
| 4 | LSTM, price-only | Deep learning | 7 price features, seq_len=10, hidden=32 | n/a | Rolling 504d |
| 5 | LSTM + Macro | Deep learning | 7 price + PCA(21→10) macro, hidden=64, 2 layers, dropout 0.2 | n/a | Rolling 504d |
| 6 | PCA ARIMAX-GARCH | Statistical ML | ARIMA(y, exog=PCA(k=5), order=(p,0,q)) joint MLE. (p,q) one-off AIC on initial window |
GARCH(1,1) on residuals | Rolling ARIMAX_WINDOW (grid-search optimised) |
| 7 | PLS ARIMAX-GARCH | Latent-factor | ARIMA(y, exog=PLS(c=3), order=(p,0,q)) joint MLE. (p,q) one-off AIC on initial window |
GARCH(1,1) on residuals | Rolling ARIMAX_WINDOW (grid-search optimised) |
Planned additions (see the roadmap below): four more models (XGB and LSTM with EN-selected macro, XGB and LSTM with an explicit regime variable) plus HMM-based regime detection variants.
All models target the daily log-return
The baseline mean equation is selected by AIC grid search over
where
Special case, ARIMA(0,0,0). When AIC selects $(p^,d^,q^*) = (0,0,0)$, the mean equation collapses to a constant:
This is the degenerate "random walk with drift" mean assumption. The model attributes all return variation to the conditional variance process, not to predictable linear structure.
The macro-augmented models follow a two-step procedure. Mean via joint MLE, variance via GARCH on the mean residuals.
Step 1. Joint ARIMAX mean equation with compressed exogenous block.
where $\mathbf{f}{t-1}$ contains the per-window PCA (or PLS) scores of the release-lag-aligned macro block. All parameters $(c,,\phi{1:p},,\theta_{1:q},,\boldsymbol{\beta},,\sigma_\varepsilon^2)$ are estimated jointly by MLE via statsmodels.tsa.arima.model.ARIMA(y, exog=scores, order=(p, 0, q), trend='c').fit(). The order
Step 2. Variance equation on residuals.
Fitted with mean="Zero" (the residual series is already mean-zero by construction). This ensures
| Parameter | Interpretation | Constraint |
|---|---|---|
| Long-run variance floor | ||
| ARCH term, shock sensitivity | ||
| GARCH term, variance persistence | ||
| n/a | Stationarity (finite unconditional variance) | |
| n/a | Unconditional variance |
Let
- Stationarity transform at native frequency (see table below).
-
Release-lag shift. Index shifted forward by
$\ell_k$ to prevent look-ahead:$$\tilde{t}_k = t_k^{\text{ref}} + \ell_k$$ -
Forward-fill to daily trading calendar (limit =
$\delta_k$ trading days by frequency). -
Lag construction. For each lag
$L = 1, \ldots, L_{\max}$ , feature$x_{k,t}^{(L)} = \tilde{z}_{k,t-L}$ .
The exogenous design matrix is:
Step 6a. Compression. Principal components ARIMAX_WINDOW:
The PLS variant is analogous but supervised on PLSRegression(n_components=c).fit(X, y)), producing target-correlated scores
Step 6b. Joint ARIMAX MLE. The compressed scores are fed as exog to a single statsmodels.tsa.arima.model.ARIMA fit that estimates the constant, AR(
The order
Why joint MLE, not two-step residual regression. Fitting ARIMA without exog first and then regressing its residuals on
The rolling re-fit keeps the covariance estimate calibrated to the current macro regime. An expanding-window fit on 13 years of data (2012 to 2025) spans ZIRP, COVID, the 2021 to 2023 inflation shock, and the fastest rate-hike cycle since 1980. The eigenvectors of a 13-year covariance matrix are regime averages that describe no single period well. Within a 2-year window the covariance structure is approximately stationary.
Applying PCA to
| Component | Variance share | Macro interpretation |
|---|---|---|
| PC1, Level | 60 to 80% | Global risk-on/risk-off. DGS10, credit spread, and VIX all co-move. Driven by Fed policy expectations and growth surprises. |
| PC2, Slope | 10 to 20% | Short vs long end diverge. Yield-curve steepening or flattening. Recession vs expansion transitions. |
| PC3, Curvature | 5 to 10% | Belly vs wings. Term premium dynamics, intermediate supply or demand. |
| PC4+ | < 5% | Noise. Small $ |
Loading stability. At the 2020 COVID crash and the 2022 tightening, PC1 loadings rotate. Front-end instruments decouple from long-end ones. The rolling window re-estimates eigenvectors after each shift, keeping the model calibrated. An expanding-window PCA lags regime rotations by months.
For test dates
| Model family | Training window |
|---|---|
| ARIMA-GARCH baseline | Expanding: |
| XGBoost (both variants) | Expanding: |
| LSTM (both variants) | Rolling 504 days: |
| PCA / PLS ARIMAX-GARCH | Rolling ARIMAX_WINDOW: |
One-step-ahead forecast at each step: $\hat{r}t \mid \mathcal{F}{t-1}$. No future data ever enters any training window.
The rolling window
where VAL_SIZE = 126). MSE is used as tiebreak. The selected
| Series | Feature | Transform | Release lag |
|---|---|---|---|
| DGS10 | d_DGS10 |
diff | 0d (daily) |
| DGS2 | d_DGS2 |
diff | 0d |
| VIXCLS | d_VIXCLS |
diff | 0d |
| BAA10Y | d_BAA10Y |
diff | 0d |
| DGS10 minus DGS2 | d_term_spread |
diff | 0d |
Tier A. Daily (ffill=5)
| Series | Feature | Transform |
|---|---|---|
| T5YIE | d_T5YIE |
diff |
| T10YIE | d_T10YIE |
diff |
| T5YIFR | d_T5YIFR |
diff |
| DFF | d_DFF |
diff |
Tier B. Weekly (ffill=7)
| Series | Feature | Transform | Release lag |
|---|---|---|---|
| STLFSI4 | STLFSI4 |
none | 6d |
| NFCI | NFCI |
none | 5d |
| WALCL | d_WALCL |
log_diff | 2d |
Tier C. Monthly (ffill=25)
| Series | Feature | Transform | Release lag |
|---|---|---|---|
| CPIAUCSL | mom_CPI |
log_diff (MoM) | 15d |
| CPILFESL | mom_CoreCPI |
log_diff (MoM) | 15d |
| PAYEMS | d_PAYEMS |
log_diff | 5d |
| UNRATE | d_UNRATE |
diff | 5d |
| INDPRO | d_INDPRO |
log_diff | 16d |
| UMCSENT | d_UMCSENT |
diff | 14d |
Note on CPI transforms. YoY log-diff (12-month) was non-stationary (ADF p > 0.05) over 2012 to 2025 due to the 2021 to 2023 inflation surge. I switched to MoM log-diff (lags=1), which passes ADF at p ~ 0 throughout the sample.
API: BLS v2 POST. Keyless gives 25 req/day. Registered gives 500 req/day via BLS_API_KEY.
| BLS Series ID | Feature | Transform | Release lag |
|---|---|---|---|
| CUSR0000SAH1 | mom_ShelterCPI |
log_diff (MoM) | 15d |
| CUSR0000SAM | mom_MedicalCPI |
log_diff (MoM) | 15d |
| CES0500000003 | d_HourlyEarnings |
log_diff | 5d |
| JTS00000000JOR | d_JOLTS |
diff | 35d |
Raw data is cached to .cache/ to avoid re-hitting the daily limit. If the limit is exceeded, BLS features are skipped gracefully and the pipeline continues with the remaining 21 series.
API: BEA REST API (https://apps.bea.gov/api/data/, key via BEA_API_KEY, Year=ALL).
| BEA Table | Series | Feature | Transform | Release lag |
|---|---|---|---|---|
| T10101 | A191RL | GDP_growth_pct |
none (already %) | 30d |
| T20304 | DPCERG | d_yoy_PCE_PI |
Δ YoY log-diff (4 qtrs) | 30d |
| T11200 | A055RC | yoy_CorpProfits |
YoY log-diff (4 qtrs) | 45d |
Note on PCE transform. YoY log-diff and QoQ log-diff of DPCERG are non-stationary over 2010 to 2025 (ADF p > 0.05). The first difference of the YoY log-diff (
d_yoy_log_difftransform =log().diff(4).diff()) is stationary at ADF p = 0.039 (post-2010). This represents the change in annual PCE inflation, which is economically meaningful and stationary. Forward-fill limit is 70 trading days (one quarter).
| Transform key | Operation | Typical use |
|---|---|---|
diff |
Yield/rate levels | |
log_diff |
Price/activity indices | |
yoy_log_diff |
|
Money supply, GDP level |
d_yoy_log_diff |
Price indices with structural breaks | |
pct_change |
Generic % change | |
none |
Already-stationary composites (NFCI, STLFSI4) |
Every monthly or quarterly series carries a release_lag (calendar days). After the stationarity transform, the series index is shifted forward:
Then aligned to the daily trading calendar via union-index forward-fill:
union_idx = trading_idx ∪ transformed_idx (sorted)
aligned = transformed.reindex(union_idx).ffill(limit=δ_k).reindex(trading_idx)
This two-step reindex ensures that publication dates landing on weekends or holidays still propagate correctly to the next trading day. The ffill limit prevents stale data from propagating too far.
mmforecasting/
├── notebooks/
│ └── forecasting_analysis.ipynb # Main analysis
├── src/
│ ├── macro_utils.py # Shared. Index normalisation, transforms, ADF gate.
│ ├── fred_pipeline.py # Core FRED (5) plus Extended FRED (13 series)
│ ├── bls_pipeline.py # BLS direct API (4 series, graceful degradation)
│ ├── bea_pipeline.py # BEA NIPA API (3 quarterly series)
│ ├── arimax_models.py # PCA and PLS joint-MLE ARIMAX plus GARCH
│ └── evaluation.py # Walk-forward metrics
├── .cache/ # Parquet cache (gitignored)
├── .env # API keys (FRED, BEA, BLS, Alpaca, News)
├── requirements.txt
└── README.md
All pipelines import from here to guarantee a consistent index contract:
_to_date_index(idx)takes a tz-aware or tz-naiveDatetimeIndexand returns tz-naive midnight.apply_release_lag(df, lag_days)shifts the index forward and re-normalises to midnight.apply_transform(series, transform, lags)is the dispatch table for all transform types.fetch_chunked(fetch_fn, start, end, chunk_years)splits API calls by year window.validate_features(df, label, ...)checks non-NaN fraction plus the ADF gate per column.
# Activate environment
source .venv/bin/activate
# Copy and fill API keys
cp .env.example .env # add FRED_API_KEY, BEA_API_KEY (BLS key optional)
# Launch notebook
jupyter notebook notebooks/forecasting_analysis.ipynbTICKER = "AAPL" # Target equity
START_DATE = "2012-01-01" # Data start (FRED extended data available from ~2004)
END_DATE = "2025-12-31" # Data end
TEST_SIZE = 252 # Walk-forward test window (trading days)
# ARIMAX mean-equation hyperparameters
N_AR_LAGS_EX = 5 # AR lag depth for the joint-dropna design-matrix alignment
N_EXOG_LAGS = 5 # Macro feature lag depth (per series)
PCA_K = 5 # PCA components fed to ARIMAX as exog
N_COMP_PLS = 3 # PLS latent components fed to ARIMAX as exog
MAX_AR_ARIMAX = 4 # ARIMAX (p, q) AIC grid-search, max AR order
MAX_MA_ARIMAX = 2 # ARIMAX (p, q) AIC grid-search, max MA order
# Rolling-window optimisation
WINDOW_CANDIDATES = [126, 252, 378, 504, 630, 756] # 0.5Y to 3Y
ARIMAX_WINDOW = 504 # Default 2Y. Overwritten by the grid-search cell.
VAL_SIZE = 126 # Validation fold for window selection (6 months)Every data source is validated before downstream use:
- Non-NaN fraction of at least 50% per column (expected ≥ 95% for post-2010 data).
- ADF stationarity with p < 0.05 for all transformed columns.
-
Release-lag lookahead check. No value from period
$t$ appears before$t + \ell_k$ .
The design matrix cell prints a full summary upon completion:
Macro sources : 3 block(s)
Total macro vars : 21
Aligned observations : 3,479
Exog features : 105 (21 macro vars × 5 lags)
- This project evaluates statistical predictability, not economic profitability. No transaction costs, slippage, or risk constraints are applied.
- Residual dependence may remain due to regime shifts, structural breaks, or long-memory effects.
- Results should be interpreted as model comparison, not as trading signals.
| Block | Description | Key artefact(s) |
|---|---|---|
| Macro shared utilities | macro_utils.py plus fred_pipeline.py. Shared transforms, ffill, and ADF gate. |
src/macro_utils.py |
| Extended FRED | 13 series across three tiers (daily, weekly, monthly). | fred_pipeline.py::EXTENDED_FRED |
| BLS direct API | 4 series with rate-limit-aware caching and graceful degradation. | src/bls_pipeline.py |
| BEA direct API | 3 quarterly NIPA series (GDP growth, PCE inflation delta, corp profits). | src/bea_pipeline.py |
| LSTM-with-macro robustness | Higher-capacity LSTM config (hidden=64, 2 layers, dropout 0.2), PCA(10) macro compression inside the runner, plus or minus 5σ clipping. | LSTM macro config + run_lstm_backtest |
| Rolling window plus window optimisation | Walk-forward grid search over six candidate windows on a 6-month validation fold. PCA and PLS backtests use the rolling slice. 3-factor PCA interpretation documented in the methodology cells. | ARIMAX cells |
| Block | Description | Status |
|---|---|---|
| EN-selected macro for XGB and LSTM | Back-project EN coefficients through PCA loadings to rank original macro series by L1 survival frequency. Feed top-K to XGBoost and LSTM (2 new models). | Planned |
| Explicit regime variable | Rule-based composite (T10Y3M plus VIXCLS plus NFCI via expanding percentile ranks) for XGBoost and LSTM (2 new models). | Planned |
| HMM regime detection | Hidden Markov Model on returns plus volatility, with an optional multivariate extension using VIX and yield spread. Outputs regime posteriors as features for XGB and LSTM. | Planned |
| News sentiment | Alpaca News API plus FinBERT (news_pipeline.py), gated by USE_NEWS flag. |
Not started |
MIT