Compare commits

..
21 changed files with 585 additions and 1374 deletions
@@ -1,46 +0,0 @@
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,hitrate_5d_0.50,2026-01-04,2026-08-19,157,92,65,0.414,0.255023,1.4465,-0.080671,0.649911,5.7351,-0.031795
2026,hitrate_5d_0.60,2026-01-04,2026-08-19,157,49,108,0.6879,0.255023,1.4465,-0.080671,0.489313,6.0111,-0.014864
2026,hitrate_5d_0.70,2026-01-04,2026-08-19,157,15,142,0.9045,0.255023,1.4465,-0.080671,0.229523,4.3611,-0.002029
2026,hitrate_10d_0.50,2026-01-04,2026-08-19,157,109,48,0.3057,0.255023,1.4465,-0.080671,0.335822,2.6391,-0.060142
2026,hitrate_10d_0.60,2026-01-04,2026-08-19,157,36,121,0.7707,0.255023,1.4465,-0.080671,0.251108,3.8579,-0.026842
2026,hitrate_10d_0.70,2026-01-04,2026-08-19,157,9,148,0.9427,0.255023,1.4465,-0.080671,0.039314,1.3415,-0.012061
2026,hitrate_20d_0.40,2026-01-04,2026-08-19,157,157,0,0.0,0.255023,1.4465,-0.080671,0.255023,1.4465,-0.080671
2026,hitrate_20d_0.50,2026-01-04,2026-08-19,157,118,39,0.2484,0.255023,1.4465,-0.080671,0.343819,2.5185,-0.06259
2026,hitrate_20d_0.60,2026-01-04,2026-08-19,157,28,129,0.8217,0.255023,1.4465,-0.080671,0.020214,0.3601,-0.028489
2025,hitrate_5d_0.50,2025-01-02,2025-12-31,250,153,97,0.388,0.177515,0.8573,-0.217417,0.72055,6.9599,-0.048495
2025,hitrate_5d_0.60,2025-01-02,2025-12-31,250,90,160,0.64,0.177515,0.8573,-0.217417,0.48564,5.3623,-0.046673
2025,hitrate_5d_0.70,2025-01-02,2025-12-31,250,39,211,0.844,0.177515,0.8573,-0.217417,0.249221,4.9448,-0.009619
2025,hitrate_10d_0.50,2025-01-02,2025-12-31,250,170,80,0.32,0.177515,0.8573,-0.217417,0.435847,3.5614,-0.055776
2025,hitrate_10d_0.60,2025-01-02,2025-12-31,250,66,184,0.736,0.177515,0.8573,-0.217417,0.324574,5.2279,-0.016028
2025,hitrate_10d_0.70,2025-01-02,2025-12-31,250,14,236,0.944,0.177515,0.8573,-0.217417,0.043002,1.4635,-0.010154
2025,hitrate_20d_0.40,2025-01-02,2025-12-31,250,247,3,0.012,0.177515,0.8573,-0.217417,0.202949,0.9896,-0.217417
2025,hitrate_20d_0.50,2025-01-02,2025-12-31,250,177,73,0.292,0.177515,0.8573,-0.217417,0.340772,2.8303,-0.071731
2025,hitrate_20d_0.60,2025-01-02,2025-12-31,250,48,202,0.808,0.177515,0.8573,-0.217417,0.266028,4.1749,-0.021527
2024,hitrate_5d_0.50,2024-01-02,2024-12-31,253,139,114,0.4506,0.08229,0.5594,-0.10685,0.30351,2.4275,-0.088219
2024,hitrate_5d_0.60,2024-01-02,2024-12-31,253,71,182,0.7194,0.08229,0.5594,-0.10685,0.265365,2.4908,-0.078304
2024,hitrate_5d_0.70,2024-01-02,2024-12-31,253,19,234,0.9249,0.08229,0.5594,-0.10685,0.143125,3.377,-0.003364
2024,hitrate_10d_0.50,2024-01-02,2024-12-31,253,157,96,0.3794,0.08229,0.5594,-0.10685,0.277421,2.5767,-0.043146
2024,hitrate_10d_0.60,2024-01-02,2024-12-31,253,37,216,0.8538,0.08229,0.5594,-0.10685,0.207161,3.6083,-0.011122
2024,hitrate_10d_0.70,2024-01-02,2024-12-31,253,7,246,0.9723,0.08229,0.5594,-0.10685,0.031785,1.7408,-0.002083
2024,hitrate_20d_0.40,2024-01-02,2024-12-31,253,247,6,0.0237,0.08229,0.5594,-0.10685,0.078007,0.5342,-0.10685
2024,hitrate_20d_0.50,2024-01-02,2024-12-31,253,177,76,0.3004,0.08229,0.5594,-0.10685,0.210672,1.8469,-0.056207
2024,hitrate_20d_0.60,2024-01-02,2024-12-31,253,13,240,0.9486,0.08229,0.5594,-0.10685,0.005953,0.3039,-0.014443
2023,hitrate_5d_0.50,2023-01-03,2023-12-29,250,139,111,0.444,-0.047644,-0.2738,-0.197856,0.546654,4.2124,-0.035676
2023,hitrate_5d_0.60,2023-01-03,2023-12-29,250,79,171,0.684,-0.047644,-0.2738,-0.197856,0.556291,5.1026,-0.025449
2023,hitrate_5d_0.70,2023-01-03,2023-12-29,250,34,216,0.864,-0.047644,-0.2738,-0.197856,0.404269,4.4477,-0.013842
2023,hitrate_10d_0.50,2023-01-03,2023-12-29,250,148,102,0.408,-0.047644,-0.2738,-0.197856,0.459869,3.5211,-0.046921
2023,hitrate_10d_0.60,2023-01-03,2023-12-29,250,62,188,0.752,-0.047644,-0.2738,-0.197856,0.368232,4.0148,-0.022983
2023,hitrate_10d_0.70,2023-01-03,2023-12-29,250,13,237,0.948,-0.047644,-0.2738,-0.197856,0.091481,2.1324,-0.010866
2023,hitrate_20d_0.40,2023-01-03,2023-12-29,250,237,13,0.052,-0.047644,-0.2738,-0.197856,0.06639,0.3895,-0.146704
2023,hitrate_20d_0.50,2023-01-03,2023-12-29,250,149,101,0.404,-0.047644,-0.2738,-0.197856,0.366016,2.5969,-0.063998
2023,hitrate_20d_0.60,2023-01-03,2023-12-29,250,40,210,0.84,-0.047644,-0.2738,-0.197856,0.149844,2.2645,-0.032267
2021,hitrate_5d_0.50,2021-01-04,2021-12-31,252,145,107,0.4246,0.18367,1.0979,-0.102651,0.556615,5.9167,-0.028062
2021,hitrate_5d_0.60,2021-01-04,2021-12-31,252,68,184,0.7302,0.18367,1.0979,-0.102651,0.351867,5.8468,-0.011638
2021,hitrate_5d_0.70,2021-01-04,2021-12-31,252,26,226,0.8968,0.18367,1.0979,-0.102651,0.148417,3.9593,-0.0041
2021,hitrate_10d_0.50,2021-01-04,2021-12-31,252,163,89,0.3532,0.18367,1.0979,-0.102651,0.465023,4.2342,-0.040569
2021,hitrate_10d_0.60,2021-01-04,2021-12-31,252,51,201,0.7976,0.18367,1.0979,-0.102651,0.21802,4.7978,-0.011134
2021,hitrate_10d_0.70,2021-01-04,2021-12-31,252,13,239,0.9484,0.18367,1.0979,-0.102651,0.06108,2.7055,-0.000262
2021,hitrate_20d_0.40,2021-01-04,2021-12-31,252,252,0,0.0,0.18367,1.0979,-0.102651,0.18367,1.0979,-0.102651
2021,hitrate_20d_0.50,2021-01-04,2021-12-31,252,166,86,0.3413,0.18367,1.0979,-0.102651,0.289756,2.5787,-0.049244
2021,hitrate_20d_0.60,2021-01-04,2021-12-31,252,38,214,0.8492,0.18367,1.0979,-0.102651,0.104685,2.394,-0.015084
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 hitrate_5d_0.50 2026-01-04 2026-08-19 157 92 65 0.414 0.255023 1.4465 -0.080671 0.649911 5.7351 -0.031795
3 2026 hitrate_5d_0.60 2026-01-04 2026-08-19 157 49 108 0.6879 0.255023 1.4465 -0.080671 0.489313 6.0111 -0.014864
4 2026 hitrate_5d_0.70 2026-01-04 2026-08-19 157 15 142 0.9045 0.255023 1.4465 -0.080671 0.229523 4.3611 -0.002029
5 2026 hitrate_10d_0.50 2026-01-04 2026-08-19 157 109 48 0.3057 0.255023 1.4465 -0.080671 0.335822 2.6391 -0.060142
6 2026 hitrate_10d_0.60 2026-01-04 2026-08-19 157 36 121 0.7707 0.255023 1.4465 -0.080671 0.251108 3.8579 -0.026842
7 2026 hitrate_10d_0.70 2026-01-04 2026-08-19 157 9 148 0.9427 0.255023 1.4465 -0.080671 0.039314 1.3415 -0.012061
8 2026 hitrate_20d_0.40 2026-01-04 2026-08-19 157 157 0 0.0 0.255023 1.4465 -0.080671 0.255023 1.4465 -0.080671
9 2026 hitrate_20d_0.50 2026-01-04 2026-08-19 157 118 39 0.2484 0.255023 1.4465 -0.080671 0.343819 2.5185 -0.06259
10 2026 hitrate_20d_0.60 2026-01-04 2026-08-19 157 28 129 0.8217 0.255023 1.4465 -0.080671 0.020214 0.3601 -0.028489
11 2025 hitrate_5d_0.50 2025-01-02 2025-12-31 250 153 97 0.388 0.177515 0.8573 -0.217417 0.72055 6.9599 -0.048495
12 2025 hitrate_5d_0.60 2025-01-02 2025-12-31 250 90 160 0.64 0.177515 0.8573 -0.217417 0.48564 5.3623 -0.046673
13 2025 hitrate_5d_0.70 2025-01-02 2025-12-31 250 39 211 0.844 0.177515 0.8573 -0.217417 0.249221 4.9448 -0.009619
14 2025 hitrate_10d_0.50 2025-01-02 2025-12-31 250 170 80 0.32 0.177515 0.8573 -0.217417 0.435847 3.5614 -0.055776
15 2025 hitrate_10d_0.60 2025-01-02 2025-12-31 250 66 184 0.736 0.177515 0.8573 -0.217417 0.324574 5.2279 -0.016028
16 2025 hitrate_10d_0.70 2025-01-02 2025-12-31 250 14 236 0.944 0.177515 0.8573 -0.217417 0.043002 1.4635 -0.010154
17 2025 hitrate_20d_0.40 2025-01-02 2025-12-31 250 247 3 0.012 0.177515 0.8573 -0.217417 0.202949 0.9896 -0.217417
18 2025 hitrate_20d_0.50 2025-01-02 2025-12-31 250 177 73 0.292 0.177515 0.8573 -0.217417 0.340772 2.8303 -0.071731
19 2025 hitrate_20d_0.60 2025-01-02 2025-12-31 250 48 202 0.808 0.177515 0.8573 -0.217417 0.266028 4.1749 -0.021527
20 2024 hitrate_5d_0.50 2024-01-02 2024-12-31 253 139 114 0.4506 0.08229 0.5594 -0.10685 0.30351 2.4275 -0.088219
21 2024 hitrate_5d_0.60 2024-01-02 2024-12-31 253 71 182 0.7194 0.08229 0.5594 -0.10685 0.265365 2.4908 -0.078304
22 2024 hitrate_5d_0.70 2024-01-02 2024-12-31 253 19 234 0.9249 0.08229 0.5594 -0.10685 0.143125 3.377 -0.003364
23 2024 hitrate_10d_0.50 2024-01-02 2024-12-31 253 157 96 0.3794 0.08229 0.5594 -0.10685 0.277421 2.5767 -0.043146
24 2024 hitrate_10d_0.60 2024-01-02 2024-12-31 253 37 216 0.8538 0.08229 0.5594 -0.10685 0.207161 3.6083 -0.011122
25 2024 hitrate_10d_0.70 2024-01-02 2024-12-31 253 7 246 0.9723 0.08229 0.5594 -0.10685 0.031785 1.7408 -0.002083
26 2024 hitrate_20d_0.40 2024-01-02 2024-12-31 253 247 6 0.0237 0.08229 0.5594 -0.10685 0.078007 0.5342 -0.10685
27 2024 hitrate_20d_0.50 2024-01-02 2024-12-31 253 177 76 0.3004 0.08229 0.5594 -0.10685 0.210672 1.8469 -0.056207
28 2024 hitrate_20d_0.60 2024-01-02 2024-12-31 253 13 240 0.9486 0.08229 0.5594 -0.10685 0.005953 0.3039 -0.014443
29 2023 hitrate_5d_0.50 2023-01-03 2023-12-29 250 139 111 0.444 -0.047644 -0.2738 -0.197856 0.546654 4.2124 -0.035676
30 2023 hitrate_5d_0.60 2023-01-03 2023-12-29 250 79 171 0.684 -0.047644 -0.2738 -0.197856 0.556291 5.1026 -0.025449
31 2023 hitrate_5d_0.70 2023-01-03 2023-12-29 250 34 216 0.864 -0.047644 -0.2738 -0.197856 0.404269 4.4477 -0.013842
32 2023 hitrate_10d_0.50 2023-01-03 2023-12-29 250 148 102 0.408 -0.047644 -0.2738 -0.197856 0.459869 3.5211 -0.046921
33 2023 hitrate_10d_0.60 2023-01-03 2023-12-29 250 62 188 0.752 -0.047644 -0.2738 -0.197856 0.368232 4.0148 -0.022983
34 2023 hitrate_10d_0.70 2023-01-03 2023-12-29 250 13 237 0.948 -0.047644 -0.2738 -0.197856 0.091481 2.1324 -0.010866
35 2023 hitrate_20d_0.40 2023-01-03 2023-12-29 250 237 13 0.052 -0.047644 -0.2738 -0.197856 0.06639 0.3895 -0.146704
36 2023 hitrate_20d_0.50 2023-01-03 2023-12-29 250 149 101 0.404 -0.047644 -0.2738 -0.197856 0.366016 2.5969 -0.063998
37 2023 hitrate_20d_0.60 2023-01-03 2023-12-29 250 40 210 0.84 -0.047644 -0.2738 -0.197856 0.149844 2.2645 -0.032267
38 2021 hitrate_5d_0.50 2021-01-04 2021-12-31 252 145 107 0.4246 0.18367 1.0979 -0.102651 0.556615 5.9167 -0.028062
39 2021 hitrate_5d_0.60 2021-01-04 2021-12-31 252 68 184 0.7302 0.18367 1.0979 -0.102651 0.351867 5.8468 -0.011638
40 2021 hitrate_5d_0.70 2021-01-04 2021-12-31 252 26 226 0.8968 0.18367 1.0979 -0.102651 0.148417 3.9593 -0.0041
41 2021 hitrate_10d_0.50 2021-01-04 2021-12-31 252 163 89 0.3532 0.18367 1.0979 -0.102651 0.465023 4.2342 -0.040569
42 2021 hitrate_10d_0.60 2021-01-04 2021-12-31 252 51 201 0.7976 0.18367 1.0979 -0.102651 0.21802 4.7978 -0.011134
43 2021 hitrate_10d_0.70 2021-01-04 2021-12-31 252 13 239 0.9484 0.18367 1.0979 -0.102651 0.06108 2.7055 -0.000262
44 2021 hitrate_20d_0.40 2021-01-04 2021-12-31 252 252 0 0.0 0.18367 1.0979 -0.102651 0.18367 1.0979 -0.102651
45 2021 hitrate_20d_0.50 2021-01-04 2021-12-31 252 166 86 0.3413 0.18367 1.0979 -0.102651 0.289756 2.5787 -0.049244
46 2021 hitrate_20d_0.60 2021-01-04 2021-12-31 252 38 214 0.8492 0.18367 1.0979 -0.102651 0.104685 2.394 -0.015084
@@ -1,722 +0,0 @@
[
{
"window": "2026",
"gate": "hitrate_5d_0.50",
"start": "2026-01-04",
"end": "2026-08-19",
"trade_dates": 157,
"gate_open": 92,
"gate_closed": 65,
"trip_rate": 0.414,
"base_ann": 0.255023,
"base_sharpe": 1.4465,
"base_maxDD": -0.080671,
"gated_ann": 0.649911,
"gated_sharpe": 5.7351,
"gated_maxDD": -0.031795
},
{
"window": "2026",
"gate": "hitrate_5d_0.60",
"start": "2026-01-04",
"end": "2026-08-19",
"trade_dates": 157,
"gate_open": 49,
"gate_closed": 108,
"trip_rate": 0.6879,
"base_ann": 0.255023,
"base_sharpe": 1.4465,
"base_maxDD": -0.080671,
"gated_ann": 0.489313,
"gated_sharpe": 6.0111,
"gated_maxDD": -0.014864
},
{
"window": "2026",
"gate": "hitrate_5d_0.70",
"start": "2026-01-04",
"end": "2026-08-19",
"trade_dates": 157,
"gate_open": 15,
"gate_closed": 142,
"trip_rate": 0.9045,
"base_ann": 0.255023,
"base_sharpe": 1.4465,
"base_maxDD": -0.080671,
"gated_ann": 0.229523,
"gated_sharpe": 4.3611,
"gated_maxDD": -0.002029
},
{
"window": "2026",
"gate": "hitrate_10d_0.50",
"start": "2026-01-04",
"end": "2026-08-19",
"trade_dates": 157,
"gate_open": 109,
"gate_closed": 48,
"trip_rate": 0.3057,
"base_ann": 0.255023,
"base_sharpe": 1.4465,
"base_maxDD": -0.080671,
"gated_ann": 0.335822,
"gated_sharpe": 2.6391,
"gated_maxDD": -0.060142
},
{
"window": "2026",
"gate": "hitrate_10d_0.60",
"start": "2026-01-04",
"end": "2026-08-19",
"trade_dates": 157,
"gate_open": 36,
"gate_closed": 121,
"trip_rate": 0.7707,
"base_ann": 0.255023,
"base_sharpe": 1.4465,
"base_maxDD": -0.080671,
"gated_ann": 0.251108,
"gated_sharpe": 3.8579,
"gated_maxDD": -0.026842
},
{
"window": "2026",
"gate": "hitrate_10d_0.70",
"start": "2026-01-04",
"end": "2026-08-19",
"trade_dates": 157,
"gate_open": 9,
"gate_closed": 148,
"trip_rate": 0.9427,
"base_ann": 0.255023,
"base_sharpe": 1.4465,
"base_maxDD": -0.080671,
"gated_ann": 0.039314,
"gated_sharpe": 1.3415,
"gated_maxDD": -0.012061
},
{
"window": "2026",
"gate": "hitrate_20d_0.40",
"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": "hitrate_20d_0.50",
"start": "2026-01-04",
"end": "2026-08-19",
"trade_dates": 157,
"gate_open": 118,
"gate_closed": 39,
"trip_rate": 0.2484,
"base_ann": 0.255023,
"base_sharpe": 1.4465,
"base_maxDD": -0.080671,
"gated_ann": 0.343819,
"gated_sharpe": 2.5185,
"gated_maxDD": -0.06259
},
{
"window": "2026",
"gate": "hitrate_20d_0.60",
"start": "2026-01-04",
"end": "2026-08-19",
"trade_dates": 157,
"gate_open": 28,
"gate_closed": 129,
"trip_rate": 0.8217,
"base_ann": 0.255023,
"base_sharpe": 1.4465,
"base_maxDD": -0.080671,
"gated_ann": 0.020214,
"gated_sharpe": 0.3601,
"gated_maxDD": -0.028489
},
{
"window": "2025",
"gate": "hitrate_5d_0.50",
"start": "2025-01-02",
"end": "2025-12-31",
"trade_dates": 250,
"gate_open": 153,
"gate_closed": 97,
"trip_rate": 0.388,
"base_ann": 0.177515,
"base_sharpe": 0.8573,
"base_maxDD": -0.217417,
"gated_ann": 0.72055,
"gated_sharpe": 6.9599,
"gated_maxDD": -0.048495
},
{
"window": "2025",
"gate": "hitrate_5d_0.60",
"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.48564,
"gated_sharpe": 5.3623,
"gated_maxDD": -0.046673
},
{
"window": "2025",
"gate": "hitrate_5d_0.70",
"start": "2025-01-02",
"end": "2025-12-31",
"trade_dates": 250,
"gate_open": 39,
"gate_closed": 211,
"trip_rate": 0.844,
"base_ann": 0.177515,
"base_sharpe": 0.8573,
"base_maxDD": -0.217417,
"gated_ann": 0.249221,
"gated_sharpe": 4.9448,
"gated_maxDD": -0.009619
},
{
"window": "2025",
"gate": "hitrate_10d_0.50",
"start": "2025-01-02",
"end": "2025-12-31",
"trade_dates": 250,
"gate_open": 170,
"gate_closed": 80,
"trip_rate": 0.32,
"base_ann": 0.177515,
"base_sharpe": 0.8573,
"base_maxDD": -0.217417,
"gated_ann": 0.435847,
"gated_sharpe": 3.5614,
"gated_maxDD": -0.055776
},
{
"window": "2025",
"gate": "hitrate_10d_0.60",
"start": "2025-01-02",
"end": "2025-12-31",
"trade_dates": 250,
"gate_open": 66,
"gate_closed": 184,
"trip_rate": 0.736,
"base_ann": 0.177515,
"base_sharpe": 0.8573,
"base_maxDD": -0.217417,
"gated_ann": 0.324574,
"gated_sharpe": 5.2279,
"gated_maxDD": -0.016028
},
{
"window": "2025",
"gate": "hitrate_10d_0.70",
"start": "2025-01-02",
"end": "2025-12-31",
"trade_dates": 250,
"gate_open": 14,
"gate_closed": 236,
"trip_rate": 0.944,
"base_ann": 0.177515,
"base_sharpe": 0.8573,
"base_maxDD": -0.217417,
"gated_ann": 0.043002,
"gated_sharpe": 1.4635,
"gated_maxDD": -0.010154
},
{
"window": "2025",
"gate": "hitrate_20d_0.40",
"start": "2025-01-02",
"end": "2025-12-31",
"trade_dates": 250,
"gate_open": 247,
"gate_closed": 3,
"trip_rate": 0.012,
"base_ann": 0.177515,
"base_sharpe": 0.8573,
"base_maxDD": -0.217417,
"gated_ann": 0.202949,
"gated_sharpe": 0.9896,
"gated_maxDD": -0.217417
},
{
"window": "2025",
"gate": "hitrate_20d_0.50",
"start": "2025-01-02",
"end": "2025-12-31",
"trade_dates": 250,
"gate_open": 177,
"gate_closed": 73,
"trip_rate": 0.292,
"base_ann": 0.177515,
"base_sharpe": 0.8573,
"base_maxDD": -0.217417,
"gated_ann": 0.340772,
"gated_sharpe": 2.8303,
"gated_maxDD": -0.071731
},
{
"window": "2025",
"gate": "hitrate_20d_0.60",
"start": "2025-01-02",
"end": "2025-12-31",
"trade_dates": 250,
"gate_open": 48,
"gate_closed": 202,
"trip_rate": 0.808,
"base_ann": 0.177515,
"base_sharpe": 0.8573,
"base_maxDD": -0.217417,
"gated_ann": 0.266028,
"gated_sharpe": 4.1749,
"gated_maxDD": -0.021527
},
{
"window": "2024",
"gate": "hitrate_5d_0.50",
"start": "2024-01-02",
"end": "2024-12-31",
"trade_dates": 253,
"gate_open": 139,
"gate_closed": 114,
"trip_rate": 0.4506,
"base_ann": 0.08229,
"base_sharpe": 0.5594,
"base_maxDD": -0.10685,
"gated_ann": 0.30351,
"gated_sharpe": 2.4275,
"gated_maxDD": -0.088219
},
{
"window": "2024",
"gate": "hitrate_5d_0.60",
"start": "2024-01-02",
"end": "2024-12-31",
"trade_dates": 253,
"gate_open": 71,
"gate_closed": 182,
"trip_rate": 0.7194,
"base_ann": 0.08229,
"base_sharpe": 0.5594,
"base_maxDD": -0.10685,
"gated_ann": 0.265365,
"gated_sharpe": 2.4908,
"gated_maxDD": -0.078304
},
{
"window": "2024",
"gate": "hitrate_5d_0.70",
"start": "2024-01-02",
"end": "2024-12-31",
"trade_dates": 253,
"gate_open": 19,
"gate_closed": 234,
"trip_rate": 0.9249,
"base_ann": 0.08229,
"base_sharpe": 0.5594,
"base_maxDD": -0.10685,
"gated_ann": 0.143125,
"gated_sharpe": 3.377,
"gated_maxDD": -0.003364
},
{
"window": "2024",
"gate": "hitrate_10d_0.50",
"start": "2024-01-02",
"end": "2024-12-31",
"trade_dates": 253,
"gate_open": 157,
"gate_closed": 96,
"trip_rate": 0.3794,
"base_ann": 0.08229,
"base_sharpe": 0.5594,
"base_maxDD": -0.10685,
"gated_ann": 0.277421,
"gated_sharpe": 2.5767,
"gated_maxDD": -0.043146
},
{
"window": "2024",
"gate": "hitrate_10d_0.60",
"start": "2024-01-02",
"end": "2024-12-31",
"trade_dates": 253,
"gate_open": 37,
"gate_closed": 216,
"trip_rate": 0.8538,
"base_ann": 0.08229,
"base_sharpe": 0.5594,
"base_maxDD": -0.10685,
"gated_ann": 0.207161,
"gated_sharpe": 3.6083,
"gated_maxDD": -0.011122
},
{
"window": "2024",
"gate": "hitrate_10d_0.70",
"start": "2024-01-02",
"end": "2024-12-31",
"trade_dates": 253,
"gate_open": 7,
"gate_closed": 246,
"trip_rate": 0.9723,
"base_ann": 0.08229,
"base_sharpe": 0.5594,
"base_maxDD": -0.10685,
"gated_ann": 0.031785,
"gated_sharpe": 1.7408,
"gated_maxDD": -0.002083
},
{
"window": "2024",
"gate": "hitrate_20d_0.40",
"start": "2024-01-02",
"end": "2024-12-31",
"trade_dates": 253,
"gate_open": 247,
"gate_closed": 6,
"trip_rate": 0.0237,
"base_ann": 0.08229,
"base_sharpe": 0.5594,
"base_maxDD": -0.10685,
"gated_ann": 0.078007,
"gated_sharpe": 0.5342,
"gated_maxDD": -0.10685
},
{
"window": "2024",
"gate": "hitrate_20d_0.50",
"start": "2024-01-02",
"end": "2024-12-31",
"trade_dates": 253,
"gate_open": 177,
"gate_closed": 76,
"trip_rate": 0.3004,
"base_ann": 0.08229,
"base_sharpe": 0.5594,
"base_maxDD": -0.10685,
"gated_ann": 0.210672,
"gated_sharpe": 1.8469,
"gated_maxDD": -0.056207
},
{
"window": "2024",
"gate": "hitrate_20d_0.60",
"start": "2024-01-02",
"end": "2024-12-31",
"trade_dates": 253,
"gate_open": 13,
"gate_closed": 240,
"trip_rate": 0.9486,
"base_ann": 0.08229,
"base_sharpe": 0.5594,
"base_maxDD": -0.10685,
"gated_ann": 0.005953,
"gated_sharpe": 0.3039,
"gated_maxDD": -0.014443
},
{
"window": "2023",
"gate": "hitrate_5d_0.50",
"start": "2023-01-03",
"end": "2023-12-29",
"trade_dates": 250,
"gate_open": 139,
"gate_closed": 111,
"trip_rate": 0.444,
"base_ann": -0.047644,
"base_sharpe": -0.2738,
"base_maxDD": -0.197856,
"gated_ann": 0.546654,
"gated_sharpe": 4.2124,
"gated_maxDD": -0.035676
},
{
"window": "2023",
"gate": "hitrate_5d_0.60",
"start": "2023-01-03",
"end": "2023-12-29",
"trade_dates": 250,
"gate_open": 79,
"gate_closed": 171,
"trip_rate": 0.684,
"base_ann": -0.047644,
"base_sharpe": -0.2738,
"base_maxDD": -0.197856,
"gated_ann": 0.556291,
"gated_sharpe": 5.1026,
"gated_maxDD": -0.025449
},
{
"window": "2023",
"gate": "hitrate_5d_0.70",
"start": "2023-01-03",
"end": "2023-12-29",
"trade_dates": 250,
"gate_open": 34,
"gate_closed": 216,
"trip_rate": 0.864,
"base_ann": -0.047644,
"base_sharpe": -0.2738,
"base_maxDD": -0.197856,
"gated_ann": 0.404269,
"gated_sharpe": 4.4477,
"gated_maxDD": -0.013842
},
{
"window": "2023",
"gate": "hitrate_10d_0.50",
"start": "2023-01-03",
"end": "2023-12-29",
"trade_dates": 250,
"gate_open": 148,
"gate_closed": 102,
"trip_rate": 0.408,
"base_ann": -0.047644,
"base_sharpe": -0.2738,
"base_maxDD": -0.197856,
"gated_ann": 0.459869,
"gated_sharpe": 3.5211,
"gated_maxDD": -0.046921
},
{
"window": "2023",
"gate": "hitrate_10d_0.60",
"start": "2023-01-03",
"end": "2023-12-29",
"trade_dates": 250,
"gate_open": 62,
"gate_closed": 188,
"trip_rate": 0.752,
"base_ann": -0.047644,
"base_sharpe": -0.2738,
"base_maxDD": -0.197856,
"gated_ann": 0.368232,
"gated_sharpe": 4.0148,
"gated_maxDD": -0.022983
},
{
"window": "2023",
"gate": "hitrate_10d_0.70",
"start": "2023-01-03",
"end": "2023-12-29",
"trade_dates": 250,
"gate_open": 13,
"gate_closed": 237,
"trip_rate": 0.948,
"base_ann": -0.047644,
"base_sharpe": -0.2738,
"base_maxDD": -0.197856,
"gated_ann": 0.091481,
"gated_sharpe": 2.1324,
"gated_maxDD": -0.010866
},
{
"window": "2023",
"gate": "hitrate_20d_0.40",
"start": "2023-01-03",
"end": "2023-12-29",
"trade_dates": 250,
"gate_open": 237,
"gate_closed": 13,
"trip_rate": 0.052,
"base_ann": -0.047644,
"base_sharpe": -0.2738,
"base_maxDD": -0.197856,
"gated_ann": 0.06639,
"gated_sharpe": 0.3895,
"gated_maxDD": -0.146704
},
{
"window": "2023",
"gate": "hitrate_20d_0.50",
"start": "2023-01-03",
"end": "2023-12-29",
"trade_dates": 250,
"gate_open": 149,
"gate_closed": 101,
"trip_rate": 0.404,
"base_ann": -0.047644,
"base_sharpe": -0.2738,
"base_maxDD": -0.197856,
"gated_ann": 0.366016,
"gated_sharpe": 2.5969,
"gated_maxDD": -0.063998
},
{
"window": "2023",
"gate": "hitrate_20d_0.60",
"start": "2023-01-03",
"end": "2023-12-29",
"trade_dates": 250,
"gate_open": 40,
"gate_closed": 210,
"trip_rate": 0.84,
"base_ann": -0.047644,
"base_sharpe": -0.2738,
"base_maxDD": -0.197856,
"gated_ann": 0.149844,
"gated_sharpe": 2.2645,
"gated_maxDD": -0.032267
},
{
"window": "2021",
"gate": "hitrate_5d_0.50",
"start": "2021-01-04",
"end": "2021-12-31",
"trade_dates": 252,
"gate_open": 145,
"gate_closed": 107,
"trip_rate": 0.4246,
"base_ann": 0.18367,
"base_sharpe": 1.0979,
"base_maxDD": -0.102651,
"gated_ann": 0.556615,
"gated_sharpe": 5.9167,
"gated_maxDD": -0.028062
},
{
"window": "2021",
"gate": "hitrate_5d_0.60",
"start": "2021-01-04",
"end": "2021-12-31",
"trade_dates": 252,
"gate_open": 68,
"gate_closed": 184,
"trip_rate": 0.7302,
"base_ann": 0.18367,
"base_sharpe": 1.0979,
"base_maxDD": -0.102651,
"gated_ann": 0.351867,
"gated_sharpe": 5.8468,
"gated_maxDD": -0.011638
},
{
"window": "2021",
"gate": "hitrate_5d_0.70",
"start": "2021-01-04",
"end": "2021-12-31",
"trade_dates": 252,
"gate_open": 26,
"gate_closed": 226,
"trip_rate": 0.8968,
"base_ann": 0.18367,
"base_sharpe": 1.0979,
"base_maxDD": -0.102651,
"gated_ann": 0.148417,
"gated_sharpe": 3.9593,
"gated_maxDD": -0.0041
},
{
"window": "2021",
"gate": "hitrate_10d_0.50",
"start": "2021-01-04",
"end": "2021-12-31",
"trade_dates": 252,
"gate_open": 163,
"gate_closed": 89,
"trip_rate": 0.3532,
"base_ann": 0.18367,
"base_sharpe": 1.0979,
"base_maxDD": -0.102651,
"gated_ann": 0.465023,
"gated_sharpe": 4.2342,
"gated_maxDD": -0.040569
},
{
"window": "2021",
"gate": "hitrate_10d_0.60",
"start": "2021-01-04",
"end": "2021-12-31",
"trade_dates": 252,
"gate_open": 51,
"gate_closed": 201,
"trip_rate": 0.7976,
"base_ann": 0.18367,
"base_sharpe": 1.0979,
"base_maxDD": -0.102651,
"gated_ann": 0.21802,
"gated_sharpe": 4.7978,
"gated_maxDD": -0.011134
},
{
"window": "2021",
"gate": "hitrate_10d_0.70",
"start": "2021-01-04",
"end": "2021-12-31",
"trade_dates": 252,
"gate_open": 13,
"gate_closed": 239,
"trip_rate": 0.9484,
"base_ann": 0.18367,
"base_sharpe": 1.0979,
"base_maxDD": -0.102651,
"gated_ann": 0.06108,
"gated_sharpe": 2.7055,
"gated_maxDD": -0.000262
},
{
"window": "2021",
"gate": "hitrate_20d_0.40",
"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": "hitrate_20d_0.50",
"start": "2021-01-04",
"end": "2021-12-31",
"trade_dates": 252,
"gate_open": 166,
"gate_closed": 86,
"trip_rate": 0.3413,
"base_ann": 0.18367,
"base_sharpe": 1.0979,
"base_maxDD": -0.102651,
"gated_ann": 0.289756,
"gated_sharpe": 2.5787,
"gated_maxDD": -0.049244
},
{
"window": "2021",
"gate": "hitrate_20d_0.60",
"start": "2021-01-04",
"end": "2021-12-31",
"trade_dates": 252,
"gate_open": 38,
"gate_closed": 214,
"trip_rate": 0.8492,
"base_ann": 0.18367,
"base_sharpe": 1.0979,
"base_maxDD": -0.102651,
"gated_ann": 0.104685,
"gated_sharpe": 2.394,
"gated_maxDD": -0.015084
}
]
-293
View File
@@ -1,293 +0,0 @@
"""Signal-quality gate walk-forward backtest.
Gates trades based on whether the model's recent topk predictions were correct
(hit rate). This is a retrospective gate — it measures prediction accuracy,
not market state.
Usage:
cd /app && .venv/bin/python book/scripts/signal_quality_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/signal_quality_gate")
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"},
]
# Signal-quality gate configs: (lookback_days, threshold, name)
SIGNAL_GATE_CONFIGS = [
(5, 0.50, "hitrate_5d_0.50"),
(5, 0.60, "hitrate_5d_0.60"),
(5, 0.70, "hitrate_5d_0.70"),
(10, 0.50, "hitrate_10d_0.50"),
(10, 0.60, "hitrate_10d_0.60"),
(10, 0.70, "hitrate_10d_0.70"),
(20, 0.40, "hitrate_20d_0.40"),
(20, 0.50, "hitrate_20d_0.50"),
(20, 0.60, "hitrate_20d_0.60"),
]
def load_pred(path: str) -> pd.Series:
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
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 load_bars_for_window(start: str, end: str) -> pd.DataFrame:
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()
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 compute_hit_rate_series(
pred: pd.Series, ret_df: pd.DataFrame, topk: int = 10, lookback: int = 10,
) -> pd.Series:
dt_idx = pred.index.get_level_values(0)
trade_dates = sorted(dt_idx.unique())
hit_rates = {}
for i in range(1, len(trade_dates)):
prev_date = trade_dates[i - 1]
curr_date = trade_dates[i]
try:
prev_scores = pred.loc[prev_date]
except KeyError:
continue
if isinstance(prev_scores, pd.DataFrame):
prev_scores = prev_scores.iloc[:, 0]
prev_scores = prev_scores.dropna().sort_values(ascending=False)
topk_syms = list(prev_scores.index[:topk])
if curr_date not in ret_df.index:
continue
today_ret = ret_df.loc[curr_date]
topk_rets = today_ret.reindex(topk_syms).dropna()
if len(topk_rets) > 0:
hit_rate = (topk_rets > 0).mean()
hit_rates[curr_date] = hit_rate
hit_series = pd.Series(hit_rates)
if len(hit_series) == 0:
return hit_series
rolling_hr = hit_series.rolling(lookback, min_periods=max(1, lookback // 2)).mean()
return rolling_hr
def run_backtest(pred, hit_rate, close_df, start, end, topk=10, threshold=0.5):
if not isinstance(pred.index, pd.MultiIndex):
return {"error": "pred must have MultiIndex"}
ret_df = close_df.pct_change()
ret_df.index = pd.to_datetime(ret_df.index).normalize()
dt_idx = pred.index.get_level_values(0)
window_mask = (dt_idx >= pd.Timestamp(start)) & (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())
gate_open = {}
for d in trade_dates:
known = hit_rate[hit_rate.index <= d]
if len(known) > 0 and not pd.isna(known.iloc[-1]):
gate_open[d] = bool(known.iloc[-1] >= threshold)
else:
gate_open[d] = True
n_total = len(trade_dates)
n_open = sum(1 for v in gate_open.values() if v)
n_closed = n_total - n_open
holdings_base = []
holdings_gated = []
equity_gated = 1_000_000.0
equity_base = 1_000_000.0
prev_week = None
prev_scores = None
daily_gated = []
daily_base = []
ret_by_date = {rd: ret_df.loc[rd] for rd in ret_df.index}
for d in trade_dates:
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.DataFrame):
day_scores = day_scores.iloc[:, 0]
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)
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())
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)
g_series = pd.Series(daily_gated, index=trade_dates)
b_series = pd.Series(daily_base, index=trade_dates)
def _metrics(eq):
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 main():
OUT_DIR.mkdir(parents=True, exist_ok=True)
full_start = "2015-01-03"
full_end = "2026-08-19"
print("Loading lake bars...")
close_df = load_bars_for_window(full_start, full_end)
print(f" {close_df.shape[1]} symbols, {close_df.shape[0]} days")
ret_df = close_df.pct_change()
ret_df.index = pd.to_datetime(ret_df.index).normalize()
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}) ===")
pred = load_pred(pred_path)
print(f" pred shape: {pred.shape}")
hit_rates = {}
for lookback, _, name in SIGNAL_GATE_CONFIGS:
if lookback not in hit_rates:
hr = compute_hit_rate_series(pred, ret_df, topk=10, lookback=lookback)
hit_rates[lookback] = hr
print(f" lookback={lookback}: {len(hr)} days with hit rates")
for lookback, threshold, name in SIGNAL_GATE_CONFIGS:
hr = hit_rates[lookback]
bt = run_backtest(pred, hr, close_df, ws, we, topk=10, threshold=threshold)
if "error" in bt:
print(f" {name}: {bt['error']}")
continue
row = {
"window": wl,
"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" {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})")
df = pd.DataFrame(results)
out_path = OUT_DIR / "signal_quality_gate_results.csv"
df.to_csv(out_path, index=False)
with open(OUT_DIR / "signal_quality_gate_results.json", "w") as f:
json.dump(df.to_dict(orient="records"), f, indent=2, default=str)
print(f"\nSaved to {out_path}")
print("\n=== Summary: Gated Return by Window ===")
for gate_name in df["gate"].unique():
gdf = df[df["gate"] == gate_name]
print(f"\n{gate_name}:")
for _, r in gdf.iterrows():
print(f" {r['window']}: base={r['base_ann']:+.1%}, gated={r['gated_ann']:+.1%}, "
f"trip={r['trip_rate']:.0%}, diff={r['gated_ann']-r['base_ann']:+.1%}pp")
if __name__ == "__main__":
main()
+16 -15
View File
@@ -1,30 +1,31 @@
# TradeAC custom-qlib-code snapshot (auto-generated)
# parent repo HEAD : ceb1e196e24761a87d1afe1bde00079b79e02693
# parent repo HEAD : adc685d9cfe7556ef314cc9dc7c7bccb482aa2f8
# tac-qlib/tac_qlib/contrib
# tac-qlib/tac_qlib/data
# per-file hashes (git hash-object):
1b6298c4a5652f2e863cbdc385a1014a570fcd59 tac-qlib/tac_qlib/contrib/__init__.py
861592c63edd6a0853a9cb174b5970435b135fc8 tac-qlib/tac_qlib/contrib/__pycache__/__init__.cpython-312.pyc
b419ee55ed455a1c45423d1c9025ca5cc0a98576 tac-qlib/tac_qlib/contrib/__pycache__/__init__.cpython-312.pyc
c76a9f17f680e74eea766eff27f7624359749ed6 tac-qlib/tac_qlib/contrib/data/__init__.py
5c547a2ef92e075e550fe6d01508a2f1d3f536bc tac-qlib/tac_qlib/contrib/data/__pycache__/__init__.cpython-312.pyc
4f656130d167e79dcaaeb7783a121f0b36852374 tac-qlib/tac_qlib/contrib/data/__pycache__/handler.cpython-312.pyc
2f6c67620aa2f9e6aaaef3369361d9b3eac3d6ca tac-qlib/tac_qlib/contrib/data/__pycache__/__init__.cpython-312.pyc
fdd5923a70a399e8680913593ff111641947898e tac-qlib/tac_qlib/contrib/data/__pycache__/handler.cpython-312.pyc
0dd25ef161c6e0f15eafc84886e7e1381deb38c3 tac-qlib/tac_qlib/contrib/data/handler.py
b151d139a0dcde87d74b21e7c4b729176ba5c39b tac-qlib/tac_qlib/contrib/model/__init__.py
b1489f2fc0dee85f0a4f90b2e6ad545ed9c8967b tac-qlib/tac_qlib/contrib/model/__pycache__/__init__.cpython-312.pyc
121ef237da1df1b8e21a561c3ad0db200b901339 tac-qlib/tac_qlib/contrib/model/__pycache__/rank_ensemble.cpython-312.pyc
74d0da348cbcc3700c96b6f4fe4391488e61efc5 tac-qlib/tac_qlib/contrib/model/__pycache__/rank_gbdt.cpython-312.pyc
08dec87ccdf6bb5d2cf611ca3032a4280aaab8cf tac-qlib/tac_qlib/contrib/model/__pycache__/__init__.cpython-312.pyc
6fb61946ea9a83dfb560de3717f5fbf482c4c00e tac-qlib/tac_qlib/contrib/model/__pycache__/rank_ensemble.cpython-312.pyc
3e80f2e08b661ddd2f58ffe5a6196063fa41ae51 tac-qlib/tac_qlib/contrib/model/__pycache__/rank_gbdt.cpython-312.pyc
d3f051f3a8650c42fedc7b367b966f7c74fb5789 tac-qlib/tac_qlib/contrib/model/rank_ensemble.py
d03e6611338918d4aac5eea4adf26f85a3763652 tac-qlib/tac_qlib/contrib/model/rank_gbdt.py
2c2f167b693f4366a769998e3c9d4804f29e31e0 tac-qlib/tac_qlib/contrib/strategy/__init__.py
c29e45e562256bf786c36f91a971b097467276e9 tac-qlib/tac_qlib/contrib/strategy/__pycache__/__init__.cpython-312.pyc
6dd1c568a2961842793674390d5abffd1a0e71b8 tac-qlib/tac_qlib/contrib/strategy/__pycache__/optimal_stop.cpython-312.pyc
519a1f4c05dbe0ac018ab8b779eb33d53b4dd545 tac-qlib/tac_qlib/contrib/strategy/ic_gate.py
c4ef84ffda2a611262412fe1127689c667f3d0c1 tac-qlib/tac_qlib/contrib/strategy/__init__.py
6ad10c2ebe37c16417e67c7aeb731ad1fcb6da2f tac-qlib/tac_qlib/contrib/strategy/__pycache__/__init__.cpython-312.pyc
8d684b3216b040071d9ee4fa920a0e0c7486d278 tac-qlib/tac_qlib/contrib/strategy/__pycache__/optimal_stop.cpython-312.pyc
896ef74ae47bcd1ed388e1e5d9c8d70c28097fe9 tac-qlib/tac_qlib/contrib/strategy/kelly_dropout.py
79aaad9e39fcc740a773f4f63c512ce1086cfde0 tac-qlib/tac_qlib/contrib/strategy/optimal_stop.py
7bcee5f0b09cfa721440f1354f16f2dd9a112b12 tac-qlib/tac_qlib/contrib/strategy/regime_gate.py
5b9acfb4340111b204249add7760bd53c6ae03f1 tac-qlib/tac_qlib/contrib/strategy/regime_gate.py
aa1ee880d52ceb5821d65973962099c2254f710a tac-qlib/tac_qlib/contrib/strategy/top_bottom.py
fe60bacdfedd48617863be31f24b7c7daebfac5a tac-qlib/tac_qlib/contrib/strategy/weekly_rebalance.py
92e6e90eb0cd0a25142034560f27adb6b705b1a8 tac-qlib/tac_qlib/data/__init__.py
316bf4aa160cc8d15929ea648be03f4b4999667d tac-qlib/tac_qlib/data/__pycache__/__init__.cpython-312.pyc
554a3f29d181b64effbf49a8161b32e7f93d8d3e tac-qlib/tac_qlib/data/__pycache__/config.cpython-312.pyc
8b47f6d78ac046b6b7b2fb07bd7f3382773ffb73 tac-qlib/tac_qlib/data/__pycache__/providers.cpython-312.pyc
7c4e6c345fad1978efe8860c0d977d0c02d6f8d9 tac-qlib/tac_qlib/data/__pycache__/__init__.cpython-312.pyc
99e602392d51663cb06d5c425000b1ed1e5a916b tac-qlib/tac_qlib/data/__pycache__/config.cpython-312.pyc
020dcdcf288e4832c8cf2386351f78d5ceb4fe13 tac-qlib/tac_qlib/data/__pycache__/providers.cpython-312.pyc
53c9007a928841fd3c3b08450f9a6520ce1ac091 tac-qlib/tac_qlib/data/config.py
8d0644f6f0d1efb94798ed444cc73e63b643459b tac-qlib/tac_qlib/data/providers.py
@@ -1,11 +1,13 @@
from .ic_gate import ICGateTopkDropoutStrategy # noqa: F401
from .kelly_dropout import FractionalKellyDropoutStrategy # noqa: F401
from .optimal_stop import OptimalStopControl # noqa: F401
from .regime_gate import RegimeGateTopkDropoutStrategy # noqa: F401
from .regime_gate import RegimeGateDropoutStrategy # noqa: F401
from .top_bottom import TopBottomDropoutStrategy # noqa: F401
from .weekly_rebalance import WeeklyRebalanceDropoutStrategy # noqa: F401
__all__ = [
"ICGateTopkDropoutStrategy",
"OptimalStopControl",
"RegimeGateTopkDropoutStrategy",
"FractionalKellyDropoutStrategy",
"WeeklyRebalanceDropoutStrategy",
"TopBottomDropoutStrategy",
"RegimeGateDropoutStrategy",
]
@@ -1,117 +0,0 @@
"""Realized-IC circuit breaker TopkDropout strategy.
Subclass of ``qlib.contrib.strategy.signal_strategy.TopkDropoutStrategy`` that
holds the book (issues NO orders) while the streaming realized RankIC of the
deployed signal is below threshold — i.e. the model's cross-sectional
predictions are no longer earning against realized forward returns. When the
gate is open it behaves exactly like the reference TopkDropoutStrategy.
The gate is evaluated per trade step on the trailing mean realized RankIC of
the signal over the last ``ic_window`` trading days whose label is fully
realized as of the decision date (no lookahead — a 5d fwd label ``close[t+6]/
close[t+1]-1`` is only known at ``t+6``).
Two wiring modes:
* ``ic_gate``: a precomputed ``pd.Series`` indexed by datetime of booleans
(True = gate open / trade allowed). Computed once by the caller (e.g.
``rd_backtest``) and looked up per step. Missing dates default to open.
* realized-IC self-computation: when ``ic_min_rankic`` is given but no
``ic_gate``, the strategy computes the per-date realized RankIC itself from
``self.signal`` (the pred scores) and the lake 1d bars via
``tac_qlib.risk_limits.realized_rankic_series``, then applies the same
trailing-window comparison. Works when instantiated from a workflow YAML
PortAnaRecord config (``lake_root`` / ``market`` must be provided).
"""
from __future__ import annotations
import pandas as pd
from qlib.backtest.decision import TradeDecisionWO
from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy
from tac_qlib.risk_limits import ic_circuit_breaker, realized_rankic_series
__all__ = ["ICGateTopkDropoutStrategy"]
class ICGateTopkDropoutStrategy(TopkDropoutStrategy):
"""TopkDropout with a streaming realized-IC circuit breaker.
Parameters
----------
topk, n_drop, method_sell, method_buy, hold_thresh, only_tradable,
forbid_all_trade_at_limit : same as ``TopkDropoutStrategy``.
ic_min_rankic : float — pause new trading while trailing realized RankIC is
below this threshold (0 disables the gate).
ic_window : int — trailing window for the realized RankIC mean (default 22).
ic_label_horizon : int — label horizon in trading days (default 6).
ic_min_obs : int — min realized labels before the gate arms (default 10).
ic_gate : pd.Series, optional — precomputed per-date gate (bool indexed by
datetime). When provided, it overrides self-computation.
lake_root, market : str — lake location for self-computed realized IC.
"""
def __init__(
self,
*,
topk,
n_drop,
ic_min_rankic: float = 0.0,
ic_window: int = 22,
ic_label_horizon: int = 6,
ic_min_obs: int = 10,
ic_gate=None,
lake_root: str = "",
market: str = "US",
**kwargs,
):
super().__init__(topk=topk, n_drop=n_drop, **kwargs)
self.ic_min_rankic = float(ic_min_rankic or 0.0)
self.ic_window = int(ic_window or 22)
self.ic_label_horizon = int(ic_label_horizon or 6)
self.ic_min_obs = int(ic_min_obs or 10)
self._ic_gate = ic_gate
self._realized_ic = None
self.lake_root = lake_root or ""
self.market = market or "US"
def _load_realized_ic(self):
if self._realized_ic is None:
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(
self.trade_calendar.get_trade_step(), shift=-self.ic_label_horizon
)
pred = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
if isinstance(pred, pd.DataFrame):
pred = pred.iloc[:, 0]
self._realized_ic = realized_rankic_series(
pred, self.lake_root, self.market, label_horizon=self.ic_label_horizon
)
return self._realized_ic
def _gate_open(self, trade_start_time) -> bool:
ts = pd.Timestamp(trade_start_time)
if self._ic_gate is not None:
# precomputed gate series: look up the latest known decision date <= ts
known = self._ic_gate[self._ic_gate.index <= ts]
if len(known):
return bool(known.iloc[-1])
return True
if self.ic_min_rankic <= 0:
return True
realized = self._load_realized_ic()
limits = {
"ic_min_rankic": self.ic_min_rankic,
"ic_window": self.ic_window,
"ic_min_obs": self.ic_min_obs,
}
tripped, _reason, _trail = ic_circuit_breaker(realized, ts, limits)
return not tripped
def generate_trade_decision(self, execute_result=None):
trade_step = self.trade_calendar.get_trade_step()
trade_start_time, _ = self.trade_calendar.get_step_time(trade_step)
if not self._gate_open(trade_start_time):
return TradeDecisionWO([], self)
return super().generate_trade_decision(execute_result)
@@ -0,0 +1,201 @@
"""Fractional-Kelly dropout strategy for cross-sectional signals.
Sizing rule variant of ``qlib.contrib.strategy.signal_strategy.TopkDropoutStrategy``:
the topk/n_drop SELECTION is identical to the reference, but the buy size is
proportional to the score MAGNITUDE (edge) instead of equal-weight, capped at a
fraction ``cap_frac`` of the equal-weight notional so a single name cannot
over-concentrate the book.
``cap_frac`` is the fraction of the equal-weight per-name notional that a top
signal can deploy at most (e.g. 0.5 = at most half the equal-weight size).
Names whose score is below the median of the buy set get a proportionally
smaller slice; the residual stays in cash (that is the point of the rule:
throw away less edge per name, deploy less capital when conviction is low).
"""
from __future__ import annotations
from typing import List
import numpy as np
import pandas as pd
from qlib.backtest import Order
from qlib.backtest.decision import OrderDir, TradeDecisionWO
from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy
__all__ = ["FractionalKellyDropoutStrategy"]
DEFAULT_CAP_FRAC = 0.5
class FractionalKellyDropoutStrategy(TopkDropoutStrategy):
"""TopkDropout selection with score-magnitude (fractional-Kelly) sizing.
Parameters
----------
topk, n_drop, method_sell, method_buy, hold_thresh, only_tradable,
forbid_all_trade_at_limit : same as ``TopkDropoutStrategy``.
cap_frac : max buy notional as a fraction of the equal-weight notional.
"""
def __init__(self, *, topk, n_drop, cap_frac: float = DEFAULT_CAP_FRAC, **kwargs):
super().__init__(topk=topk, n_drop=n_drop, **kwargs)
self.cap_frac = cap_frac
def generate_trade_decision(self, execute_result=None):
import copy
trade_step = self.trade_calendar.get_trade_step()
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1)
pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
if isinstance(pred_score, pd.DataFrame):
pred_score = pred_score.iloc[:, 0]
if pred_score is None:
return TradeDecisionWO([], self)
if self.only_tradable:
def get_first_n(li, n, reverse=False):
cur_n = 0
res = []
for si in reversed(li) if reverse else li:
if self.trade_exchange.is_stock_tradable(
stock_id=si, start_time=trade_start_time, end_time=trade_end_time
):
res.append(si)
cur_n += 1
if cur_n >= n:
break
return res[::-1] if reverse else res
def get_last_n(li, n):
return get_first_n(li, n, reverse=True)
def filter_stock(li):
return [
si
for si in li
if self.trade_exchange.is_stock_tradable(
stock_id=si, start_time=trade_start_time, end_time=trade_end_time
)
]
else:
def get_first_n(li, n):
return list(li)[:n]
def get_last_n(li, n):
return list(li)[-n:]
def filter_stock(li):
return li
current_temp: "object" = copy.deepcopy(self.trade_position)
sell_order_list: List[Order] = []
buy_order_list: List[Order] = []
cash = current_temp.get_cash()
current_stock_list = current_temp.get_stock_list()
last = pred_score.reindex(current_stock_list).sort_values(ascending=False).index
if self.method_buy == "top":
today = get_first_n(
pred_score[~pred_score.index.isin(last)].sort_values(ascending=False).index,
self.n_drop + self.topk - len(last),
)
elif self.method_buy == "random":
topk_candi = get_first_n(pred_score.sort_values(ascending=False).index, self.topk)
candi = list(filter(lambda x: x not in last, topk_candi))
n = self.n_drop + self.topk - len(last)
try:
today = np.random.choice(candi, n, replace=False)
except ValueError:
today = candi
else:
raise NotImplementedError(f"This type of input is not supported")
comb = pred_score.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index
if self.method_sell == "bottom":
sell = last[last.isin(get_last_n(comb, self.n_drop))]
elif self.method_sell == "random":
candi = filter_stock(last)
try:
sell = pd.Index(np.random.choice(candi, self.n_drop, replace=False) if len(last) else [])
except ValueError:
sell = candi
else:
raise NotImplementedError(f"This type of input is not supported")
buy = today[: len(sell) + self.topk - len(last)]
for code in current_stock_list:
if not self.trade_exchange.is_stock_tradable(
stock_id=code,
start_time=trade_start_time,
end_time=trade_end_time,
direction=None if self.forbid_all_trade_at_limit else OrderDir.SELL,
):
continue
if code in sell:
time_per_step = self.trade_calendar.get_freq()
if current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh:
continue
sell_amount = current_temp.get_stock_amount(code=code)
sell_order = Order(
stock_id=code,
amount=sell_amount,
start_time=trade_start_time,
end_time=trade_end_time,
direction=Order.SELL,
)
if self.trade_exchange.check_order(sell_order):
sell_order_list.append(sell_order)
trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(
sell_order, position=current_temp
)
cash += trade_val - trade_cost
if len(buy) == 0:
return TradeDecisionWO(sell_order_list, self)
# ---- fractional-Kelly sizing --------------------------------------
# equal-weight notional (reference baseline)
eq_notional = cash * self.risk_degree / len(buy)
buy_scores = pred_score.reindex(buy).astype(float)
lo, hi = buy_scores.min(), buy_scores.max()
if hi == lo:
w = pd.Series(1.0, index=buy_scores.index)
else:
w = (buy_scores - lo) / (hi - lo) # [0,1] edge magnitude
w = w.clip(lower=0.0)
w_max = w.max()
w = w / w_max if w_max > 0 else w # max == 1.0
for code in buy:
if not self.trade_exchange.is_stock_tradable(
stock_id=code,
start_time=trade_start_time,
end_time=trade_end_time,
direction=None if self.forbid_all_trade_at_limit else OrderDir.BUY,
):
continue
buy_price = self.trade_exchange.get_deal_price(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=OrderDir.BUY
)
notional = eq_notional * min(self.cap_frac, float(w.get(code, 0.0)))
buy_amount = notional / buy_price
factor = self.trade_exchange.get_factor(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
)
buy_amount = self.trade_exchange.round_amount_by_trade_unit(buy_amount, factor)
buy_order = Order(
stock_id=code,
amount=buy_amount,
start_time=trade_start_time,
end_time=trade_end_time,
direction=Order.BUY,
)
buy_order_list.append(buy_order)
return TradeDecisionWO(sell_order_list + buy_order_list, self)
@@ -1,215 +1,231 @@
"""Regime-gate TopkDropout strategy.
"""HMM-regime overlay TopkDropout strategy.
Subclass of ``qlib.contrib.strategy.signal_strategy.TopkDropoutStrategy`` that
holds the book (issues NO orders) while a regime detector says the market is in
an unfavorable state. When the gate is open it behaves exactly like the
reference TopkDropoutStrategy.
Regime-gate overlay on ``qlib.contrib.strategy.signal_strategy.TopkDropoutStrategy``:
selection and sizing are identical to the reference, but a name is only BOUGHT
(entry gate) when its per-symbol HMM regime posterior ``sp_hmm_p_regime1`` on
the signal date is >= ``regime_threshold``; otherwise it is held in cash instead
of being opened.
Three detector types are supported (all causal — no lookahead):
The regime posterior is read from the lake feature provider on the fly via
``qlib.data.D.features`` (field ``$sp_hmm_p_regime1``) for the signal window, so
no regime column needs to enter the model's ``feature_fields`` — the gate is a
pure overlay (book ch.01: regime flags regressed as model features, survived
only as an overlay). The HMM itself was fit with ``fit_end=<train end>`` when
the lake features were backfilled, so there is no lookahead.
* ``dispersion``: cross-sectional standard deviation of 22-day rolling returns
across the universe. Gate closes when CS dispersion < threshold (low
dispersion means the spread between winners and losers is too narrow for
TopkDropout to exploit).
* ``vol``: cross-sectional mean of 22-day rolling realized volatility. Gate
closes when avg vol is outside a band ``[vol_low, vol_high]`` (strategy
needs moderate vol — too calm or too turbulent both hurt).
* ``hmm``: pre-computed HMM posterior for regime 1 (``sp_hmm_p_regime1``).
Gate closes when posterior < threshold (model is not confident the calm
regime is active).
The gate is provided as a precomputed ``pd.Series`` of booleans indexed by
datetime (True = trade allowed). The companion ``compute_regime_gate``
function builds this series from lake bars; call it once before backtesting
and pass the result as the ``regime_gate`` parameter.
Names already held are NOT force-sold when the regime turns unfavourable
(entry gate only, matching the queue-10 design).
"""
from __future__ import annotations
from typing import List
import numpy as np
import pandas as pd
from qlib.backtest.decision import TradeDecisionWO
from qlib.backtest import Order
from qlib.backtest.decision import OrderDir, TradeDecisionWO
from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy
__all__ = ["RegimeGateTopkDropoutStrategy", "compute_regime_gate"]
try:
from qlib.data import D
except ImportError: # pragma: no cover - qlib always present in this stack
D = None
__all__ = ["RegimeGateDropoutStrategy"]
DEFAULT_REGIME_THRESHOLD = 0.5
REGIME_FIELD = "$sp_hmm_p_regime1"
class RegimeGateTopkDropoutStrategy(TopkDropoutStrategy):
"""TopkDropout with a regime-gate circuit breaker.
class RegimeGateDropoutStrategy(TopkDropoutStrategy):
"""TopkDropout with an HMM-regime entry gate on buy candidates.
Parameters
----------
topk, n_drop, method_sell, method_buy, hold_thresh, only_tradable,
forbid_all_trade_at_limit : same as ``TopkDropoutStrategy``.
regime_gate : pd.Series — precomputed per-date gate (bool indexed by
datetime). True = trade allowed, False = no orders. Missing dates
default to open (trade allowed).
regime_threshold : minimum ``sp_hmm_p_regime1`` posterior required to open a
new position (default 0.5).
"""
def __init__(self, *, regime_gate=None, **kwargs):
super().__init__(**kwargs)
self._regime_gate = regime_gate
def __init__(self, *, topk, n_drop, regime_threshold: float = DEFAULT_REGIME_THRESHOLD, **kwargs):
super().__init__(topk=topk, n_drop=n_drop, **kwargs)
self.regime_threshold = regime_threshold
def _gate_open(self, trade_start_time) -> bool:
if self._regime_gate is None:
return True
ts = pd.Timestamp(trade_start_time)
known = self._regime_gate[self._regime_gate.index <= ts]
if len(known):
return bool(known.iloc[-1])
return True # default open if no history yet
def _regime_for(self, codes, pred_start, pred_end) -> pd.Series:
"""Return {code: sp_hmm_p_regime1} for the signal window (last day)."""
if D is None:
return pd.Series(dtype=float)
try:
df = D.features(list(codes), [REGIME_FIELD], start_time=pred_start, end_time=pred_end, freq="day")
except Exception: # noqa: BLE001 - a regime read failure should gate open, not crash
return pd.Series(dtype=float)
if df is None or len(df) == 0:
return pd.Series(dtype=float)
# df index is MultiIndex (datetime, instrument); take the last day's values
df = df.reset_index()
ts_col = "datetime" if "datetime" in df.columns else df.columns[0]
sym_col = "instrument" if "instrument" in df.columns else df.columns[1]
last_ts = df[ts_col].max()
last = df[df[ts_col] == last_ts]
out = {}
for _, row in last.iterrows():
sym = str(row[sym_col]).split("/")[-1].upper()
val = row.iloc[-1]
out[sym] = float(val) if val == val else np.nan
return pd.Series(out)
def generate_trade_decision(self, execute_result=None):
import copy
trade_step = self.trade_calendar.get_trade_step()
trade_start_time, _ = self.trade_calendar.get_step_time(trade_step)
if not self._gate_open(trade_start_time):
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1)
pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
if isinstance(pred_score, pd.DataFrame):
pred_score = pred_score.iloc[:, 0]
if pred_score is None:
return TradeDecisionWO([], self)
return super().generate_trade_decision(execute_result)
if self.only_tradable:
# ---------------------------------------------------------------------------
# Precomputation helper
# ---------------------------------------------------------------------------
def compute_regime_gate(
detector: str,
threshold: float = 0.0,
*,
lake_root: str = "",
market: str = "US",
start: str = "2015-01-03",
end: str = "2026-08-19",
vol_low: float = 0.0,
vol_high: float = 999.0,
hmm_field: str = "sp_hmm_p_regime1",
) -> pd.Series:
"""Build a per-date regime gate series from lake bars.
Parameters
----------
detector : str — ``"dispersion"``, ``"vol"``, or ``"hmm"``.
threshold : float — for ``dispersion``: min CS dispersion to allow trading.
For ``hmm``: min HMM posterior to allow trading.
Ignored for ``vol`` (uses ``vol_low``/``vol_high`` band instead).
lake_root, market : str — lake location.
start, end : str — date window.
vol_low, vol_high : float — annualized vol band for the ``vol`` detector.
hmm_field : str — HMM feature column name for the ``hmm`` detector.
Returns
-------
pd.Series — bool, indexed by datetime. True = trade allowed.
"""
from tac_qlib.data.config import LakeConfig, resolve_lake_root
cfg = LakeConfig(resolve_lake_root(lake_root or None), market)
symbols = _universe_symbols(cfg)
close_df, vol_df = _load_daily_bars(symbols, cfg, start, end)
if close_df.empty:
return pd.Series(dtype=bool)
if detector == "dispersion":
return _dispersion_gate(close_df, threshold)
elif detector == "vol":
return _vol_gate(close_df, vol_low, vol_high)
elif detector == "hmm":
return _hmm_gate(cfg, symbols, threshold, start, end, hmm_field)
else:
raise ValueError(f"Unknown detector: {detector!r}")
def _universe_symbols(cfg) -> list:
"""Read symbols from the lake symbols.parquet."""
import pathlib
sp = cfg.lake_root / "symbols.parquet"
if sp.exists():
df = pd.read_parquet(sp)
col = "symbol" if "symbol" in df.columns else df.columns[0]
return sorted(df[col].astype(str).str.upper().tolist())
return []
def _load_daily_bars(symbols, cfg, start, end):
"""Load daily close prices for all symbols into a wide DataFrame."""
closes = {}
vols = {}
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()
df = df.loc[start:end]
if len(df) < 22:
continue
closes[sym] = df["c"]
if "v" in df.columns:
vols[sym] = df["v"]
close_df = pd.DataFrame(closes)
vol_df = pd.DataFrame(vols) if vols else None
return close_df, vol_df
def _dispersion_gate(close_df, threshold):
"""Cross-sectional dispersion of 22-day rolling returns."""
if close_df.empty or close_df.shape[1] < 2:
return pd.Series(dtype=bool)
ret = close_df.pct_change(22)
cs_disp = ret.std(axis=1)
gate = cs_disp >= threshold
gate.iloc[:22] = True # warmup: allow trading
return gate
def _vol_gate(close_df, vol_low, vol_high):
"""Cross-sectional mean of 22-day rolling realized vol."""
if close_df.empty or close_df.shape[1] < 2:
return pd.Series(dtype=bool)
import numpy as np
log_ret = np.log(close_df / close_df.shift(1))
rv22 = log_ret.rolling(22).std() * (252 ** 0.5)
cs_mean_vol = rv22.mean(axis=1)
gate = (cs_mean_vol >= vol_low) & (cs_mean_vol <= vol_high)
gate.iloc[:22] = True # warmup
return gate
def _hmm_gate(cfg, symbols, threshold, start, end, hmm_field):
"""HMM regime posterior gate from persisted SP features."""
feat_root = cfg.lake_root / "features"
all_posteriors = {}
for sym in symbols:
# check both ta and sp family paths
for family in ("sp", "ta"):
p = feat_root / f"market=US" / f"timeframe=1d" / f"family={family}" / f"symbol={sym}.parquet"
if not p.exists():
continue
try:
df = pd.read_parquet(p)
except Exception:
continue
if hmm_field not in df.columns:
continue
tcol = df["t"] if "t" in df.columns else df["date"]
ts = pd.to_datetime(tcol)
s = pd.Series(df[hmm_field].values, index=ts, name=sym)
s = s.loc[start:end].dropna()
if len(s) > 0:
all_posteriors[sym] = s
def get_first_n(li, n, reverse=False):
cur_n = 0
res = []
for si in reversed(li) if reverse else li:
if self.trade_exchange.is_stock_tradable(
stock_id=si, start_time=trade_start_time, end_time=trade_end_time
):
res.append(si)
cur_n += 1
if cur_n >= n:
break
if not all_posteriors:
# no HMM features found — default open
idx = pd.date_range(start, end, freq="B")
return pd.Series(True, index=idx)
post_df = pd.DataFrame(all_posteriors)
cs_mean = post_df.mean(axis=1)
gate = cs_mean >= threshold
return gate
return res[::-1] if reverse else res
def get_last_n(li, n):
return get_first_n(li, n, reverse=True)
def filter_stock(li):
return [
si
for si in li
if self.trade_exchange.is_stock_tradable(
stock_id=si, start_time=trade_start_time, end_time=trade_end_time
)
]
else:
def get_first_n(li, n):
return list(li)[:n]
def get_last_n(li, n):
return list(li)[-n:]
def filter_stock(li):
return li
current_temp: "object" = copy.deepcopy(self.trade_position)
sell_order_list: List[Order] = []
buy_order_list: List[Order] = []
cash = current_temp.get_cash()
current_stock_list = current_temp.get_stock_list()
last = pred_score.reindex(current_stock_list).sort_values(ascending=False).index
if self.method_buy == "top":
today = get_first_n(
pred_score[~pred_score.index.isin(last)].sort_values(ascending=False).index,
self.n_drop + self.topk - len(last),
)
elif self.method_buy == "random":
topk_candi = get_first_n(pred_score.sort_values(ascending=False).index, self.topk)
candi = list(filter(lambda x: x not in last, topk_candi))
n = self.n_drop + self.topk - len(last)
try:
today = np.random.choice(candi, n, replace=False)
except ValueError:
today = candi
else:
raise NotImplementedError(f"This type of input is not supported")
comb = pred_score.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index
if self.method_sell == "bottom":
sell = last[last.isin(get_last_n(comb, self.n_drop))]
elif self.method_sell == "random":
candi = filter_stock(last)
try:
sell = pd.Index(np.random.choice(candi, self.n_drop, replace=False) if len(last) else [])
except ValueError:
sell = candi
else:
raise NotImplementedError(f"This type of input is not supported")
buy = today[: len(sell) + self.topk - len(last)]
# ---- regime gate -----------------------------------------------------
if buy:
regime = self._regime_for(buy, pred_start_time, pred_end_time)
gated = [c for c in buy if regime.get(c, np.nan) >= self.regime_threshold]
else:
gated = []
for code in current_stock_list:
if not self.trade_exchange.is_stock_tradable(
stock_id=code,
start_time=trade_start_time,
end_time=trade_end_time,
direction=None if self.forbid_all_trade_at_limit else OrderDir.SELL,
):
continue
if code in sell:
time_per_step = self.trade_calendar.get_freq()
if current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh:
continue
sell_amount = current_temp.get_stock_amount(code=code)
sell_order = Order(
stock_id=code,
amount=sell_amount,
start_time=trade_start_time,
end_time=trade_end_time,
direction=Order.SELL,
)
if self.trade_exchange.check_order(sell_order):
sell_order_list.append(sell_order)
trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(
sell_order, position=current_temp
)
cash += trade_val - trade_cost
if len(gated) == 0:
return TradeDecisionWO(sell_order_list, self)
value = cash * self.risk_degree / len(gated)
for code in gated:
if not self.trade_exchange.is_stock_tradable(
stock_id=code,
start_time=trade_start_time,
end_time=trade_end_time,
direction=None if self.forbid_all_trade_at_limit else OrderDir.BUY,
):
continue
buy_price = self.trade_exchange.get_deal_price(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=OrderDir.BUY
)
buy_amount = value / buy_price
factor = self.trade_exchange.get_factor(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
)
buy_amount = self.trade_exchange.round_amount_by_trade_unit(buy_amount, factor)
buy_order = Order(
stock_id=code,
amount=buy_amount,
start_time=trade_start_time,
end_time=trade_end_time,
direction=Order.BUY,
)
buy_order_list.append(buy_order)
return TradeDecisionWO(sell_order_list + buy_order_list, self)
@@ -0,0 +1,169 @@
"""Market-neutral top/bottom long-short strategy for cross-sectional signals.
Captures the cross-sectional long-short spread net of costs: buys the top-ranked
``topk`` names and shorts the bottom-ranked ``topk`` names, equal-weight per
side, sized to ``risk_degree`` of total value per side. Rebalances daily to the
current rank (dropout-free: the book converges to the latest top/bottom sets).
The long and short legs use equal notional per side (gross exposure ~2x
``risk_degree`` of NAV, i.e. approximately market neutral before transaction
costs). Benchmark neutrality (SPY beta ~ 0) is the secondary sanity metric.
"""
from __future__ import annotations
from typing import List
import copy
import pandas as pd
from qlib.backtest import Order
from qlib.backtest.decision import OrderDir, TradeDecisionWO
from qlib.contrib.strategy.signal_strategy import BaseSignalStrategy
__all__ = ["TopBottomDropoutStrategy"]
DEFAULT_SHORT_LEG = True
DEFAULT_REBALANCE_DAILY = True
class TopBottomDropoutStrategy(BaseSignalStrategy):
"""Long top-k / short bottom-k equal-weight market-neutral book.
Parameters
----------
topk : number of names on each side (long top-k and short bottom-k).
short_leg : whether to open the short side (if False, long-only topk).
rebalance_daily : if True rebalance to current rank every day; else keep
positions and only refresh on score changes (dropout-style).
risk_degree : fraction of total value deployed per side.
"""
def __init__(
self,
*,
topk: int = 10,
short_leg: bool = DEFAULT_SHORT_LEG,
rebalance_daily: bool = DEFAULT_REBALANCE_DAILY,
**kwargs,
):
super().__init__(**kwargs)
self.topk = topk
self.short_leg = short_leg
self.rebalance_daily = rebalance_daily
self._prev_longs = set()
self._prev_shorts = set()
def generate_trade_decision(self, execute_result=None):
trade_step = self.trade_calendar.get_trade_step()
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1)
pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
if isinstance(pred_score, pd.DataFrame):
pred_score = pred_score.iloc[:, 0]
if pred_score is None or len(pred_score) == 0:
return TradeDecisionWO([], self)
# rank all names; topk longs and topk shorts
ranked = pred_score.sort_values(ascending=False)
longs = list(ranked.index[: self.topk])
shorts = list(ranked.index[-self.topk :]) if self.short_leg else []
current_temp: "object" = copy.deepcopy(self.trade_position)
current_codes = set(current_temp.get_stock_list())
holdings = {c: current_temp for c in current_codes if abs(current_temp.get_stock_amount(c)) > 1e-6}
sell_orders: List[Order] = []
buy_orders: List[Order] = []
def _tradable(code, direction):
try:
return self.trade_exchange.is_stock_tradable(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=direction
)
except TypeError:
return self.trade_exchange.is_stock_tradable(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
)
# determine target set (long/short)
target_longs = set(longs)
target_shorts = set(shorts)
# close positions not in the target book
for code in list(holdings):
if code in target_longs or code in target_shorts:
continue
amt = abs(current_temp.get_stock_amount(code))
o = Order(
stock_id=code,
amount=amt,
start_time=trade_start_time,
end_time=trade_end_time,
direction=Order.SELL if code in target_longs else Order.SELL,
)
if self.trade_exchange.check_order(o):
sell_orders.append(o)
self.trade_exchange.deal_order(o, position=current_temp)
# equal-weight notional per side
total_value = current_temp.get_cash()
for code, pos in holdings.items():
if code in target_longs or code in target_shorts:
mark = self.trade_exchange.get_deal_price(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=Order.SELL
)
if mark is not None and mark == mark:
total_value += abs(current_temp.get_stock_amount(code)) * mark
side_notional = total_value * self.risk_degree / max(1, self.topk)
for code in longs:
if code in holdings and abs(current_temp.get_stock_amount(code)) > 1e-6:
continue
px = self.trade_exchange.get_deal_price(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=Order.BUY
)
if px is None or px != px or px <= 0:
continue
amount = side_notional / px
factor = self.trade_exchange.get_factor(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
)
amount = self.trade_exchange.round_amount_by_trade_unit(amount, factor)
o = Order(
stock_id=code,
amount=amount,
start_time=trade_start_time,
end_time=trade_end_time,
direction=Order.BUY,
)
if self.trade_exchange.check_order(o):
buy_orders.append(o)
if self.short_leg:
for code in shorts:
if code in holdings and abs(current_temp.get_stock_amount(code)) > 1e-6:
continue
px = self.trade_exchange.get_deal_price(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=Order.SELL
)
if px is None or px != px or px <= 0:
continue
amount = side_notional / px
factor = self.trade_exchange.get_factor(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
)
amount = self.trade_exchange.round_amount_by_trade_unit(amount, factor)
o = Order(
stock_id=code,
amount=amount,
start_time=trade_start_time,
end_time=trade_end_time,
direction=Order.SELL,
)
if self.trade_exchange.check_order(o):
sell_orders.append(o)
return TradeDecisionWO(sell_orders + buy_orders, self)