From 718b048df92c1fd6791fa70c531d4e67528e48b0 Mon Sep 17 00:00:00 2001 From: zhaoli Date: Thu, 20 Aug 2026 22:14:31 +0000 Subject: [PATCH] Add regime gate walk-forward test (EVIDENCE#050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- book/CLAIMS.md | 1 + book/EVIDENCE.md | 1 + book/chapters/11-walk-forward-and-guards.md | 43 +- .../regime_gate/regime_gate_trip_rates.csv | 71 ++ .../regime_gate/regime_gate_trip_rates.json | 1122 +++++++++++++++++ book/scripts/regime_gate_bt.py | 400 ++++++ 6 files changed, 1636 insertions(+), 2 deletions(-) create mode 100644 book/data/regime_gate/regime_gate_trip_rates.csv create mode 100644 book/data/regime_gate/regime_gate_trip_rates.json create mode 100644 book/scripts/regime_gate_bt.py diff --git a/book/CLAIMS.md b/book/CLAIMS.md index 977a278..f0eab2a 100644 --- a/book/CLAIMS.md +++ b/book/CLAIMS.md @@ -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 diff --git a/book/EVIDENCE.md b/book/EVIDENCE.md index 8f09d7a..82252ec 100644 --- a/book/EVIDENCE.md +++ b/book/EVIDENCE.md @@ -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/) diff --git a/book/chapters/11-walk-forward-and-guards.md b/book/chapters/11-walk-forward-and-guards.md index a40b536..b35bbc8 100644 --- a/book/chapters/11-walk-forward-and-guards.md +++ b/book/chapters/11-walk-forward-and-guards.md @@ -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 @@ -103,4 +141,5 @@ Key takeaways: topk=10 is the sweet spot (topk=15 dilutes the signal by ~6.5pp). | `EVIDENCE#046` | exp 55, mlflow exp 57/58 `tac-rd-bt-m2-sharpe22-adaptive-{1y,2y}`, branch `exp/55-adaptive-short-window-retrain-test-the-4` | | `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` | \ No newline at end of file +| `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` | \ No newline at end of file diff --git a/book/data/regime_gate/regime_gate_trip_rates.csv b/book/data/regime_gate/regime_gate_trip_rates.csv new file mode 100644 index 0000000..9eaf781 --- /dev/null +++ b/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 diff --git a/book/data/regime_gate/regime_gate_trip_rates.json b/book/data/regime_gate/regime_gate_trip_rates.json new file mode 100644 index 0000000..644a92a --- /dev/null +++ b/book/data/regime_gate/regime_gate_trip_rates.json @@ -0,0 +1,1122 @@ +[ + { + "window": "2026", + "gate": "disp_0.010", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 157, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": 0.255023, + "gated_sharpe": 1.4465, + "gated_maxDD": -0.080671 + }, + { + "window": "2026", + "gate": "disp_0.015", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 157, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": 0.255023, + "gated_sharpe": 1.4465, + "gated_maxDD": -0.080671 + }, + { + "window": "2026", + "gate": "disp_0.020", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 157, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": 0.255023, + "gated_sharpe": 1.4465, + "gated_maxDD": -0.080671 + }, + { + "window": "2026", + "gate": "disp_0.025", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 157, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": 0.255023, + "gated_sharpe": 1.4465, + "gated_maxDD": -0.080671 + }, + { + "window": "2026", + "gate": "disp_0.030", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 157, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": 0.255023, + "gated_sharpe": 1.4465, + "gated_maxDD": -0.080671 + }, + { + "window": "2026", + "gate": "vol_low_max15", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 0, + "gate_closed": 157, + "trip_rate": 1.0, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": 0.0, + "gated_sharpe": 0.0, + "gated_maxDD": 0.0 + }, + { + "window": "2026", + "gate": "vol_low_max20", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 12, + "gate_closed": 145, + "trip_rate": 0.9236, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": 0.043049, + "gated_sharpe": 1.1515, + "gated_maxDD": -0.023108 + }, + { + "window": "2026", + "gate": "vol_low_max25", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 58, + "gate_closed": 99, + "trip_rate": 0.6306, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": -0.016384, + "gated_sharpe": -0.1645, + "gated_maxDD": -0.108398 + }, + { + "window": "2026", + "gate": "vol_10_25", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 58, + "gate_closed": 99, + "trip_rate": 0.6306, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": -0.016384, + "gated_sharpe": -0.1645, + "gated_maxDD": -0.108398 + }, + { + "window": "2026", + "gate": "vol_10_30", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 157, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": 0.255023, + "gated_sharpe": 1.4465, + "gated_maxDD": -0.080671 + }, + { + "window": "2026", + "gate": "hmm_0.3", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 157, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": 0.255023, + "gated_sharpe": 1.4465, + "gated_maxDD": -0.080671 + }, + { + "window": "2026", + "gate": "hmm_0.5", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 153, + "gate_closed": 4, + "trip_rate": 0.0255, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": 0.149795, + "gated_sharpe": 0.9136, + "gated_maxDD": -0.080671 + }, + { + "window": "2026", + "gate": "hmm_0.7", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 99, + "gate_closed": 58, + "trip_rate": 0.3694, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": 0.10771, + "gated_sharpe": 1.0292, + "gated_maxDD": -0.057741 + }, + { + "window": "2026", + "gate": "hmm_0.9", + "start": "2026-01-04", + "end": "2026-08-19", + "trade_dates": 157, + "gate_open": 0, + "gate_closed": 157, + "trip_rate": 1.0, + "base_ann": 0.255023, + "base_sharpe": 1.4465, + "base_maxDD": -0.080671, + "gated_ann": 0.0, + "gated_sharpe": 0.0, + "gated_maxDD": 0.0 + }, + { + "window": "2025", + "gate": "disp_0.010", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 250, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.177515, + "gated_sharpe": 0.8573, + "gated_maxDD": -0.217417 + }, + { + "window": "2025", + "gate": "disp_0.015", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 250, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.177515, + "gated_sharpe": 0.8573, + "gated_maxDD": -0.217417 + }, + { + "window": "2025", + "gate": "disp_0.020", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 250, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.177515, + "gated_sharpe": 0.8573, + "gated_maxDD": -0.217417 + }, + { + "window": "2025", + "gate": "disp_0.025", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 250, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.177515, + "gated_sharpe": 0.8573, + "gated_maxDD": -0.217417 + }, + { + "window": "2025", + "gate": "disp_0.030", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 250, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.177515, + "gated_sharpe": 0.8573, + "gated_maxDD": -0.217417 + }, + { + "window": "2025", + "gate": "vol_low_max15", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 0, + "gate_closed": 250, + "trip_rate": 1.0, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.0, + "gated_sharpe": 0.0, + "gated_maxDD": 0.0 + }, + { + "window": "2025", + "gate": "vol_low_max20", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 90, + "gate_closed": 160, + "trip_rate": 0.64, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.144812, + "gated_sharpe": 2.0398, + "gated_maxDD": -0.0378 + }, + { + "window": "2025", + "gate": "vol_low_max25", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 178, + "gate_closed": 72, + "trip_rate": 0.288, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.127917, + "gated_sharpe": 1.0934, + "gated_maxDD": -0.11916 + }, + { + "window": "2025", + "gate": "vol_10_25", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 178, + "gate_closed": 72, + "trip_rate": 0.288, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.127917, + "gated_sharpe": 1.0934, + "gated_maxDD": -0.11916 + }, + { + "window": "2025", + "gate": "vol_10_30", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 212, + "gate_closed": 38, + "trip_rate": 0.152, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.173997, + "gated_sharpe": 1.2001, + "gated_maxDD": -0.130753 + }, + { + "window": "2025", + "gate": "hmm_0.3", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 244, + "gate_closed": 6, + "trip_rate": 0.024, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.221577, + "gated_sharpe": 1.364, + "gated_maxDD": -0.14578 + }, + { + "window": "2025", + "gate": "hmm_0.5", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 233, + "gate_closed": 17, + "trip_rate": 0.068, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.280047, + "gated_sharpe": 1.9219, + "gated_maxDD": -0.070373 + }, + { + "window": "2025", + "gate": "hmm_0.7", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 185, + "gate_closed": 65, + "trip_rate": 0.26, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.268006, + "gated_sharpe": 2.4144, + "gated_maxDD": -0.070373 + }, + { + "window": "2025", + "gate": "hmm_0.9", + "start": "2025-01-02", + "end": "2025-12-31", + "trade_dates": 250, + "gate_open": 0, + "gate_closed": 250, + "trip_rate": 1.0, + "base_ann": 0.177515, + "base_sharpe": 0.8573, + "base_maxDD": -0.217417, + "gated_ann": 0.0, + "gated_sharpe": 0.0, + "gated_maxDD": 0.0 + }, + { + "window": "2024", + "gate": "disp_0.010", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 253, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.08229, + "gated_sharpe": 0.5594, + "gated_maxDD": -0.10685 + }, + { + "window": "2024", + "gate": "disp_0.015", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 253, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.08229, + "gated_sharpe": 0.5594, + "gated_maxDD": -0.10685 + }, + { + "window": "2024", + "gate": "disp_0.020", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 253, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.08229, + "gated_sharpe": 0.5594, + "gated_maxDD": -0.10685 + }, + { + "window": "2024", + "gate": "disp_0.025", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 253, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.08229, + "gated_sharpe": 0.5594, + "gated_maxDD": -0.10685 + }, + { + "window": "2024", + "gate": "disp_0.030", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 253, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.08229, + "gated_sharpe": 0.5594, + "gated_maxDD": -0.10685 + }, + { + "window": "2024", + "gate": "vol_low_max15", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 0, + "gate_closed": 253, + "trip_rate": 1.0, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.0, + "gated_sharpe": 0.0, + "gated_maxDD": 0.0 + }, + { + "window": "2024", + "gate": "vol_low_max20", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 86, + "gate_closed": 167, + "trip_rate": 0.6601, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.000386, + "gated_sharpe": 0.0056, + "gated_maxDD": -0.063954 + }, + { + "window": "2024", + "gate": "vol_low_max25", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 179, + "gate_closed": 74, + "trip_rate": 0.2925, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.081492, + "gated_sharpe": 0.6993, + "gated_maxDD": -0.070375 + }, + { + "window": "2024", + "gate": "vol_10_25", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 179, + "gate_closed": 74, + "trip_rate": 0.2925, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.081492, + "gated_sharpe": 0.6993, + "gated_maxDD": -0.070375 + }, + { + "window": "2024", + "gate": "vol_10_30", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 234, + "gate_closed": 19, + "trip_rate": 0.0751, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.089809, + "gated_sharpe": 0.6617, + "gated_maxDD": -0.068417 + }, + { + "window": "2024", + "gate": "hmm_0.3", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 253, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.08229, + "gated_sharpe": 0.5594, + "gated_maxDD": -0.10685 + }, + { + "window": "2024", + "gate": "hmm_0.5", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 249, + "gate_closed": 4, + "trip_rate": 0.0158, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.136453, + "gated_sharpe": 0.959, + "gated_maxDD": -0.081611 + }, + { + "window": "2024", + "gate": "hmm_0.7", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 213, + "gate_closed": 40, + "trip_rate": 0.1581, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.172432, + "gated_sharpe": 1.6081, + "gated_maxDD": -0.051163 + }, + { + "window": "2024", + "gate": "hmm_0.9", + "start": "2024-01-02", + "end": "2024-12-31", + "trade_dates": 253, + "gate_open": 0, + "gate_closed": 253, + "trip_rate": 1.0, + "base_ann": 0.08229, + "base_sharpe": 0.5594, + "base_maxDD": -0.10685, + "gated_ann": 0.0, + "gated_sharpe": 0.0, + "gated_maxDD": 0.0 + }, + { + "window": "2023", + "gate": "disp_0.010", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 250, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": -0.047644, + "gated_sharpe": -0.2738, + "gated_maxDD": -0.197856 + }, + { + "window": "2023", + "gate": "disp_0.015", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 250, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": -0.047644, + "gated_sharpe": -0.2738, + "gated_maxDD": -0.197856 + }, + { + "window": "2023", + "gate": "disp_0.020", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 250, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": -0.047644, + "gated_sharpe": -0.2738, + "gated_maxDD": -0.197856 + }, + { + "window": "2023", + "gate": "disp_0.025", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 250, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": -0.047644, + "gated_sharpe": -0.2738, + "gated_maxDD": -0.197856 + }, + { + "window": "2023", + "gate": "disp_0.030", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 250, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": -0.047644, + "gated_sharpe": -0.2738, + "gated_maxDD": -0.197856 + }, + { + "window": "2023", + "gate": "vol_low_max15", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 0, + "gate_closed": 250, + "trip_rate": 1.0, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": 0.0, + "gated_sharpe": 0.0, + "gated_maxDD": 0.0 + }, + { + "window": "2023", + "gate": "vol_low_max20", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 103, + "gate_closed": 147, + "trip_rate": 0.588, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": -0.052602, + "gated_sharpe": -0.5107, + "gated_maxDD": -0.148726 + }, + { + "window": "2023", + "gate": "vol_low_max25", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 245, + "gate_closed": 5, + "trip_rate": 0.02, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": -0.077097, + "gated_sharpe": -0.4555, + "gated_maxDD": -0.179606 + }, + { + "window": "2023", + "gate": "vol_10_25", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 245, + "gate_closed": 5, + "trip_rate": 0.02, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": -0.077097, + "gated_sharpe": -0.4555, + "gated_maxDD": -0.179606 + }, + { + "window": "2023", + "gate": "vol_10_30", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 250, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": -0.047644, + "gated_sharpe": -0.2738, + "gated_maxDD": -0.197856 + }, + { + "window": "2023", + "gate": "hmm_0.3", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 250, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": -0.047644, + "gated_sharpe": -0.2738, + "gated_maxDD": -0.197856 + }, + { + "window": "2023", + "gate": "hmm_0.5", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 242, + "gate_closed": 8, + "trip_rate": 0.032, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": -0.005112, + "gated_sharpe": -0.0312, + "gated_maxDD": -0.171319 + }, + { + "window": "2023", + "gate": "hmm_0.7", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 119, + "gate_closed": 131, + "trip_rate": 0.524, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": 0.006276, + "gated_sharpe": 0.0709, + "gated_maxDD": -0.084209 + }, + { + "window": "2023", + "gate": "hmm_0.9", + "start": "2023-01-03", + "end": "2023-12-29", + "trade_dates": 250, + "gate_open": 0, + "gate_closed": 250, + "trip_rate": 1.0, + "base_ann": -0.047644, + "base_sharpe": -0.2738, + "base_maxDD": -0.197856, + "gated_ann": 0.0, + "gated_sharpe": 0.0, + "gated_maxDD": 0.0 + }, + { + "window": "2021", + "gate": "disp_0.010", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 252, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.18367, + "gated_sharpe": 1.0979, + "gated_maxDD": -0.102651 + }, + { + "window": "2021", + "gate": "disp_0.015", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 252, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.18367, + "gated_sharpe": 1.0979, + "gated_maxDD": -0.102651 + }, + { + "window": "2021", + "gate": "disp_0.020", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 252, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.18367, + "gated_sharpe": 1.0979, + "gated_maxDD": -0.102651 + }, + { + "window": "2021", + "gate": "disp_0.025", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 252, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.18367, + "gated_sharpe": 1.0979, + "gated_maxDD": -0.102651 + }, + { + "window": "2021", + "gate": "disp_0.030", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 252, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.18367, + "gated_sharpe": 1.0979, + "gated_maxDD": -0.102651 + }, + { + "window": "2021", + "gate": "vol_low_max15", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 0, + "gate_closed": 252, + "trip_rate": 1.0, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.0, + "gated_sharpe": 0.0, + "gated_maxDD": 0.0 + }, + { + "window": "2021", + "gate": "vol_low_max20", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 111, + "gate_closed": 141, + "trip_rate": 0.5595, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.052323, + "gated_sharpe": 0.6232, + "gated_maxDD": -0.065118 + }, + { + "window": "2021", + "gate": "vol_low_max25", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 212, + "gate_closed": 40, + "trip_rate": 0.1587, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.099622, + "gated_sharpe": 0.7322, + "gated_maxDD": -0.077033 + }, + { + "window": "2021", + "gate": "vol_10_25", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 212, + "gate_closed": 40, + "trip_rate": 0.1587, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.099622, + "gated_sharpe": 0.7322, + "gated_maxDD": -0.077033 + }, + { + "window": "2021", + "gate": "vol_10_30", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 252, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.18367, + "gated_sharpe": 1.0979, + "gated_maxDD": -0.102651 + }, + { + "window": "2021", + "gate": "hmm_0.3", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 252, + "gate_closed": 0, + "trip_rate": 0.0, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.18367, + "gated_sharpe": 1.0979, + "gated_maxDD": -0.102651 + }, + { + "window": "2021", + "gate": "hmm_0.5", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 243, + "gate_closed": 9, + "trip_rate": 0.0357, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.180238, + "gated_sharpe": 1.1183, + "gated_maxDD": -0.116244 + }, + { + "window": "2021", + "gate": "hmm_0.7", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 190, + "gate_closed": 62, + "trip_rate": 0.246, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.184264, + "gated_sharpe": 1.7758, + "gated_maxDD": -0.095388 + }, + { + "window": "2021", + "gate": "hmm_0.9", + "start": "2021-01-04", + "end": "2021-12-31", + "trade_dates": 252, + "gate_open": 0, + "gate_closed": 252, + "trip_rate": 1.0, + "base_ann": 0.18367, + "base_sharpe": 1.0979, + "base_maxDD": -0.102651, + "gated_ann": 0.0, + "gated_sharpe": 0.0, + "gated_maxDD": 0.0 + } +] \ No newline at end of file diff --git a/book/scripts/regime_gate_bt.py b/book/scripts/regime_gate_bt.py new file mode 100644 index 0000000..cbd1048 --- /dev/null +++ b/book/scripts/regime_gate_bt.py @@ -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()