Add regime gate walk-forward test (EVIDENCE#050)

- 3 detector types (dispersion/vol/HMM) × 14 configs across 5 years
- Dispersion gates: 0% trip rate everywhere (dead)
- Vol gates: trip differential +32-47pp but destroy returns in good years
- HMM gates: +6pp differential, hmm_0.7 improves 2023/2025 but kills 2026
- Guard candidate regime gate REFUTED (ch 11)
- Script: book/scripts/regime_gate_bt.py
- Results: book/data/regime_gate/regime_gate_trip_rates.csv
This commit is contained in:
zhaoli
2026-08-20 22:14:31 +00:00
parent 4831a2770c
commit 718b048df9
6 changed files with 1636 additions and 2 deletions
+1
View File
@@ -70,6 +70,7 @@ The running scoreboard of every quantitative claim in the book. Updated per chap
| Shorter training windows (1y/2y) recover the edge | REFUTED (every test year negative; only the growing window ever goes positive; mean annual excess ≈ −13% for every window length) | EVIDENCE#046 → exp 55 |
| The edge concentrates in fresh (low-staleness) predictions | REFUTED (every 90-day staleness bucket negative; freshest bucket most negative; 2025 gains are late-year at 336–397d staleness) | EVIDENCE#047 → exp 56 |
| The 2026 edge is a 2025–2026 regime artifact; no guard candidate recovers it out-of-sample | PROVEN | EVIDENCE#043–048 → exp 52–56 |
| A regime gate (dispersion/vol/HMM detector) selectively trades in profitable years | REFUTED (dispersion 0% trip everywhere; vol gates close on profitable days; HMM 37% trip in 2026 vs 31% in bad years — too weak to protect) | EVIDENCE#050 → ad-hoc simulation `book/scripts/regime_gate_bt.py` |
| Live capital should be sized for the mean (≈ −13% annual excess), not the 2026 tail | PROVEN (walk-forward) + HYPOTHESIS (forward-looking) | EVIDENCE#043–047 → exp 52–56 |
## Data & reproducibility
+1
View File
@@ -78,6 +78,7 @@ Experiments 8–18 record metrics under a legacy schema (`ls_sharpe`, `maxdd_wit
|----|-------|--------|-----------|
| EVIDENCE#048 | Streaming IC circuit-breaker (`ic_min_rankic`, `ICGateTopkDropoutStrategy` in `tac_qlib/contrib/strategy/ic_gate.py`) trip-rate study: with thresholds 0.02–0.06, the gate trips on 25–50% of days in every year (2021–2026), freezing TopkDropout's rotation out of losers. A gate that trips every year cannot separate good years from bad. Do not deploy live. | ad-hoc scripted study on exp 52/53 pred/label artifacts, `tac_qlib/tac_qlib/contrib/strategy/ic_gate.py`, `tac_qlib/tac_qlib/risk_limits.py` | yes — guard candidate 3 REFUTED |
| EVIDENCE#049 | Perturbation stress test on Config A 2026 (exp 52, pred from run `9f98ea5c`): same signal, varying topk (5/10/15), n_drop (1/2/3), costs (base/high/5×base). **topk**: 10 optimal (32.8% raw, Sharpe 1.98); 5 loses ~0.5pp, 15 loses ~6.5pp. **n_drop**: 1 optimal; 2 loses ~6pp, 3 loses ~4pp. **costs**: immaterial — 5× cost increase (25bp/35bp/$15) drops return only 0.17pp (32.84%→32.67%). maxDD stable −5.8% to −7.0% across all perturbations. **Within the 2026 window the edge is robust to parameter perturbation.** The problem remains that it does not exist in other windows (ch 11). | ad-hoc rd_backtest grid on exp 52 pred.pkl, `book/data/perturbation/config_a_2026_sensitivity.json` | yes — within-window robustness confirmed |
| EVIDENCE#050 | Regime gate walk-forward test across 5 years (2021–2026): three detector types (dispersion, vol, HMM) × 14 configs. **Dispersion gates**: 0% trip rate everywhere — CS std of 22d returns never crosses any threshold. **Vol gates** (best: `vol_low_max20`): opens 92% in 2026 vs 60% in bad years (+32pp differential), but 2026 gated return collapses from +25.5% to +4.3% — the gate closes on profitable days. **HMM gates** (best: `hmm_0.7`): opens 37% in 2026 vs 31% in bad years (+6pp differential), 2026 return drops from +25.5% to +10.8%. No detector type achieves the goal of selective protection: tripping more in bad years while preserving good-year returns. The gate measures current market state, not whether yesterday's signals will predict today's returns. | scripted simulation: `book/scripts/regime_gate_bt.py`, results `book/data/regime_gate/regime_gate_trip_rates.csv`, pred.pkl from exp 52 (2024–2026) and exp 56 (2021, 2023) | yes — guard candidate regime gate REFUTED |
## External references (book/references/)
+40 -1
View File
@@ -88,10 +88,48 @@ Key takeaways: topk=10 is the sweet spot (topk=15 dilutes the signal by ~6.5pp).
4. Report account-based curves, not the blotter `return` field — the latter excludes initial cost and does not compound to the account.
5. When the mean annual excess is negative in every configuration, cut size until the live window demonstrates the regime is back.
### Guard 6: Regime gate (dispersion / vol / HMM)
`PROVEN — EVIDENCE#050`
If the edge is regime-dependent, the most direct guard is a regime detector that opens on good years and closes on bad years. We test three detector types, each producing a daily boolean (trade / don't trade):
| Detector | Logic |
|----------|-------|
| **dispersion** | CS std of 22-day rolling returns < threshold (low dispersion → calm market → trade) |
| **vol** | CS mean of 22-day rolling realized vol within a band (mid-range vol → trade) |
| **HMM** | 2-state Gaussian HMM posterior for regime 1 (productive regime) > threshold |
Each detector is applied as a daily gate on top of the weekly-rebalance TopkDropout (topk=10, n_drop=1, yesterday's scores). We run 14 configs across 5 walk-forward windows (2021–2026), tracking trip rate (fraction of days gate is open) and gated return.
**Trip rates (2026 vs bad years 2021/2023/2024):**
| Gate | 2026 trip | Bad-years avg | Differential |
|------|-----------|---------------|-------------|
| `vol_low_max20` | 92% | 60% | +32pp |
| `vol_low_max25` | 63% | 16% | +47pp |
| `hmm_0.7` | 37% | 31% | +6pp |
| All dispersion | 0% | 0% | 0pp |
The vol gates show the largest trip differential — they open on more days in 2026 than in bad years. But the gate **closes on the wrong days**: when the gate is open only 63% of the time (vol_low_max25), the 2026 return collapses from +25.5% to −1.6%. The gate eliminates the profitable days along with the bad ones.
**Gated returns:**
| Gate | 2026 base | 2026 gated | 2023 base | 2023 gated | 2025 base | 2025 gated |
|------|-----------|------------|-----------|------------|-----------|------------|
| `vol_low_max20` | +25.5% | +4.3% | −4.8% | −5.3% | +17.8% | +14.5% |
| `hmm_0.7` | +25.5% | +10.8% | −4.8% | +0.6% | +17.8% | +26.8% |
`hmm_0.7` has the most interesting profile: it **improves** 2023 (−4.8% → +0.6%) and 2025 (+17.8% → +26.8%), but **destroys** 2026 (+25.5% → +10.8%). The gate's Sharpe is inflated (1.78 in 2021) because it spends most of its time in cash — the Sharpe measures "active days only" and ignores the flat periods.
**Why none of these gates work:** The gate answers *"is the market calm right now?"* — but the right question is *"will today's signal be profitable tomorrow?"* These are different questions. A calm market can produce bad signals (low vol but wrong factor regime), and a volatile market can produce good signals (high vol but correct factor direction). The gate needs to predict **signal quality**, not **market state**.
`TODO(evidence-needed: a retrospective signal-quality gate — did yesterday's topk signals predict today's returns? — tested out-of-sample)`
## Open questions
- `TODO(evidence-needed: a live window that matches the 2026 label regime, to test whether the edge returns when the regime returns)`
- `TODO(evidence-needed: a regime-change detector that is causal (no lookahead) and demonstrably selects the 2026 window before the fact — none of the five guards did)`
- `TODO(evidence-needed: a retrospective signal-quality gate — did yesterday's topk signals predict today's returns? — tested out-of-sample)`
## Evidence cited in this chapter
@@ -104,3 +142,4 @@ Key takeaways: topk=10 is the sweet spot (topk=15 dilutes the signal by ~6.5pp).
| `EVIDENCE#047` | exp 56, staleness analysis on the exp 53/54 pred/label artifacts, branch `exp/56-window-staleness-isolation-the-m2-sharpe` |
| Guard 3 (`ic_min_rankic`) | `tac_qlib/tac_qlib/contrib/strategy/ic_gate.py` (ICGateTopkDropoutStrategy), `tac_qlib/tac_qlib/risk_limits.py`; trip-rate study on exp 52/53 preds |
| `EVIDENCE#049` | Perturbation stress test on Config A 2026 (exp 52, pred `9f98ea5c`): topk/n_drop/cost grid, `book/data/perturbation/config_a_2026_sensitivity.json` |
| `EVIDENCE#050` | Regime gate walk-forward test (2021–2026): 3 detector types × 14 configs; scripted simulation `book/scripts/regime_gate_bt.py`, results `book/data/regime_gate/regime_gate_trip_rates.csv` |
@@ -0,0 +1,71 @@
window,gate,start,end,trade_dates,gate_open,gate_closed,trip_rate,base_ann,base_sharpe,base_maxDD,gated_ann,gated_sharpe,gated_maxDD
2026,disp_0.010,2026-01-04,2026-08-19,157,157,0,0.0,0.255023,1.4465,-0.080671,0.255023,1.4465,-0.080671
2026,disp_0.015,2026-01-04,2026-08-19,157,157,0,0.0,0.255023,1.4465,-0.080671,0.255023,1.4465,-0.080671
2026,disp_0.020,2026-01-04,2026-08-19,157,157,0,0.0,0.255023,1.4465,-0.080671,0.255023,1.4465,-0.080671
2026,disp_0.025,2026-01-04,2026-08-19,157,157,0,0.0,0.255023,1.4465,-0.080671,0.255023,1.4465,-0.080671
2026,disp_0.030,2026-01-04,2026-08-19,157,157,0,0.0,0.255023,1.4465,-0.080671,0.255023,1.4465,-0.080671
2026,vol_low_max15,2026-01-04,2026-08-19,157,0,157,1.0,0.255023,1.4465,-0.080671,0.0,0.0,0.0
2026,vol_low_max20,2026-01-04,2026-08-19,157,12,145,0.9236,0.255023,1.4465,-0.080671,0.043049,1.1515,-0.023108
2026,vol_low_max25,2026-01-04,2026-08-19,157,58,99,0.6306,0.255023,1.4465,-0.080671,-0.016384,-0.1645,-0.108398
2026,vol_10_25,2026-01-04,2026-08-19,157,58,99,0.6306,0.255023,1.4465,-0.080671,-0.016384,-0.1645,-0.108398
2026,vol_10_30,2026-01-04,2026-08-19,157,157,0,0.0,0.255023,1.4465,-0.080671,0.255023,1.4465,-0.080671
2026,hmm_0.3,2026-01-04,2026-08-19,157,157,0,0.0,0.255023,1.4465,-0.080671,0.255023,1.4465,-0.080671
2026,hmm_0.5,2026-01-04,2026-08-19,157,153,4,0.0255,0.255023,1.4465,-0.080671,0.149795,0.9136,-0.080671
2026,hmm_0.7,2026-01-04,2026-08-19,157,99,58,0.3694,0.255023,1.4465,-0.080671,0.10771,1.0292,-0.057741
2026,hmm_0.9,2026-01-04,2026-08-19,157,0,157,1.0,0.255023,1.4465,-0.080671,0.0,0.0,0.0
2025,disp_0.010,2025-01-02,2025-12-31,250,250,0,0.0,0.177515,0.8573,-0.217417,0.177515,0.8573,-0.217417
2025,disp_0.015,2025-01-02,2025-12-31,250,250,0,0.0,0.177515,0.8573,-0.217417,0.177515,0.8573,-0.217417
2025,disp_0.020,2025-01-02,2025-12-31,250,250,0,0.0,0.177515,0.8573,-0.217417,0.177515,0.8573,-0.217417
2025,disp_0.025,2025-01-02,2025-12-31,250,250,0,0.0,0.177515,0.8573,-0.217417,0.177515,0.8573,-0.217417
2025,disp_0.030,2025-01-02,2025-12-31,250,250,0,0.0,0.177515,0.8573,-0.217417,0.177515,0.8573,-0.217417
2025,vol_low_max15,2025-01-02,2025-12-31,250,0,250,1.0,0.177515,0.8573,-0.217417,0.0,0.0,0.0
2025,vol_low_max20,2025-01-02,2025-12-31,250,90,160,0.64,0.177515,0.8573,-0.217417,0.144812,2.0398,-0.0378
2025,vol_low_max25,2025-01-02,2025-12-31,250,178,72,0.288,0.177515,0.8573,-0.217417,0.127917,1.0934,-0.11916
2025,vol_10_25,2025-01-02,2025-12-31,250,178,72,0.288,0.177515,0.8573,-0.217417,0.127917,1.0934,-0.11916
2025,vol_10_30,2025-01-02,2025-12-31,250,212,38,0.152,0.177515,0.8573,-0.217417,0.173997,1.2001,-0.130753
2025,hmm_0.3,2025-01-02,2025-12-31,250,244,6,0.024,0.177515,0.8573,-0.217417,0.221577,1.364,-0.14578
2025,hmm_0.5,2025-01-02,2025-12-31,250,233,17,0.068,0.177515,0.8573,-0.217417,0.280047,1.9219,-0.070373
2025,hmm_0.7,2025-01-02,2025-12-31,250,185,65,0.26,0.177515,0.8573,-0.217417,0.268006,2.4144,-0.070373
2025,hmm_0.9,2025-01-02,2025-12-31,250,0,250,1.0,0.177515,0.8573,-0.217417,0.0,0.0,0.0
2024,disp_0.010,2024-01-02,2024-12-31,253,253,0,0.0,0.08229,0.5594,-0.10685,0.08229,0.5594,-0.10685
2024,disp_0.015,2024-01-02,2024-12-31,253,253,0,0.0,0.08229,0.5594,-0.10685,0.08229,0.5594,-0.10685
2024,disp_0.020,2024-01-02,2024-12-31,253,253,0,0.0,0.08229,0.5594,-0.10685,0.08229,0.5594,-0.10685
2024,disp_0.025,2024-01-02,2024-12-31,253,253,0,0.0,0.08229,0.5594,-0.10685,0.08229,0.5594,-0.10685
2024,disp_0.030,2024-01-02,2024-12-31,253,253,0,0.0,0.08229,0.5594,-0.10685,0.08229,0.5594,-0.10685
2024,vol_low_max15,2024-01-02,2024-12-31,253,0,253,1.0,0.08229,0.5594,-0.10685,0.0,0.0,0.0
2024,vol_low_max20,2024-01-02,2024-12-31,253,86,167,0.6601,0.08229,0.5594,-0.10685,0.000386,0.0056,-0.063954
2024,vol_low_max25,2024-01-02,2024-12-31,253,179,74,0.2925,0.08229,0.5594,-0.10685,0.081492,0.6993,-0.070375
2024,vol_10_25,2024-01-02,2024-12-31,253,179,74,0.2925,0.08229,0.5594,-0.10685,0.081492,0.6993,-0.070375
2024,vol_10_30,2024-01-02,2024-12-31,253,234,19,0.0751,0.08229,0.5594,-0.10685,0.089809,0.6617,-0.068417
2024,hmm_0.3,2024-01-02,2024-12-31,253,253,0,0.0,0.08229,0.5594,-0.10685,0.08229,0.5594,-0.10685
2024,hmm_0.5,2024-01-02,2024-12-31,253,249,4,0.0158,0.08229,0.5594,-0.10685,0.136453,0.959,-0.081611
2024,hmm_0.7,2024-01-02,2024-12-31,253,213,40,0.1581,0.08229,0.5594,-0.10685,0.172432,1.6081,-0.051163
2024,hmm_0.9,2024-01-02,2024-12-31,253,0,253,1.0,0.08229,0.5594,-0.10685,0.0,0.0,0.0
2023,disp_0.010,2023-01-03,2023-12-29,250,250,0,0.0,-0.047644,-0.2738,-0.197856,-0.047644,-0.2738,-0.197856
2023,disp_0.015,2023-01-03,2023-12-29,250,250,0,0.0,-0.047644,-0.2738,-0.197856,-0.047644,-0.2738,-0.197856
2023,disp_0.020,2023-01-03,2023-12-29,250,250,0,0.0,-0.047644,-0.2738,-0.197856,-0.047644,-0.2738,-0.197856
2023,disp_0.025,2023-01-03,2023-12-29,250,250,0,0.0,-0.047644,-0.2738,-0.197856,-0.047644,-0.2738,-0.197856
2023,disp_0.030,2023-01-03,2023-12-29,250,250,0,0.0,-0.047644,-0.2738,-0.197856,-0.047644,-0.2738,-0.197856
2023,vol_low_max15,2023-01-03,2023-12-29,250,0,250,1.0,-0.047644,-0.2738,-0.197856,0.0,0.0,0.0
2023,vol_low_max20,2023-01-03,2023-12-29,250,103,147,0.588,-0.047644,-0.2738,-0.197856,-0.052602,-0.5107,-0.148726
2023,vol_low_max25,2023-01-03,2023-12-29,250,245,5,0.02,-0.047644,-0.2738,-0.197856,-0.077097,-0.4555,-0.179606
2023,vol_10_25,2023-01-03,2023-12-29,250,245,5,0.02,-0.047644,-0.2738,-0.197856,-0.077097,-0.4555,-0.179606
2023,vol_10_30,2023-01-03,2023-12-29,250,250,0,0.0,-0.047644,-0.2738,-0.197856,-0.047644,-0.2738,-0.197856
2023,hmm_0.3,2023-01-03,2023-12-29,250,250,0,0.0,-0.047644,-0.2738,-0.197856,-0.047644,-0.2738,-0.197856
2023,hmm_0.5,2023-01-03,2023-12-29,250,242,8,0.032,-0.047644,-0.2738,-0.197856,-0.005112,-0.0312,-0.171319
2023,hmm_0.7,2023-01-03,2023-12-29,250,119,131,0.524,-0.047644,-0.2738,-0.197856,0.006276,0.0709,-0.084209
2023,hmm_0.9,2023-01-03,2023-12-29,250,0,250,1.0,-0.047644,-0.2738,-0.197856,0.0,0.0,0.0
2021,disp_0.010,2021-01-04,2021-12-31,252,252,0,0.0,0.18367,1.0979,-0.102651,0.18367,1.0979,-0.102651
2021,disp_0.015,2021-01-04,2021-12-31,252,252,0,0.0,0.18367,1.0979,-0.102651,0.18367,1.0979,-0.102651
2021,disp_0.020,2021-01-04,2021-12-31,252,252,0,0.0,0.18367,1.0979,-0.102651,0.18367,1.0979,-0.102651
2021,disp_0.025,2021-01-04,2021-12-31,252,252,0,0.0,0.18367,1.0979,-0.102651,0.18367,1.0979,-0.102651
2021,disp_0.030,2021-01-04,2021-12-31,252,252,0,0.0,0.18367,1.0979,-0.102651,0.18367,1.0979,-0.102651
2021,vol_low_max15,2021-01-04,2021-12-31,252,0,252,1.0,0.18367,1.0979,-0.102651,0.0,0.0,0.0
2021,vol_low_max20,2021-01-04,2021-12-31,252,111,141,0.5595,0.18367,1.0979,-0.102651,0.052323,0.6232,-0.065118
2021,vol_low_max25,2021-01-04,2021-12-31,252,212,40,0.1587,0.18367,1.0979,-0.102651,0.099622,0.7322,-0.077033
2021,vol_10_25,2021-01-04,2021-12-31,252,212,40,0.1587,0.18367,1.0979,-0.102651,0.099622,0.7322,-0.077033
2021,vol_10_30,2021-01-04,2021-12-31,252,252,0,0.0,0.18367,1.0979,-0.102651,0.18367,1.0979,-0.102651
2021,hmm_0.3,2021-01-04,2021-12-31,252,252,0,0.0,0.18367,1.0979,-0.102651,0.18367,1.0979,-0.102651
2021,hmm_0.5,2021-01-04,2021-12-31,252,243,9,0.0357,0.18367,1.0979,-0.102651,0.180238,1.1183,-0.116244
2021,hmm_0.7,2021-01-04,2021-12-31,252,190,62,0.246,0.18367,1.0979,-0.102651,0.184264,1.7758,-0.095388
2021,hmm_0.9,2021-01-04,2021-12-31,252,0,252,1.0,0.18367,1.0979,-0.102651,0.0,0.0,0.0
1 window gate start end trade_dates gate_open gate_closed trip_rate base_ann base_sharpe base_maxDD gated_ann gated_sharpe gated_maxDD
2 2026 disp_0.010 2026-01-04 2026-08-19 157 157 0 0.0 0.255023 1.4465 -0.080671 0.255023 1.4465 -0.080671
3 2026 disp_0.015 2026-01-04 2026-08-19 157 157 0 0.0 0.255023 1.4465 -0.080671 0.255023 1.4465 -0.080671
4 2026 disp_0.020 2026-01-04 2026-08-19 157 157 0 0.0 0.255023 1.4465 -0.080671 0.255023 1.4465 -0.080671
5 2026 disp_0.025 2026-01-04 2026-08-19 157 157 0 0.0 0.255023 1.4465 -0.080671 0.255023 1.4465 -0.080671
6 2026 disp_0.030 2026-01-04 2026-08-19 157 157 0 0.0 0.255023 1.4465 -0.080671 0.255023 1.4465 -0.080671
7 2026 vol_low_max15 2026-01-04 2026-08-19 157 0 157 1.0 0.255023 1.4465 -0.080671 0.0 0.0 0.0
8 2026 vol_low_max20 2026-01-04 2026-08-19 157 12 145 0.9236 0.255023 1.4465 -0.080671 0.043049 1.1515 -0.023108
9 2026 vol_low_max25 2026-01-04 2026-08-19 157 58 99 0.6306 0.255023 1.4465 -0.080671 -0.016384 -0.1645 -0.108398
10 2026 vol_10_25 2026-01-04 2026-08-19 157 58 99 0.6306 0.255023 1.4465 -0.080671 -0.016384 -0.1645 -0.108398
11 2026 vol_10_30 2026-01-04 2026-08-19 157 157 0 0.0 0.255023 1.4465 -0.080671 0.255023 1.4465 -0.080671
12 2026 hmm_0.3 2026-01-04 2026-08-19 157 157 0 0.0 0.255023 1.4465 -0.080671 0.255023 1.4465 -0.080671
13 2026 hmm_0.5 2026-01-04 2026-08-19 157 153 4 0.0255 0.255023 1.4465 -0.080671 0.149795 0.9136 -0.080671
14 2026 hmm_0.7 2026-01-04 2026-08-19 157 99 58 0.3694 0.255023 1.4465 -0.080671 0.10771 1.0292 -0.057741
15 2026 hmm_0.9 2026-01-04 2026-08-19 157 0 157 1.0 0.255023 1.4465 -0.080671 0.0 0.0 0.0
16 2025 disp_0.010 2025-01-02 2025-12-31 250 250 0 0.0 0.177515 0.8573 -0.217417 0.177515 0.8573 -0.217417
17 2025 disp_0.015 2025-01-02 2025-12-31 250 250 0 0.0 0.177515 0.8573 -0.217417 0.177515 0.8573 -0.217417
18 2025 disp_0.020 2025-01-02 2025-12-31 250 250 0 0.0 0.177515 0.8573 -0.217417 0.177515 0.8573 -0.217417
19 2025 disp_0.025 2025-01-02 2025-12-31 250 250 0 0.0 0.177515 0.8573 -0.217417 0.177515 0.8573 -0.217417
20 2025 disp_0.030 2025-01-02 2025-12-31 250 250 0 0.0 0.177515 0.8573 -0.217417 0.177515 0.8573 -0.217417
21 2025 vol_low_max15 2025-01-02 2025-12-31 250 0 250 1.0 0.177515 0.8573 -0.217417 0.0 0.0 0.0
22 2025 vol_low_max20 2025-01-02 2025-12-31 250 90 160 0.64 0.177515 0.8573 -0.217417 0.144812 2.0398 -0.0378
23 2025 vol_low_max25 2025-01-02 2025-12-31 250 178 72 0.288 0.177515 0.8573 -0.217417 0.127917 1.0934 -0.11916
24 2025 vol_10_25 2025-01-02 2025-12-31 250 178 72 0.288 0.177515 0.8573 -0.217417 0.127917 1.0934 -0.11916
25 2025 vol_10_30 2025-01-02 2025-12-31 250 212 38 0.152 0.177515 0.8573 -0.217417 0.173997 1.2001 -0.130753
26 2025 hmm_0.3 2025-01-02 2025-12-31 250 244 6 0.024 0.177515 0.8573 -0.217417 0.221577 1.364 -0.14578
27 2025 hmm_0.5 2025-01-02 2025-12-31 250 233 17 0.068 0.177515 0.8573 -0.217417 0.280047 1.9219 -0.070373
28 2025 hmm_0.7 2025-01-02 2025-12-31 250 185 65 0.26 0.177515 0.8573 -0.217417 0.268006 2.4144 -0.070373
29 2025 hmm_0.9 2025-01-02 2025-12-31 250 0 250 1.0 0.177515 0.8573 -0.217417 0.0 0.0 0.0
30 2024 disp_0.010 2024-01-02 2024-12-31 253 253 0 0.0 0.08229 0.5594 -0.10685 0.08229 0.5594 -0.10685
31 2024 disp_0.015 2024-01-02 2024-12-31 253 253 0 0.0 0.08229 0.5594 -0.10685 0.08229 0.5594 -0.10685
32 2024 disp_0.020 2024-01-02 2024-12-31 253 253 0 0.0 0.08229 0.5594 -0.10685 0.08229 0.5594 -0.10685
33 2024 disp_0.025 2024-01-02 2024-12-31 253 253 0 0.0 0.08229 0.5594 -0.10685 0.08229 0.5594 -0.10685
34 2024 disp_0.030 2024-01-02 2024-12-31 253 253 0 0.0 0.08229 0.5594 -0.10685 0.08229 0.5594 -0.10685
35 2024 vol_low_max15 2024-01-02 2024-12-31 253 0 253 1.0 0.08229 0.5594 -0.10685 0.0 0.0 0.0
36 2024 vol_low_max20 2024-01-02 2024-12-31 253 86 167 0.6601 0.08229 0.5594 -0.10685 0.000386 0.0056 -0.063954
37 2024 vol_low_max25 2024-01-02 2024-12-31 253 179 74 0.2925 0.08229 0.5594 -0.10685 0.081492 0.6993 -0.070375
38 2024 vol_10_25 2024-01-02 2024-12-31 253 179 74 0.2925 0.08229 0.5594 -0.10685 0.081492 0.6993 -0.070375
39 2024 vol_10_30 2024-01-02 2024-12-31 253 234 19 0.0751 0.08229 0.5594 -0.10685 0.089809 0.6617 -0.068417
40 2024 hmm_0.3 2024-01-02 2024-12-31 253 253 0 0.0 0.08229 0.5594 -0.10685 0.08229 0.5594 -0.10685
41 2024 hmm_0.5 2024-01-02 2024-12-31 253 249 4 0.0158 0.08229 0.5594 -0.10685 0.136453 0.959 -0.081611
42 2024 hmm_0.7 2024-01-02 2024-12-31 253 213 40 0.1581 0.08229 0.5594 -0.10685 0.172432 1.6081 -0.051163
43 2024 hmm_0.9 2024-01-02 2024-12-31 253 0 253 1.0 0.08229 0.5594 -0.10685 0.0 0.0 0.0
44 2023 disp_0.010 2023-01-03 2023-12-29 250 250 0 0.0 -0.047644 -0.2738 -0.197856 -0.047644 -0.2738 -0.197856
45 2023 disp_0.015 2023-01-03 2023-12-29 250 250 0 0.0 -0.047644 -0.2738 -0.197856 -0.047644 -0.2738 -0.197856
46 2023 disp_0.020 2023-01-03 2023-12-29 250 250 0 0.0 -0.047644 -0.2738 -0.197856 -0.047644 -0.2738 -0.197856
47 2023 disp_0.025 2023-01-03 2023-12-29 250 250 0 0.0 -0.047644 -0.2738 -0.197856 -0.047644 -0.2738 -0.197856
48 2023 disp_0.030 2023-01-03 2023-12-29 250 250 0 0.0 -0.047644 -0.2738 -0.197856 -0.047644 -0.2738 -0.197856
49 2023 vol_low_max15 2023-01-03 2023-12-29 250 0 250 1.0 -0.047644 -0.2738 -0.197856 0.0 0.0 0.0
50 2023 vol_low_max20 2023-01-03 2023-12-29 250 103 147 0.588 -0.047644 -0.2738 -0.197856 -0.052602 -0.5107 -0.148726
51 2023 vol_low_max25 2023-01-03 2023-12-29 250 245 5 0.02 -0.047644 -0.2738 -0.197856 -0.077097 -0.4555 -0.179606
52 2023 vol_10_25 2023-01-03 2023-12-29 250 245 5 0.02 -0.047644 -0.2738 -0.197856 -0.077097 -0.4555 -0.179606
53 2023 vol_10_30 2023-01-03 2023-12-29 250 250 0 0.0 -0.047644 -0.2738 -0.197856 -0.047644 -0.2738 -0.197856
54 2023 hmm_0.3 2023-01-03 2023-12-29 250 250 0 0.0 -0.047644 -0.2738 -0.197856 -0.047644 -0.2738 -0.197856
55 2023 hmm_0.5 2023-01-03 2023-12-29 250 242 8 0.032 -0.047644 -0.2738 -0.197856 -0.005112 -0.0312 -0.171319
56 2023 hmm_0.7 2023-01-03 2023-12-29 250 119 131 0.524 -0.047644 -0.2738 -0.197856 0.006276 0.0709 -0.084209
57 2023 hmm_0.9 2023-01-03 2023-12-29 250 0 250 1.0 -0.047644 -0.2738 -0.197856 0.0 0.0 0.0
58 2021 disp_0.010 2021-01-04 2021-12-31 252 252 0 0.0 0.18367 1.0979 -0.102651 0.18367 1.0979 -0.102651
59 2021 disp_0.015 2021-01-04 2021-12-31 252 252 0 0.0 0.18367 1.0979 -0.102651 0.18367 1.0979 -0.102651
60 2021 disp_0.020 2021-01-04 2021-12-31 252 252 0 0.0 0.18367 1.0979 -0.102651 0.18367 1.0979 -0.102651
61 2021 disp_0.025 2021-01-04 2021-12-31 252 252 0 0.0 0.18367 1.0979 -0.102651 0.18367 1.0979 -0.102651
62 2021 disp_0.030 2021-01-04 2021-12-31 252 252 0 0.0 0.18367 1.0979 -0.102651 0.18367 1.0979 -0.102651
63 2021 vol_low_max15 2021-01-04 2021-12-31 252 0 252 1.0 0.18367 1.0979 -0.102651 0.0 0.0 0.0
64 2021 vol_low_max20 2021-01-04 2021-12-31 252 111 141 0.5595 0.18367 1.0979 -0.102651 0.052323 0.6232 -0.065118
65 2021 vol_low_max25 2021-01-04 2021-12-31 252 212 40 0.1587 0.18367 1.0979 -0.102651 0.099622 0.7322 -0.077033
66 2021 vol_10_25 2021-01-04 2021-12-31 252 212 40 0.1587 0.18367 1.0979 -0.102651 0.099622 0.7322 -0.077033
67 2021 vol_10_30 2021-01-04 2021-12-31 252 252 0 0.0 0.18367 1.0979 -0.102651 0.18367 1.0979 -0.102651
68 2021 hmm_0.3 2021-01-04 2021-12-31 252 252 0 0.0 0.18367 1.0979 -0.102651 0.18367 1.0979 -0.102651
69 2021 hmm_0.5 2021-01-04 2021-12-31 252 243 9 0.0357 0.18367 1.0979 -0.102651 0.180238 1.1183 -0.116244
70 2021 hmm_0.7 2021-01-04 2021-12-31 252 190 62 0.246 0.18367 1.0979 -0.102651 0.184264 1.7758 -0.095388
71 2021 hmm_0.9 2021-01-04 2021-12-31 252 0 252 1.0 0.18367 1.0979 -0.102651 0.0 0.0 0.0
File diff suppressed because it is too large Load Diff
+400
View File
@@ -0,0 +1,400 @@
"""Regime-gate walk-forward backtest grid.
Precomputes regime gates (dispersion/vol/hmm × threshold grid) from lake bars,
then runs a qlib TopkDropout backtest with each gate applied as a date-level
trade overlay. Uses the SAME pred.pkl from exp 52 (Config A 2026) so the
model is trained only once.
Usage:
cd /app && .venv/bin/python book/scripts/regime_gate_bt.py
"""
from __future__ import annotations
import json
import pathlib
import sys
import time
import numpy as np
import pandas as pd
LAKE_ROOT = "/home/data/lake"
MARKET = "US"
OUT_DIR = pathlib.Path("/app/experiments/book/data/regime_gate")
# Walk-forward test windows with their pred.pkl sources
WINDOWS = [
{"label": "2026", "start": "2026-01-04", "end": "2026-08-19",
"pred": f"{LAKE_ROOT}/mlruns/52/9f98ea5c550a409f87b56a6cd8fee343/artifacts/pred.pkl"},
{"label": "2025", "start": "2025-01-02", "end": "2025-12-31",
"pred": f"{LAKE_ROOT}/mlruns/52/fe96741654df4780957a3a949999ae6a/artifacts/pred.pkl"},
{"label": "2024", "start": "2024-01-02", "end": "2024-12-31",
"pred": f"{LAKE_ROOT}/mlruns/52/71ed5bfa9984490f8bba8b222f7acc39/artifacts/pred.pkl"},
{"label": "2023", "start": "2023-01-03", "end": "2023-12-29",
"pred": f"{LAKE_ROOT}/mlruns/56/8ca46e554311444c9a42637a788226e8/artifacts/pred.pkl"},
{"label": "2021", "start": "2021-01-04", "end": "2021-12-31",
"pred": f"{LAKE_ROOT}/mlruns/56/4e0700ddab2a4e108b46efece7346ee3/artifacts/pred.pkl"},
]
# Gate grid
DISP_THRESHOLDS = [0.010, 0.015, 0.020, 0.025, 0.030]
VOL_BANDS = [
(0.0, 0.15, "low_max15"),
(0.0, 0.20, "low_max20"),
(0.0, 0.25, "low_max25"),
(0.10, 0.25, "10_25"),
(0.10, 0.30, "10_30"),
]
HMM_THRESHOLDS = [0.3, 0.5, 0.7, 0.9]
def load_pred(path: str) -> pd.Series:
"""Load pred.pkl (MultiIndex: datetime × instrument → score), dates normalized to midnight."""
df = pd.read_pickle(path)
if isinstance(df, pd.DataFrame):
if "score" in df.columns:
s = df["score"]
else:
s = df.iloc[:, 0]
else:
s = df
# Normalize datetime level to date-only (midnight, no tz)
idx = s.index
new_dt = pd.to_datetime(idx.get_level_values(0)).normalize()
s.index = pd.MultiIndex.from_arrays([new_dt, idx.get_level_values(1)], names=idx.names)
return s
def precompute_gates(close_df: pd.DataFrame) -> dict:
"""Precompute all regime gate series from close prices."""
gates = {}
# --- dispersion gates ---
ret22 = close_df.pct_change(22)
cs_disp = ret22.std(axis=1)
for thr in DISP_THRESHOLDS:
g = cs_disp >= thr
g.iloc[:22] = True
gates[f"disp_{thr:.3f}"] = g
# --- vol gates ---
import numpy as np
log_ret = np.log(close_df / close_df.shift(1))
rv22 = log_ret.rolling(22).std() * (252 ** 0.5)
cs_vol = rv22.mean(axis=1)
for vlow, vhigh, tag in VOL_BANDS:
g = (cs_vol >= vlow) & (cs_vol <= vhigh)
g.iloc[:22] = True
gates[f"vol_{tag}"] = g
# --- HMM gates ---
hmm_root = pathlib.Path(LAKE_ROOT) / "features" / "market=US" / "timeframe=1d"
for thr in HMM_THRESHOLDS:
all_post = {}
for sym in close_df.columns:
for family in ("sp", "ta"):
fp = hmm_root / f"family={family}" / f"symbol={sym}.parquet"
if not fp.exists():
continue
try:
feat = pd.read_parquet(fp)
except Exception:
continue
if "sp_hmm_p_regime1" not in feat.columns:
continue
tcol = feat["t"] if "t" in feat.columns else feat["date"]
ts = pd.to_datetime(tcol)
s = pd.Series(feat["sp_hmm_p_regime1"].values, index=ts, name=sym)
s = s.dropna()
if len(s) > 0:
all_post[sym] = s
break
if all_post:
post_df = pd.DataFrame(all_post)
cs_mean = post_df.mean(axis=1)
g = cs_mean >= thr
else:
g = pd.Series(True, index=close_df.index)
gates[f"hmm_{thr:.1f}"] = g
# Normalize all gate indices to date-only (no tz, no time)
for key in gates:
gates[key].index = pd.to_datetime(gates[key].index).normalize()
return gates
def run_backtest_with_gate(
pred: pd.Series,
gate: pd.Series,
close_df: pd.DataFrame,
start: str,
end: str,
topk: int = 10,
n_drop: int = 1,
) -> dict:
"""Simulate TopkDropout with gate overlay, computing daily returns.
- On gate-open days: hold topk stocks (equal-weight), rebalance weekly
- On gate-closed days: liquidate to cash
- Tracks both gated and ungated (baseline) equity curves
"""
# Ensure pred has MultiIndex (date, instrument)
if not isinstance(pred.index, pd.MultiIndex):
return {"error": "pred must have MultiIndex (date, instrument)"}
# Daily returns per symbol (close-to-close)
ret_df = close_df.pct_change()
# Normalize ret_df index to date-only for matching
ret_df.index = pd.to_datetime(ret_df.index).normalize()
# Filter pred to window and get trade dates
dt_idx = pred.index.get_level_values(0)
window_mask = dt_idx >= pd.Timestamp(start)
window_mask &= dt_idx <= pd.Timestamp(end)
window_pred = pred.loc[window_mask]
if len(window_pred) == 0:
return {"error": "no pred data in window"}
trade_dates = sorted(dt_idx[window_mask].unique())
# Compute gate status per trade date
gate_open = {}
for d in trade_dates:
known = gate[gate.index <= d]
gate_open[d] = bool(known.iloc[-1]) if len(known) else True
n_total = len(trade_dates)
n_open = sum(1 for v in gate_open.values() if v)
n_closed = n_total - n_open
# Simulate: track current holdings — both base and gated use weekly rebalance
# Use yesterday's scores to pick today's holdings (no look-ahead)
holdings_base = []
holdings_gated = []
equity_gated = 1_000_000.0
equity_base = 1_000_000.0
prev_week = None
prev_scores = None # yesterday's scores
daily_gated = []
daily_base = []
# Build a date → ret_df row map
ret_by_date = {rd: ret_df.loc[rd] for rd in ret_df.index}
for i, d in enumerate(trade_dates):
# Get today's cross-sectional prediction
try:
day_scores = window_pred.loc[d]
except KeyError:
daily_gated.append(equity_gated)
daily_base.append(equity_base)
prev_scores = None
continue
if isinstance(day_scores, pd.Series) and not isinstance(day_scores.index, pd.MultiIndex):
pass
elif isinstance(day_scores, pd.DataFrame):
day_scores = day_scores.iloc[:, 0]
else:
daily_gated.append(equity_gated)
daily_base.append(equity_base)
prev_scores = None
continue
day_scores = day_scores.dropna().sort_values(ascending=False)
if len(day_scores) == 0:
daily_gated.append(equity_gated)
daily_base.append(equity_base)
prev_scores = None
continue
ret_row = ret_by_date.get(d)
if ret_row is None:
daily_gated.append(equity_gated)
daily_base.append(equity_base)
prev_scores = day_scores
continue
cur_week = (d.isocalendar()[0], d.isocalendar()[1]) if hasattr(d, 'isocalendar') else None
gate_val = gate_open.get(d, True)
# --- ungated baseline: weekly rebalance using yesterday's scores ---
if cur_week != prev_week or not holdings_base:
if prev_scores is not None:
holdings_base = list(prev_scores.index[:topk])
if holdings_base:
base_rets = ret_row.reindex(holdings_base).dropna()
if len(base_rets) > 0:
equity_base *= (1 + base_rets.mean())
# --- gated: weekly rebalance only when gate open, using yesterday's scores ---
if gate_val:
if cur_week != prev_week or not holdings_gated:
if prev_scores is not None:
holdings_gated = list(prev_scores.index[:topk])
if holdings_gated:
hold_rets = ret_row.reindex(holdings_gated).dropna()
if len(hold_rets) > 0:
equity_gated *= (1 + hold_rets.mean())
else:
holdings_gated = []
prev_week = cur_week
prev_scores = day_scores
daily_gated.append(equity_gated)
daily_base.append(equity_base)
# Compute metrics
g_series = pd.Series(daily_gated, index=trade_dates)
b_series = pd.Series(daily_base, index=trade_dates)
def _metrics(eq: pd.Series) -> dict:
if len(eq) < 2:
return {"ann_return": 0, "sharpe": 0, "maxDD": 0}
rets = eq.pct_change().dropna()
ann_ret = float((eq.iloc[-1] / eq.iloc[0]) ** (252 / max(len(eq), 1)) - 1)
vol = float(rets.std() * (252 ** 0.5)) if len(rets) > 1 else 0
sharpe = ann_ret / vol if vol > 0 else 0
peak = eq.cummax()
dd = (eq - peak) / peak
maxDD = float(dd.min())
return {"ann_return": round(ann_ret, 6), "sharpe": round(sharpe, 4), "maxDD": round(maxDD, 6)}
base_m = _metrics(b_series)
gated_m = _metrics(g_series)
return {
"trade_dates": n_total,
"gate_open_days": n_open,
"gate_closed_days": n_closed,
"trip_rate": round(n_closed / n_total, 4) if n_total else 0,
"base": base_m,
"gated": gated_m,
}
def load_bars_for_window(start: str, end: str) -> pd.DataFrame:
"""Load daily close prices for all symbols in the universe."""
from tac_qlib.data.config import LakeConfig, resolve_lake_root
cfg = LakeConfig(resolve_lake_root(LAKE_ROOT), MARKET)
sp = cfg.lake_root / "symbols.parquet"
if sp.exists():
syms = pd.read_parquet(sp)
col = "symbol" if "symbol" in syms.columns else syms.columns[0]
symbols = sorted(syms[col].astype(str).str.upper().tolist())
else:
return pd.DataFrame()
closes = {}
for sym in symbols:
p = cfg.bar_path("1d", sym)
if not p.exists():
continue
try:
df = pd.read_parquet(p)
except Exception:
continue
if not len(df):
continue
tcol = df["t"] if "t" in df.columns else df["date"]
ts = pd.to_datetime(tcol)
df = df.assign(_t=ts).set_index("_t").sort_index()
# Load a bit extra for warmup
warmup_start = pd.Timestamp(start) - pd.Timedelta(days=60)
df = df.loc[warmup_start:end]
if len(df) >= 22:
closes[sym] = df["c"]
return pd.DataFrame(closes)
def main():
OUT_DIR.mkdir(parents=True, exist_ok=True)
# Load bars (with warmup) for the full panel
full_start = "2015-01-03"
full_end = "2026-08-19"
print("Loading lake bars for gate precomputation...")
close_df = load_bars_for_window(full_start, full_end)
print(f" {close_df.shape[1]} symbols, {close_df.shape[0]} days")
print("Precomputing regime gates...")
gates = precompute_gates(close_df)
print(f" {len(gates)} gate configs: {list(gates.keys())}")
results = []
for window in WINDOWS:
wl, ws, we = window["label"], window["start"], window["end"]
pred_path = window["pred"]
print(f"\n=== Window {wl} ({ws} to {we}) ===")
print(f" Loading pred.pkl from {pred_path}...")
pred = load_pred(pred_path)
print(f" pred shape: {pred.shape}")
for gate_name, gate_series in gates.items():
bt = run_backtest_with_gate(pred, gate_series, close_df, ws, we)
if "error" in bt:
print(f" {gate_name}: {bt['error']}")
continue
row = {
"window": wl,
"gate": gate_name,
"start": ws,
"end": we,
"trade_dates": bt["trade_dates"],
"gate_open": bt["gate_open_days"],
"gate_closed": bt["gate_closed_days"],
"trip_rate": bt["trip_rate"],
"base_ann": bt["base"]["ann_return"],
"base_sharpe": bt["base"]["sharpe"],
"base_maxDD": bt["base"]["maxDD"],
"gated_ann": bt["gated"]["ann_return"],
"gated_sharpe": bt["gated"]["sharpe"],
"gated_maxDD": bt["gated"]["maxDD"],
}
results.append(row)
print(f" {gate_name}: trip={bt['trip_rate']:.1%}, "
f"base={bt['base']['ann_return']:+.1%} (Sharpe {bt['base']['sharpe']:.2f}), "
f"gated={bt['gated']['ann_return']:+.1%} (Sharpe {bt['gated']['sharpe']:.2f})")
# Save results
df = pd.DataFrame(results)
out_path = OUT_DIR / "regime_gate_trip_rates.csv"
df.to_csv(out_path, index=False)
print(f"\nSaved trip rates to {out_path}")
# Also save as JSON for the book
json_results = df.to_dict(orient="records")
with open(OUT_DIR / "regime_gate_trip_rates.json", "w") as f:
json.dump(json_results, f, indent=2, default=str)
# Print summary: trip rate differential (2026 vs bad years)
print("\n=== Trip Rate Summary (2026 vs bad years) ===")
for gate_name in gates.keys():
gdf = df[df["gate"] == gate_name]
r2026 = gdf[gdf["window"] == "2026"]["trip_rate"].values
r_bad = gdf[gdf["window"].isin(["2021", "2023", "2024"])]["trip_rate"].values
if len(r2026) and len(r_bad):
d = r2026[0] - np.mean(r_bad)
print(f" {gate_name}: 2026 trip={r2026[0]:.1%}, bad-years avg={np.mean(r_bad):.1%}, diff={d:+.1%}")
print("\n=== Gated Return Summary (2026 vs bad years) ===")
for gate_name in gates.keys():
gdf = df[df["gate"] == gate_name]
r2026 = gdf[gdf["window"] == "2026"]
r_bad = gdf[gdf["window"].isin(["2021", "2023", "2024"])]
if len(r2026) and len(r_bad):
g26 = r2026["gated_ann"].values[0]
b26 = r2026["base_ann"].values[0]
g_bad = r_bad["gated_ann"].mean()
b_bad = r_bad["base_ann"].mean()
print(f" {gate_name}: 2026 gated={g26:+.1%} (base={b26:+.1%}), "
f"bad-years gated={g_bad:+.1%} (base={b_bad:+.1%})")
if __name__ == "__main__":
main()