HMM regime detection + rolling Fama-French 5-factor regression + risk-aware portfolio optimization on the S&P 500 universe.
Monthly walk-forward backtest (2017–2025).
⚠️ Data Disclaimer
All raw data used in this project are sourced from publicly available datasets (Kenneth R. French Data Library and Yahoo Finance viayfinance). No proprietary or licensed terminal data (e.g., Wind, Bloomberg, CEIC) are included. For full traceability, afetch_data.pyscript is provided to reproduce the dataset from primary sources. If you are adapting this framework to markets with restricted data (e.g., A-shares via Wind), you must replace the data layer with your own licensed feeds and must not redistribute raw tick data in this repository.
graph LR
A[FF5 Factors] --> B[HMM / GMM<br/>Regime Detection]
C[SP500 Prices] --> D[Rolling FF5<br/>Regression]
B --> E[Regime Factor<br/>Means μ]
D --> F[Stock Betas β]
E & F --> G[Score = β · μ]
G --> H[Top-K Selection]
H --> I[Min-Risk +<br/>Signal Tilt]
I --> J[Portfolio]
J --> K[Monthly<br/>Backtest]
| Metric | Signal-Tilt Strategy | S&P 500 Benchmark | Δ |
|---|---|---|---|
| CAGR | 17.51% | 13.19% | +4.3% |
| Sharpe | 0.72 | 0.70 | +0.02 |
| Max Drawdown | -35.90% | -24.17% | -11.7% |
| Hit Rate | 60.7% | — | — |
| Annual Turnover | 676% | — | — |
⚠️ Caveat: The backtest above assumes zero transaction costs. With 676% annual turnover, even modest trading costs (10–20 bps) materially erode the reported alpha. See Limitations.
- Python >= 3.10
- Git
git clone https://github.com/<your-username>/regime-aware-multi-factor.git
cd regime-aware-multi-factor
pip install -r requirements.txtAll raw inputs are publicly available and included in data/ for convenience (total < 2 MB). You can also regenerate them from primary sources:
python fetch_data.py| Data Source | File | Size | License |
|---|---|---|---|
| Kenneth R. French | F-F_Research_Data_5_Factors_2x3.csv |
52 KB | Free academic / non-commercial |
Yahoo Finance (yfinance) |
sp500_monthly_prices.xlsx |
1.1 MB | Public API, CC0-equivalent for price data |
Yahoo Finance (yfinance) |
SP500_Benchmark_Returns.csv |
4 KB | Public API |
| Pre-computed (GMM) | ff5_factors_with_regime.xlsx |
16 KB | Derived from above |
python main_update.pyThis executes a monthly walk-forward backtest from 2017-02 to 2025-12 and produces:
data/backtest_signal_tilt.xlsx— detailed NAV, returns, drawdowns, turnover- Console summary of CAGR, Sharpe, Max Drawdown, etc.
# Sensitivity analysis (parameter grid heatmaps)
python sensitivity_analysis.py
# Visualize holdings for a specific month
python plot_monthly_holdings_example.py --month 2024-12 --save
# Export annual December position snapshots
python export_december_snapshots.py.
├── data/
│ ├── F-F_Research_Data_5_Factors_2x3.csv # FF5 monthly factors (Kenneth French)
│ ├── sp500_monthly_prices.xlsx # S&P 500 constituents monthly close
│ ├── SP500_Benchmark_Returns.csv # S&P 500 index monthly returns
│ ├── ff5_factors_with_regime.xlsx # Pre-computed GMM regime labels
│ └── backtest_signal_tilt.xlsx # ← Generated by main_update.py
├── Backtest_utils.py # Config dataclass, metrics, turnover
├── Cluster_analysis.py # GMM/HMM regime detection wrapper
├── HMM_regime.py # HMM-specific implementation
├── Regression.py # Rolling FF5 regression + clock alignment
├── Dynamic_stock_selection.py # Regime-aware scoring (β · μ)
├── Optimize_Portfolio.py # Risk + signal tilt optimization
├── Factor_covariance.py # Factor model covariance (Σ = BΩB' + Ψ)
├── Regime_forecast.py # RF/XGB next-regime predictor
├── Regime_alignment.py # Permutation-invariant label alignment
├── sensitivity_analysis.py # Parameter grid heatmaps
├── validation.py # Walk-forward regime validation
├── fetch_data.py # Data acquisition pipeline
├── export_december_snapshots.py # Annual position snapshots
├── plot_monthly_holdings_example.py # Single-month visualization
├── main_update.py # Main entry point
├── requirements.txt
├── LICENSE
└── README.md
Price data are stamped at the first day of each month but represent the realized return of the preceding calendar month. align_ff5_clock() shifts factor timestamps forward by one month so that returns[t] and factors[t] correspond to the same economic period.
HMM (GaussianHMM) with stickiness prior = 0.85
→ 3 latent regimes
→ Regime summary: mean of [Mkt-RF, SMB, HML, RMW, CMA] per regime
Alternative: Set CLUSTER_METHOD = "gmm" in main_update.py to use Gaussian Mixture Models.
For each stock
Ridge regularization is supported via ridge_alpha in BacktestConfig.
Given current regime
Stocks with higher scores have factor exposures that align with the "winning" factors of the current regime. Top-$k$ (default 50) are selected for portfolio optimization.
Subject to:
-
$\sum_i w_i = 1$ (fully invested) -
$0 \le w_i \le w_{max}$ (long-only, 10% cap)
Covariance
-
$B$ : beta exposure matrix from FF5 regression -
$\Omega$ : factor covariance (Ledoit-Wolf shrinkage, 12-month lookback) -
$\Psi$ : diagonal idiosyncratic variance matrix with floor1e-4
All hyperparameters are centralized in BacktestConfig (Backtest_utils.py):
| Parameter | Default | Description |
|---|---|---|
top_k |
50 | Number of stocks selected by regime score |
beta_lookback |
60 | Months for rolling FF5 regression |
cov_lookback |
12 | Months for factor covariance estimation |
w_max |
0.10 | Maximum position weight per stock |
lambda_signal |
0.005 | Signal-tilt strength in optimization |
cluster_method |
"hmm" |
Regime model: "hmm" or "gmm" |
cost_bps |
0.0 | Transaction cost in basis points ( |
- Zero transaction costs: The published backtest uses
cost_bps = 0. With 676% annual turnover, even 10 bps (bidirectional) would erode ~0.7–2.7% of annual alpha. Always re-run with realistic costs before drawing conclusions. - Regime forecasting is not wired into the main backtest:
Regime_forecast.py(RF/XGB) predicts the next regime, butmain_update.pycurrently uses the current (contemporaneous) regime. For production use, wire in the forecaster to avoid look-ahead bias. - Regime label alignment:
Regime_alignment.pysolves HMM/GMM permutation invariance but is not invoked in the main loop. Labels may swap across rolling windows. - Survivorship bias: The price universe reflects the S&P 500 as of the data pull date. Delisted or dropped constituents may not be present.
- Short covariance lookback:
cov_lookback = 12months may be noisy during regime transitions.
- Wire
Regime_forecast.pyinto the main walk-forward loop - Add realistic transaction cost modeling
- Implement regime-dependent beta estimation (
$\beta_i^{(s)}$ rather than$\beta_i$ ) - Introduce sector-neutrality constraints
- Extend to A-shares / CSI 300 with localized factor definitions
MIT — feel free to use, modify, and cite.
- Fama-French 5-factor data: Kenneth R. French Data Library
- S&P 500 price data: Yahoo Finance via
yfinance - Course project for MFIT6000C Algorithmic Trading, HKUST
Last updated: July 2026