falsealpha 0.1.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {falsealpha-0.1.0 → falsealpha-0.2.1}/PKG-INFO +18 -6
- {falsealpha-0.1.0 → falsealpha-0.2.1}/README.md +17 -5
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/gp.hpp +6 -1
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/bindings.cpp +3 -1
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/gp.cpp +51 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/ops.cpp +33 -19
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/score.cpp +4 -2
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/tests/test_ops.cpp +33 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/pyproject.toml +1 -1
- {falsealpha-0.1.0 → falsealpha-0.2.1}/scripts/noise_calibration.py +9 -2
- {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/cli.py +3 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/firewall.py +18 -5
- {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/report.py +16 -7
- {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/run.py +4 -3
- {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/test_firewall.py +44 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/.github/workflows/ci.yml +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/.github/workflows/publish.yml +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/.gitignore +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/CMakeLists.txt +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/LICENSE +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/eval.hpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/expr.hpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/mat.hpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/ops.hpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/score.hpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/stats.hpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/eval.cpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/expr.cpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/stats.cpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/tests/helpers.hpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/tests/test_expr.cpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/tests/test_gp.cpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/tests/test_score.cpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/tests/test_stats.cpp +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/docs/demo.gif +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/docs/demo.tape +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/docs/report.png +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/scripts/snoop_size.py +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/__init__.py +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/bench.py +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/binance.py +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/panel.py +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/synthetic.py +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/conftest.py +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/reference.py +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/test_bench.py +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/test_binance.py +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/test_cli.py +0 -0
- {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/test_reference.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: falsealpha
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: A C++ alpha-mining engine that searches formulaic trading signals, then tells you which of its discoveries are fake.
|
|
5
5
|
Keywords: quant,alpha,genetic-programming,backtesting,overfitting,deflated-sharpe
|
|
6
6
|
Author: Owen Alderson
|
|
@@ -22,6 +22,7 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
# falsealpha
|
|
23
23
|
|
|
24
24
|
[](https://github.com/owen-alderson/falsealpha/actions/workflows/ci.yml)
|
|
25
|
+
[](https://pypi.org/project/falsealpha/)
|
|
25
26
|
[](LICENSE)
|
|
26
27
|
|
|
27
28
|
**A C++ engine that searches thousands of formulaic trading signals, then tells you which of its own discoveries are fake.**
|
|
@@ -35,6 +36,7 @@ $ falsealpha data noise --out noise.npz # 2,500 days x 60 assets of pur
|
|
|
35
36
|
$ falsealpha mine noise.npz runs/noise
|
|
36
37
|
6002 formulas tried -> 50 finalists -> 0 survivors
|
|
37
38
|
luck bar (train): best of 6002 unskilled strategies ~ Sharpe +1.84 /yr
|
|
39
|
+
trial clusters: ~709 independent bets -> luck bar +1.56 /yr (reported; the gate counts every formula)
|
|
38
40
|
Reality Check p 0.942 (gate) SPA p 0.800
|
|
39
41
|
PBO 0.95 (share of splits where the in-sample best finalist lands in the bottom half)
|
|
40
42
|
|
|
@@ -42,6 +44,7 @@ $ falsealpha data planted --out planted.npz # same, plus one hidden alpha
|
|
|
42
44
|
$ falsealpha mine planted.npz runs/planted
|
|
43
45
|
6397 formulas tried -> 50 finalists -> 3 survivors
|
|
44
46
|
luck bar (train): best of 6397 unskilled strategies ~ Sharpe +1.85 /yr
|
|
47
|
+
trial clusters: ~667 independent bets -> luck bar +1.55 /yr (reported; the gate counts every formula)
|
|
45
48
|
Reality Check p 0.000 (gate) SPA p 0.000
|
|
46
49
|
PBO 0.00 (share of splits where the in-sample best finalist lands in the bottom half)
|
|
47
50
|
[PASS] train +5.87 (DSR 1.00) val +4.94 (DSR 1.00) (decay_linear(ts_sum(volume, 3), 5) * ts_sum(volume, 5))
|
|
@@ -49,7 +52,7 @@ $ falsealpha unseal runs/planted
|
|
|
49
52
|
combined: holdout Sharpe +4.08 /yr, P(true Sharpe > 0) 1.00, 498 days
|
|
50
53
|
```
|
|
51
54
|
|
|
52
|
-
Across 30 independent noise panels (1,500 days × 60 assets, population 200 × 6 generations; [`scripts/noise_calibration.py`](scripts/noise_calibration.py)), the firewall let through **0** formulas. The Reality Check gate never opened (0 of 30); the Deflated Sharpe alone would also have caught everything. Mean PBO was 0.49, a coin flip, which is what overfitting to noise looks like.
|
|
55
|
+
Across 30 independent noise panels (1,500 days × 60 assets, population 200 × 6 generations; [`scripts/noise_calibration.py`](scripts/noise_calibration.py)), the firewall let through **0** formulas. The Reality Check gate never opened (0 of 30); the Deflated Sharpe alone would also have caught everything, even counting only the trial clusters as independent bets (0 of 30 runs had a finalist reach a train DSR of 0.95 either way). Mean PBO was 0.49, a coin flip, which is what overfitting to noise looks like.
|
|
53
56
|
|
|
54
57
|
## On real data: six years of crypto
|
|
55
58
|
|
|
@@ -60,6 +63,7 @@ $ falsealpha data binance --out data/binance-top100.npz
|
|
|
60
63
|
$ falsealpha mine data/binance-top100.npz runs/crypto # 45 s on an M3 Pro
|
|
61
64
|
7376 formulas tried -> 50 finalists -> 0 survivors
|
|
62
65
|
luck bar (train): best of 7376 unskilled strategies ~ Sharpe +1.88 /yr
|
|
66
|
+
trial clusters: ~1197 independent bets -> luck bar +1.64 /yr (reported; the gate counts every formula)
|
|
63
67
|
Reality Check p 0.475 (gate) SPA p 0.185
|
|
64
68
|
PBO 0.73 (share of splits where the in-sample best finalist lands in the bottom half)
|
|
65
69
|
[fail] train +2.06 (DSR 0.64) val -1.08 (DSR 0.00) min(((quote_volume / ts_mean(ts_delta(open, 20), 5)) - high), -ts_sum(trades, 10))
|
|
@@ -67,7 +71,7 @@ $ falsealpha mine data/binance-top100.npz runs/crypto # 45 s on an M3 Pro
|
|
|
67
71
|
[fail] train +1.86 (DSR 0.48) val +1.02 (DSR 0.14) ts_sum((-trades * ts_cov(close, quote_volume, 10)), 40)
|
|
68
72
|
```
|
|
69
73
|
|
|
70
|
-
The best formula the search found earns a Sharpe of +2.06 a year over four years of training data, after costs. Shown alone, that is a backtest people trade. The firewall rejects it on the training data already: 7,376 tries at nothing would be expected to produce about +1.88 by luck, so its Deflated Sharpe is only 0.64. On the next 16 months, which the search never saw, it loses money (−1.08 a year), and so does the runner-up (−1.79). The few finalists that stay positive out of sample (+1.02 at best) are well within what luck explains over 493 days (Reality Check p = 0.48), and PBO is 0.73: picking the in-sample best finalist usually picks a below-median one.
|
|
74
|
+
The best formula the search found earns a Sharpe of +2.06 a year over four years of training data, after costs. Shown alone, that is a backtest people trade. The firewall rejects it on the training data already: 7,376 tries at nothing would be expected to produce about +1.88 by luck, so its Deflated Sharpe is only 0.64. Many of those tries are near-copies of each other; grouping them (|corr| > 0.7) leaves about 1,197 independent bets, a luck bar of +1.64, and a Deflated Sharpe of 0.80: still a fail, so the verdict does not rest on the conservative count. On the next 16 months, which the search never saw, it loses money (−1.08 a year), and so does the runner-up (−1.79). The few finalists that stay positive out of sample (+1.02 at best) are well within what luck explains over 493 days (Reality Check p = 0.48), and PBO is 0.73: picking the in-sample best finalist usually picks a below-median one.
|
|
71
75
|
|
|
72
76
|
So the honest result of this run is **no tradable alpha**, and the holdout (May 2025 to September 2026) stays sealed for a better search. That is the tool working: it is built to say "no" when the evidence is not there.
|
|
73
77
|
|
|
@@ -88,7 +92,7 @@ So the honest result of this run is **no tradable alpha**, and the holdout (May
|
|
|
88
92
|
```
|
|
89
93
|
|
|
90
94
|
1. **Search.** Genetic programming over an Alpha101-style formula language: ramped half-and-half initialisation, subtree crossover, point / subtree / hoist mutation, tournament selection, a parsimony penalty, and a novelty penalty against a hall of fame so it keeps finding *different* signals rather than near-copies (|corr| ≤ 0.7 between finalists). Fitness is the net long-short Sharpe on the train period only.
|
|
91
|
-
2. **Ledger.** Every distinct formula the search scores is written to `ledger.csv.gz` (formulas are canonicalised, so `a + b` and `b + a` count once). This is the *N* in the multiple-testing correction, not just the winners.
|
|
95
|
+
2. **Ledger.** Every distinct formula the search scores is written to `ledger.csv.gz` (formulas are canonicalised, so `a + b` and `b + a` count once). This is the *N* in the multiple-testing correction, not just the winners. Each formula is also assigned to a **trial cluster**: it joins the first cluster whose leader's train returns it matches with |corr| > 0.7, or leads a new one. The number of clusters estimates how many *independent* bets the search made.
|
|
92
96
|
3. **Firewall.** A finalist survives only if it passes all of:
|
|
93
97
|
- **Train Deflated Sharpe ≥ 0.95** ([Bailey & López de Prado 2014](https://papers.ssrn.com/abstract=2460551)): the train Sharpe must beat the expected maximum of *N* unskilled strategies, where *N* is the whole ledger.
|
|
94
98
|
- **Validation Sharpe > 0 with Deflated Sharpe ≥ 0.95**, deflated for the number of finalists scored on the unseen validation days.
|
|
@@ -96,7 +100,7 @@ So the honest result of this run is **no tradable alpha**, and the holdout (May
|
|
|
96
100
|
- **PBO** ([Bailey, Borwein, López de Prado & Zhu 2017](https://papers.ssrn.com/abstract=2326253)) is reported, computed over the finalists' train + validation returns: combinatorially symmetric cross-validation over 16 blocks (12,870 splits), the share where the in-sample best finalist lands in the bottom half out of sample.
|
|
97
101
|
4. **Holdout.** The search and validation never receive the holdout days: the panel is physically truncated before they run. `falsealpha unseal` scores the survivors and their equal-risk combination on the holdout once; a second look needs `--force`, and the report shows every look.
|
|
98
102
|
|
|
99
|
-
**One deliberate deviation from the DSR paper.** The paper sets the luck bar from the variance of Sharpe ratios *across* trials. In a search that finds something real, dozens of variants of that signal inflate that variance and the test ends up punishing the discovery (on the planted data it rejects a true signal with a Sharpe of 9). falsealpha uses the null variance, 1/(T−1): the expected best of *N* strategies with no skill. Counting every distinct formula as a trial overstates the number of independent trials, so the bar is conservative. The paper's version is still computed and stored (`sr0_train_paper`).
|
|
103
|
+
**One deliberate deviation from the DSR paper.** The paper sets the luck bar from the variance of Sharpe ratios *across* trials. In a search that finds something real, dozens of variants of that signal inflate that variance and the test ends up punishing the discovery (on the planted data it rejects a true signal with a Sharpe of 9). falsealpha uses the null variance, 1/(T−1): the expected best of *N* strategies with no skill. Counting every distinct formula as a trial overstates the number of independent trials, so the bar is conservative; the run reports how much by also deflating against the trial clusters (`n_effective`, `dsr_effective`, the "DSR vs clusters" column in the report), and the gate keeps the conservative count. The paper's version is still computed and stored (`sr0_train_paper`).
|
|
100
104
|
|
|
101
105
|
## Quick start
|
|
102
106
|
|
|
@@ -225,4 +229,12 @@ cmake -S . -B build -DFALSEALPHA_BUILD_TESTS=ON && cmake --build build -j && ./b
|
|
|
225
229
|
- C. R. Harvey, Y. Liu, H. Zhu (2016). …and the Cross-Section of Expected Returns. *Review of Financial Studies* 29(1).
|
|
226
230
|
- Z. Kakushadze (2016). 101 Formulaic Alphas. *Wilmott* 2016(84). [arXiv:1601.00991](https://arxiv.org/abs/1601.00991)
|
|
227
231
|
|
|
228
|
-
|
|
232
|
+
## Disclaimer
|
|
233
|
+
|
|
234
|
+
Research software, not investment advice. Backtested results do not predict future returns. falsealpha is not affiliated with or endorsed by Binance.
|
|
235
|
+
|
|
236
|
+
## Data and licences
|
|
237
|
+
|
|
238
|
+
`falsealpha data binance` downloads Binance's public historical data from [Binance Vision](https://data.binance.vision) to your own machine. Binance licenses that data under [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) with its own [dataset terms](https://data.binance.vision/Binance_Vision-Terms_of_Use.pdf): non-commercial use only, and anything you publish from it must credit Binance Vision and carry the same licence.
|
|
239
|
+
|
|
240
|
+
The results in this README and `docs/report.png` are derived from Binance Vision data, so they are licensed CC BY-NC-SA 4.0, with credit to Binance Vision. The code is MIT.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# falsealpha
|
|
2
2
|
|
|
3
3
|
[](https://github.com/owen-alderson/falsealpha/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/falsealpha/)
|
|
4
5
|
[](LICENSE)
|
|
5
6
|
|
|
6
7
|
**A C++ engine that searches thousands of formulaic trading signals, then tells you which of its own discoveries are fake.**
|
|
@@ -14,6 +15,7 @@ $ falsealpha data noise --out noise.npz # 2,500 days x 60 assets of pur
|
|
|
14
15
|
$ falsealpha mine noise.npz runs/noise
|
|
15
16
|
6002 formulas tried -> 50 finalists -> 0 survivors
|
|
16
17
|
luck bar (train): best of 6002 unskilled strategies ~ Sharpe +1.84 /yr
|
|
18
|
+
trial clusters: ~709 independent bets -> luck bar +1.56 /yr (reported; the gate counts every formula)
|
|
17
19
|
Reality Check p 0.942 (gate) SPA p 0.800
|
|
18
20
|
PBO 0.95 (share of splits where the in-sample best finalist lands in the bottom half)
|
|
19
21
|
|
|
@@ -21,6 +23,7 @@ $ falsealpha data planted --out planted.npz # same, plus one hidden alpha
|
|
|
21
23
|
$ falsealpha mine planted.npz runs/planted
|
|
22
24
|
6397 formulas tried -> 50 finalists -> 3 survivors
|
|
23
25
|
luck bar (train): best of 6397 unskilled strategies ~ Sharpe +1.85 /yr
|
|
26
|
+
trial clusters: ~667 independent bets -> luck bar +1.55 /yr (reported; the gate counts every formula)
|
|
24
27
|
Reality Check p 0.000 (gate) SPA p 0.000
|
|
25
28
|
PBO 0.00 (share of splits where the in-sample best finalist lands in the bottom half)
|
|
26
29
|
[PASS] train +5.87 (DSR 1.00) val +4.94 (DSR 1.00) (decay_linear(ts_sum(volume, 3), 5) * ts_sum(volume, 5))
|
|
@@ -28,7 +31,7 @@ $ falsealpha unseal runs/planted
|
|
|
28
31
|
combined: holdout Sharpe +4.08 /yr, P(true Sharpe > 0) 1.00, 498 days
|
|
29
32
|
```
|
|
30
33
|
|
|
31
|
-
Across 30 independent noise panels (1,500 days × 60 assets, population 200 × 6 generations; [`scripts/noise_calibration.py`](scripts/noise_calibration.py)), the firewall let through **0** formulas. The Reality Check gate never opened (0 of 30); the Deflated Sharpe alone would also have caught everything. Mean PBO was 0.49, a coin flip, which is what overfitting to noise looks like.
|
|
34
|
+
Across 30 independent noise panels (1,500 days × 60 assets, population 200 × 6 generations; [`scripts/noise_calibration.py`](scripts/noise_calibration.py)), the firewall let through **0** formulas. The Reality Check gate never opened (0 of 30); the Deflated Sharpe alone would also have caught everything, even counting only the trial clusters as independent bets (0 of 30 runs had a finalist reach a train DSR of 0.95 either way). Mean PBO was 0.49, a coin flip, which is what overfitting to noise looks like.
|
|
32
35
|
|
|
33
36
|
## On real data: six years of crypto
|
|
34
37
|
|
|
@@ -39,6 +42,7 @@ $ falsealpha data binance --out data/binance-top100.npz
|
|
|
39
42
|
$ falsealpha mine data/binance-top100.npz runs/crypto # 45 s on an M3 Pro
|
|
40
43
|
7376 formulas tried -> 50 finalists -> 0 survivors
|
|
41
44
|
luck bar (train): best of 7376 unskilled strategies ~ Sharpe +1.88 /yr
|
|
45
|
+
trial clusters: ~1197 independent bets -> luck bar +1.64 /yr (reported; the gate counts every formula)
|
|
42
46
|
Reality Check p 0.475 (gate) SPA p 0.185
|
|
43
47
|
PBO 0.73 (share of splits where the in-sample best finalist lands in the bottom half)
|
|
44
48
|
[fail] train +2.06 (DSR 0.64) val -1.08 (DSR 0.00) min(((quote_volume / ts_mean(ts_delta(open, 20), 5)) - high), -ts_sum(trades, 10))
|
|
@@ -46,7 +50,7 @@ $ falsealpha mine data/binance-top100.npz runs/crypto # 45 s on an M3 Pro
|
|
|
46
50
|
[fail] train +1.86 (DSR 0.48) val +1.02 (DSR 0.14) ts_sum((-trades * ts_cov(close, quote_volume, 10)), 40)
|
|
47
51
|
```
|
|
48
52
|
|
|
49
|
-
The best formula the search found earns a Sharpe of +2.06 a year over four years of training data, after costs. Shown alone, that is a backtest people trade. The firewall rejects it on the training data already: 7,376 tries at nothing would be expected to produce about +1.88 by luck, so its Deflated Sharpe is only 0.64. On the next 16 months, which the search never saw, it loses money (−1.08 a year), and so does the runner-up (−1.79). The few finalists that stay positive out of sample (+1.02 at best) are well within what luck explains over 493 days (Reality Check p = 0.48), and PBO is 0.73: picking the in-sample best finalist usually picks a below-median one.
|
|
53
|
+
The best formula the search found earns a Sharpe of +2.06 a year over four years of training data, after costs. Shown alone, that is a backtest people trade. The firewall rejects it on the training data already: 7,376 tries at nothing would be expected to produce about +1.88 by luck, so its Deflated Sharpe is only 0.64. Many of those tries are near-copies of each other; grouping them (|corr| > 0.7) leaves about 1,197 independent bets, a luck bar of +1.64, and a Deflated Sharpe of 0.80: still a fail, so the verdict does not rest on the conservative count. On the next 16 months, which the search never saw, it loses money (−1.08 a year), and so does the runner-up (−1.79). The few finalists that stay positive out of sample (+1.02 at best) are well within what luck explains over 493 days (Reality Check p = 0.48), and PBO is 0.73: picking the in-sample best finalist usually picks a below-median one.
|
|
50
54
|
|
|
51
55
|
So the honest result of this run is **no tradable alpha**, and the holdout (May 2025 to September 2026) stays sealed for a better search. That is the tool working: it is built to say "no" when the evidence is not there.
|
|
52
56
|
|
|
@@ -67,7 +71,7 @@ So the honest result of this run is **no tradable alpha**, and the holdout (May
|
|
|
67
71
|
```
|
|
68
72
|
|
|
69
73
|
1. **Search.** Genetic programming over an Alpha101-style formula language: ramped half-and-half initialisation, subtree crossover, point / subtree / hoist mutation, tournament selection, a parsimony penalty, and a novelty penalty against a hall of fame so it keeps finding *different* signals rather than near-copies (|corr| ≤ 0.7 between finalists). Fitness is the net long-short Sharpe on the train period only.
|
|
70
|
-
2. **Ledger.** Every distinct formula the search scores is written to `ledger.csv.gz` (formulas are canonicalised, so `a + b` and `b + a` count once). This is the *N* in the multiple-testing correction, not just the winners.
|
|
74
|
+
2. **Ledger.** Every distinct formula the search scores is written to `ledger.csv.gz` (formulas are canonicalised, so `a + b` and `b + a` count once). This is the *N* in the multiple-testing correction, not just the winners. Each formula is also assigned to a **trial cluster**: it joins the first cluster whose leader's train returns it matches with |corr| > 0.7, or leads a new one. The number of clusters estimates how many *independent* bets the search made.
|
|
71
75
|
3. **Firewall.** A finalist survives only if it passes all of:
|
|
72
76
|
- **Train Deflated Sharpe ≥ 0.95** ([Bailey & López de Prado 2014](https://papers.ssrn.com/abstract=2460551)): the train Sharpe must beat the expected maximum of *N* unskilled strategies, where *N* is the whole ledger.
|
|
73
77
|
- **Validation Sharpe > 0 with Deflated Sharpe ≥ 0.95**, deflated for the number of finalists scored on the unseen validation days.
|
|
@@ -75,7 +79,7 @@ So the honest result of this run is **no tradable alpha**, and the holdout (May
|
|
|
75
79
|
- **PBO** ([Bailey, Borwein, López de Prado & Zhu 2017](https://papers.ssrn.com/abstract=2326253)) is reported, computed over the finalists' train + validation returns: combinatorially symmetric cross-validation over 16 blocks (12,870 splits), the share where the in-sample best finalist lands in the bottom half out of sample.
|
|
76
80
|
4. **Holdout.** The search and validation never receive the holdout days: the panel is physically truncated before they run. `falsealpha unseal` scores the survivors and their equal-risk combination on the holdout once; a second look needs `--force`, and the report shows every look.
|
|
77
81
|
|
|
78
|
-
**One deliberate deviation from the DSR paper.** The paper sets the luck bar from the variance of Sharpe ratios *across* trials. In a search that finds something real, dozens of variants of that signal inflate that variance and the test ends up punishing the discovery (on the planted data it rejects a true signal with a Sharpe of 9). falsealpha uses the null variance, 1/(T−1): the expected best of *N* strategies with no skill. Counting every distinct formula as a trial overstates the number of independent trials, so the bar is conservative. The paper's version is still computed and stored (`sr0_train_paper`).
|
|
82
|
+
**One deliberate deviation from the DSR paper.** The paper sets the luck bar from the variance of Sharpe ratios *across* trials. In a search that finds something real, dozens of variants of that signal inflate that variance and the test ends up punishing the discovery (on the planted data it rejects a true signal with a Sharpe of 9). falsealpha uses the null variance, 1/(T−1): the expected best of *N* strategies with no skill. Counting every distinct formula as a trial overstates the number of independent trials, so the bar is conservative; the run reports how much by also deflating against the trial clusters (`n_effective`, `dsr_effective`, the "DSR vs clusters" column in the report), and the gate keeps the conservative count. The paper's version is still computed and stored (`sr0_train_paper`).
|
|
79
83
|
|
|
80
84
|
## Quick start
|
|
81
85
|
|
|
@@ -204,4 +208,12 @@ cmake -S . -B build -DFALSEALPHA_BUILD_TESTS=ON && cmake --build build -j && ./b
|
|
|
204
208
|
- C. R. Harvey, Y. Liu, H. Zhu (2016). …and the Cross-Section of Expected Returns. *Review of Financial Studies* 29(1).
|
|
205
209
|
- Z. Kakushadze (2016). 101 Formulaic Alphas. *Wilmott* 2016(84). [arXiv:1601.00991](https://arxiv.org/abs/1601.00991)
|
|
206
210
|
|
|
207
|
-
|
|
211
|
+
## Disclaimer
|
|
212
|
+
|
|
213
|
+
Research software, not investment advice. Backtested results do not predict future returns. falsealpha is not affiliated with or endorsed by Binance.
|
|
214
|
+
|
|
215
|
+
## Data and licences
|
|
216
|
+
|
|
217
|
+
`falsealpha data binance` downloads Binance's public historical data from [Binance Vision](https://data.binance.vision) to your own machine. Binance licenses that data under [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) with its own [dataset terms](https://data.binance.vision/Binance_Vision-Terms_of_Use.pdf): non-commercial use only, and anything you publish from it must credit Binance Vision and carry the same licence.
|
|
218
|
+
|
|
219
|
+
The results in this README and `docs/report.png` are derived from Binance Vision data, so they are licensed CC BY-NC-SA 4.0, with credit to Binance Vision. The code is MIT.
|
|
@@ -25,7 +25,7 @@ struct SearchConfig {
|
|
|
25
25
|
std::size_t train_begin = 0, train_end = 0; // fitness is measured on days [begin, end)
|
|
26
26
|
ScoreConfig score;
|
|
27
27
|
double parsimony = 0.0005; // per node, in per-period Sharpe units
|
|
28
|
-
double max_corr = 0.7; // above this |corr|
|
|
28
|
+
double max_corr = 0.7; // above this |corr| a formula is a near-copy (hall of fame, trial clusters)
|
|
29
29
|
int hall_size = 50;
|
|
30
30
|
std::uint64_t seed = 1;
|
|
31
31
|
int threads = 0; // 0 = all cores
|
|
@@ -33,10 +33,15 @@ struct SearchConfig {
|
|
|
33
33
|
|
|
34
34
|
// One row of the trial ledger: every distinct formula the search ever scored.
|
|
35
35
|
// The Deflated Sharpe Ratio needs all of them, not just the winners.
|
|
36
|
+
// Near-copies are not independent trials, so trials are also clustered by
|
|
37
|
+
// their train returns: a trial joins the first cluster whose leader it
|
|
38
|
+
// matches with |corr| > max_corr, or leads a new one. The number of clusters
|
|
39
|
+
// estimates the number of independent trials.
|
|
36
40
|
struct Trial {
|
|
37
41
|
std::string formula;
|
|
38
42
|
double sharpe = 0, mean_ic = 0, turnover = 0; // train window, per period; NaN if degenerate
|
|
39
43
|
int size = 0, generation = 0;
|
|
44
|
+
int cluster = -1; // -1 if degenerate
|
|
40
45
|
};
|
|
41
46
|
|
|
42
47
|
struct Finalist {
|
|
@@ -171,13 +171,15 @@ PYBIND11_MODULE(_core, m) {
|
|
|
171
171
|
py::dict ledger;
|
|
172
172
|
std::vector<std::string> formula;
|
|
173
173
|
std::vector<double> sharpe, ic, turnover;
|
|
174
|
-
std::vector<int> size, gen;
|
|
174
|
+
std::vector<int> size, gen, cluster;
|
|
175
175
|
for (const auto& t : r.ledger) {
|
|
176
176
|
formula.push_back(t.formula); sharpe.push_back(t.sharpe); ic.push_back(t.mean_ic);
|
|
177
177
|
turnover.push_back(t.turnover); size.push_back(t.size); gen.push_back(t.generation);
|
|
178
|
+
cluster.push_back(t.cluster);
|
|
178
179
|
}
|
|
179
180
|
ledger["formula"] = formula; ledger["sharpe"] = to_array(sharpe); ledger["mean_ic"] = to_array(ic);
|
|
180
181
|
ledger["turnover"] = to_array(turnover); ledger["size"] = size; ledger["generation"] = gen;
|
|
182
|
+
ledger["cluster"] = cluster;
|
|
181
183
|
py::list hall;
|
|
182
184
|
for (const auto& f : r.hall)
|
|
183
185
|
hall.append(py::dict(py::arg("formula") = f.formula, py::arg("fitness") = f.fitness,
|
|
@@ -117,8 +117,47 @@ struct Scored {
|
|
|
117
117
|
Summary train;
|
|
118
118
|
std::vector<double> net;
|
|
119
119
|
std::vector<double> hall_corr; // |corr| with each hall member as of the start of this generation
|
|
120
|
+
std::vector<float> unit; // train returns, demeaned, unit length (NaN days = mean)
|
|
121
|
+
int cluster = -1;
|
|
120
122
|
};
|
|
121
123
|
|
|
124
|
+
// Train returns as a demeaned unit vector, so a dot product is a correlation
|
|
125
|
+
// (with NaN days at the mean). Empty if the series is constant.
|
|
126
|
+
std::vector<float> unit_returns(const std::vector<double>& net, std::size_t begin, std::size_t end) {
|
|
127
|
+
double sum = 0, n = 0;
|
|
128
|
+
for (std::size_t t = begin; t < end; ++t)
|
|
129
|
+
if (std::isfinite(net[t])) { sum += net[t]; n += 1; }
|
|
130
|
+
if (n < 2) return {};
|
|
131
|
+
double mean = sum / n, ss = 0;
|
|
132
|
+
for (std::size_t t = begin; t < end; ++t)
|
|
133
|
+
if (std::isfinite(net[t])) ss += (net[t] - mean) * (net[t] - mean);
|
|
134
|
+
if (!(ss > 0)) return {};
|
|
135
|
+
double scale = 1 / std::sqrt(ss);
|
|
136
|
+
std::vector<float> u(end - begin, 0.0f);
|
|
137
|
+
for (std::size_t t = begin; t < end; ++t)
|
|
138
|
+
if (std::isfinite(net[t])) u[t - begin] = static_cast<float>((net[t] - mean) * scale);
|
|
139
|
+
return u;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
float dot(const std::vector<float>& a, const std::vector<float>& b) {
|
|
143
|
+
// Eight running sums, so the compiler can vectorise without reassociating.
|
|
144
|
+
float acc[8] = {};
|
|
145
|
+
std::size_t n = a.size(), i = 0;
|
|
146
|
+
for (; i + 8 <= n; i += 8)
|
|
147
|
+
for (int j = 0; j < 8; ++j) acc[j] += a[i + j] * b[i + j];
|
|
148
|
+
float s = ((acc[0] + acc[1]) + (acc[2] + acc[3])) + ((acc[4] + acc[5]) + (acc[6] + acc[7]));
|
|
149
|
+
for (; i < n; ++i) s += a[i] * b[i];
|
|
150
|
+
return s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// First leader in [from, to) that u matches with |corr| > threshold, or -1.
|
|
154
|
+
int find_cluster(const std::vector<float>& u, const std::vector<std::vector<float>>& leaders, std::size_t from,
|
|
155
|
+
std::size_t to, double threshold) {
|
|
156
|
+
for (std::size_t i = from; i < to; ++i)
|
|
157
|
+
if (std::fabs(dot(u, leaders[i])) > threshold) return static_cast<int>(i);
|
|
158
|
+
return -1;
|
|
159
|
+
}
|
|
160
|
+
|
|
122
161
|
} // namespace
|
|
123
162
|
|
|
124
163
|
double series_corr(const std::vector<double>& a, const std::vector<double>& b, std::size_t begin, std::size_t end) {
|
|
@@ -156,6 +195,7 @@ SearchResult search(const Data& d, const Mat& returns, const SearchConfig& c, co
|
|
|
156
195
|
Builder build(c, rng);
|
|
157
196
|
SearchResult result;
|
|
158
197
|
std::unordered_map<std::string, double> fitness_cache; // formula -> penalised fitness
|
|
198
|
+
std::vector<std::vector<float>> leaders; // one per trial cluster
|
|
159
199
|
|
|
160
200
|
auto fits = [&](const Node& n) {
|
|
161
201
|
return n.size() <= static_cast<std::size_t>(c.max_size) && n.depth() <= static_cast<std::size_t>(c.max_depth);
|
|
@@ -183,6 +223,7 @@ SearchResult search(const Data& d, const Mat& returns, const SearchConfig& c, co
|
|
|
183
223
|
}
|
|
184
224
|
std::vector<Scored> scored(todo.size());
|
|
185
225
|
const auto& hall = result.hall;
|
|
226
|
+
std::size_t known_leaders = leaders.size(); // read-only while the workers run
|
|
186
227
|
parallel_for(todo.size(), c.threads, [&](std::size_t j) {
|
|
187
228
|
const Node& n = pop[todo[j]];
|
|
188
229
|
Scored& s = scored[j];
|
|
@@ -191,6 +232,8 @@ SearchResult search(const Data& d, const Mat& returns, const SearchConfig& c, co
|
|
|
191
232
|
s.train = summarize(b, c.train_begin, c.train_end);
|
|
192
233
|
if (s.train.n < 2 || s.train.sd <= 0) return; // degenerate: never trades
|
|
193
234
|
s.raw = s.train.sharpe - c.parsimony * static_cast<double>(n.size());
|
|
235
|
+
s.unit = unit_returns(b.net, c.train_begin, c.train_end);
|
|
236
|
+
if (!s.unit.empty()) s.cluster = find_cluster(s.unit, leaders, 0, known_leaders, c.max_corr);
|
|
194
237
|
s.fitness = s.raw;
|
|
195
238
|
double worst = 0;
|
|
196
239
|
s.hall_corr.resize(hall.size());
|
|
@@ -217,6 +260,14 @@ SearchResult search(const Data& d, const Mat& returns, const SearchConfig& c, co
|
|
|
217
260
|
tr.sharpe = ok ? s.train.sharpe : NaN;
|
|
218
261
|
tr.mean_ic = ok ? s.train.mean_ic : NaN;
|
|
219
262
|
tr.turnover = ok ? s.train.turnover : NaN;
|
|
263
|
+
if (ok && !s.unit.empty()) {
|
|
264
|
+
if (s.cluster < 0) s.cluster = find_cluster(s.unit, leaders, known_leaders, leaders.size(), c.max_corr);
|
|
265
|
+
if (s.cluster < 0) {
|
|
266
|
+
s.cluster = static_cast<int>(leaders.size());
|
|
267
|
+
leaders.push_back(std::move(s.unit));
|
|
268
|
+
}
|
|
269
|
+
tr.cluster = s.cluster;
|
|
270
|
+
}
|
|
220
271
|
result.ledger.push_back(tr);
|
|
221
272
|
fitness_cache[s.formula] = s.fitness;
|
|
222
273
|
if (!ok) continue;
|
|
@@ -246,32 +246,46 @@ Mat apply_series(Op op, const Mat& a, const Mat* b, int d) {
|
|
|
246
246
|
}
|
|
247
247
|
if (op == Op::TsMax || op == Op::TsMin) return sliding_extreme(a, d, op == Op::TsMax);
|
|
248
248
|
|
|
249
|
-
if (op == Op::TsArgmax || op == Op::TsArgmin
|
|
250
|
-
//
|
|
249
|
+
if (op == Op::TsArgmax || op == Op::TsArgmin) {
|
|
250
|
+
// Days since the extreme (0 = today); ties go to the most recent.
|
|
251
|
+
// O(d) per cell, a whole row per step (vectorised across assets).
|
|
252
|
+
Rolling nan_count(a, d);
|
|
253
|
+
std::vector<double> best(N), lag(N);
|
|
254
|
+
bool want_max = op == Op::TsArgmax;
|
|
255
|
+
for (std::size_t t = 0; t < T; ++t) {
|
|
256
|
+
nan_count.step(t);
|
|
257
|
+
if (t + 1 < D) continue;
|
|
258
|
+
std::copy(a.row(t), a.row(t) + N, best.begin());
|
|
259
|
+
std::fill(lag.begin(), lag.end(), 0.0);
|
|
260
|
+
for (std::size_t k = 1; k < D; ++k) {
|
|
261
|
+
const double* v = a.row(t - k);
|
|
262
|
+
for (std::size_t n = 0; n < N; ++n) {
|
|
263
|
+
bool better = want_max ? v[n] > best[n] : v[n] < best[n];
|
|
264
|
+
best[n] = better ? v[n] : best[n];
|
|
265
|
+
lag[n] = better ? static_cast<double>(k) : lag[n];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (std::size_t n = 0; n < N; ++n)
|
|
269
|
+
if (!nan_count.bad[n]) out(t, n) = lag[n];
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (op == Op::TsRank) {
|
|
275
|
+
// O(d) per cell; windows are short.
|
|
251
276
|
Rolling nan_count(a, d);
|
|
252
277
|
for (std::size_t t = 0; t < T; ++t) {
|
|
253
278
|
nan_count.step(t);
|
|
254
279
|
if (t + 1 < D) continue;
|
|
255
280
|
for (std::size_t n = 0; n < N; ++n) {
|
|
256
281
|
if (nan_count.bad[n]) continue;
|
|
257
|
-
double cur = a(t, n);
|
|
258
|
-
|
|
259
|
-
double
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
below += v < cur;
|
|
263
|
-
equal += v == cur;
|
|
264
|
-
}
|
|
265
|
-
out(t, n) = (below + 0.5 * (equal - 1)) / (d - 1);
|
|
266
|
-
} else {
|
|
267
|
-
// Days since the extreme (0 = today); ties go to the most recent.
|
|
268
|
-
std::size_t best = 0;
|
|
269
|
-
for (std::size_t k = 1; k < D; ++k) {
|
|
270
|
-
double v = a(t - k, n), bv = a(t - best, n);
|
|
271
|
-
if (op == Op::TsArgmax ? v > bv : v < bv) best = k;
|
|
272
|
-
}
|
|
273
|
-
out(t, n) = static_cast<double>(best);
|
|
282
|
+
double cur = a(t, n), below = 0, equal = 0;
|
|
283
|
+
for (std::size_t k = 0; k < D; ++k) {
|
|
284
|
+
double v = a(t - k, n);
|
|
285
|
+
below += v < cur;
|
|
286
|
+
equal += v == cur;
|
|
274
287
|
}
|
|
288
|
+
out(t, n) = (below + 0.5 * (equal - 1)) / (d - 1);
|
|
275
289
|
}
|
|
276
290
|
}
|
|
277
291
|
return out;
|
|
@@ -46,7 +46,7 @@ Backtest backtest(const Mat& signal, const Mat& returns, const Data& d, const Sc
|
|
|
46
46
|
if (members.size() >= static_cast<std::size_t>(c.min_assets)) {
|
|
47
47
|
sig.clear();
|
|
48
48
|
for (auto n : members) sig.push_back(s[n]);
|
|
49
|
-
std::vector<double> rk = average_ranks(sig);
|
|
49
|
+
std::vector<double> rk = average_ranks(sig), sig_rk = rk;
|
|
50
50
|
double mean = std::accumulate(rk.begin(), rk.end(), 0.0) / rk.size();
|
|
51
51
|
double gross = 0;
|
|
52
52
|
for (auto& x : rk) { x -= mean; gross += std::fabs(x); }
|
|
@@ -56,7 +56,9 @@ Backtest backtest(const Mat& signal, const Mat& returns, const Data& d, const Sc
|
|
|
56
56
|
sig.clear(); tgt.clear();
|
|
57
57
|
for (auto n : members)
|
|
58
58
|
if (std::isfinite(r[n])) { sig.push_back(s[n]); tgt.push_back(r[n]); }
|
|
59
|
-
|
|
59
|
+
// Usually every member has a target, and the signal ranks are the ones just computed.
|
|
60
|
+
if (sig.size() < members.size()) sig_rk = average_ranks(sig);
|
|
61
|
+
if (sig.size() >= static_cast<std::size_t>(c.min_assets)) b.ic[t] = pearson(sig_rk, average_ranks(tgt));
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
double pnl = 0, turn = 0;
|
|
@@ -117,6 +117,39 @@ TEST_CASE("rolling state survives long series (drift refresh)") {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
TEST_CASE("moments stay exact in a quiet window after a volatile history") {
|
|
121
|
+
// The rolling sums behind the window means carry rounding drift from the
|
|
122
|
+
// huge earlier values; the corrected two-pass step must cancel it.
|
|
123
|
+
const std::size_t T = 400, N = 2, quiet = 300;
|
|
124
|
+
Data d;
|
|
125
|
+
d.T = T; d.N = N;
|
|
126
|
+
d.universe.assign(T * N, 1);
|
|
127
|
+
Mat c(T, N), v(T, N);
|
|
128
|
+
std::mt19937 rng(5);
|
|
129
|
+
std::normal_distribution<double> g(0.0, 1.0);
|
|
130
|
+
for (std::size_t t = 0; t < T; ++t) {
|
|
131
|
+
bool calm = t >= quiet;
|
|
132
|
+
c(t, 0) = calm ? 1.0 : 1e9 * (1 + 0.3 * g(rng)); // then constant (a redenomination)
|
|
133
|
+
c(t, 1) = calm ? 1.0 + 1e-7 * g(rng) : 1e9 * (1 + 0.3 * g(rng)); // then barely moving
|
|
134
|
+
v(t, 0) = v(t, 1) = 1 + g(rng);
|
|
135
|
+
}
|
|
136
|
+
d.fields["close"] = c;
|
|
137
|
+
d.fields["volume"] = v;
|
|
138
|
+
Mat sd = evaluate(parse("ts_std(close, 20)"), d);
|
|
139
|
+
Mat z = evaluate(parse("ts_zscore(close, 20)"), d);
|
|
140
|
+
Mat cr = evaluate(parse("ts_corr(close, volume, 20)"), d);
|
|
141
|
+
for (std::size_t t = quiet + 19; t < T; ++t) {
|
|
142
|
+
REQUIRE(sd(t, 0) == 0.0);
|
|
143
|
+
REQUIRE(std::isnan(z(t, 0)));
|
|
144
|
+
REQUIRE(std::isnan(cr(t, 0)));
|
|
145
|
+
double m = 0, ss = 0;
|
|
146
|
+
for (std::size_t k = 0; k < 20; ++k) m += c(t - k, 1);
|
|
147
|
+
m /= 20;
|
|
148
|
+
for (std::size_t k = 0; k < 20; ++k) ss += (c(t - k, 1) - m) * (c(t - k, 1) - m);
|
|
149
|
+
REQUIRE(sd(t, 1) == Approx(std::sqrt(ss / 19)).epsilon(1e-9));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
120
153
|
TEST_CASE("unknown fields are reported") {
|
|
121
154
|
Data d = test::random_data(10, 3, 1);
|
|
122
155
|
REQUIRE_THROWS_AS(evaluate(parse("rank(vwap)"), d), std::invalid_argument);
|
|
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "falsealpha"
|
|
7
|
-
version = "0.1
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "A C++ alpha-mining engine that searches formulaic trading signals, then tells you which of its discoveries are fake."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""How often does the firewall pass something on pure noise?
|
|
2
2
|
|
|
3
3
|
Mines 30 independent noise panels (1,500 days x 60 assets, population 200 x 6
|
|
4
|
-
generations, 10 bps costs) and counts survivors
|
|
5
|
-
rejections
|
|
4
|
+
generations, 10 bps costs) and counts survivors, SPA / Reality Check
|
|
5
|
+
rejections, and runs where a finalist passes the train Deflated Sharpe when
|
|
6
|
+
trials are counted as every formula or as clusters. Usage: python scripts/noise_calibration.py [workdir]
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
import sys
|
|
@@ -15,6 +16,7 @@ from falsealpha import run, synthetic
|
|
|
15
16
|
|
|
16
17
|
work = Path(sys.argv[1] if len(sys.argv) > 1 else tempfile.mkdtemp())
|
|
17
18
|
spa, rc, pbo, survivors = [], [], [], 0
|
|
19
|
+
dsr_all, dsr_eff, share = 0, 0, []
|
|
18
20
|
for s in range(30):
|
|
19
21
|
synthetic.noise(T=1500, N=60, seed=100 + s).save(work / f"noise{s}.npz")
|
|
20
22
|
v = run.mine(work / f"noise{s}.npz", work / f"run{s}", run.MineConfig(population=200, generations=6, seed=s))
|
|
@@ -22,6 +24,11 @@ for s in range(30):
|
|
|
22
24
|
rc.append(v["snoop"]["reality_check_p"])
|
|
23
25
|
pbo.append(v["pbo"]["pbo"])
|
|
24
26
|
survivors += len(v["survivors"])
|
|
27
|
+
dsr_all += any(f["train"]["dsr"] >= 0.95 for f in v["finalists"])
|
|
28
|
+
dsr_eff += any(f["train"]["dsr_effective"] >= 0.95 for f in v["finalists"])
|
|
29
|
+
share.append(v["n_effective"] / v["n_trials"])
|
|
25
30
|
print(f"survivors: {survivors}")
|
|
26
31
|
print(f"SPA p < 0.05: {sum(p < 0.05 for p in spa)}/30 Reality Check p < 0.05: {sum(p < 0.05 for p in rc)}/30")
|
|
27
32
|
print(f"mean PBO: {np.mean(pbo):.2f}")
|
|
33
|
+
print(f"train DSR >= 0.95 on some finalist: {dsr_all}/30 counting every formula, {dsr_eff}/30 counting clusters "
|
|
34
|
+
f"(clusters = {np.mean(share):.0%} of formulas)")
|
|
@@ -89,6 +89,9 @@ def cmd_mine(a: argparse.Namespace) -> None:
|
|
|
89
89
|
def _print_verdict(v: dict, ppy: float) -> None:
|
|
90
90
|
print(f"\n{v['n_trials']} formulas tried -> {v['n_finalists']} finalists -> {len(v['survivors'])} survivors")
|
|
91
91
|
print(f" luck bar (train): best of {v['n_trials']} unskilled strategies ~ Sharpe {_ann(v['sr0_train'], ppy)} /yr")
|
|
92
|
+
if "n_effective" in v:
|
|
93
|
+
print(f" trial clusters: ~{v['n_effective']} independent bets -> luck bar {_ann(v['sr0_train_effective'], ppy)} /yr"
|
|
94
|
+
" (reported; the gate counts every formula)")
|
|
92
95
|
if v["snoop"]:
|
|
93
96
|
print(f" Reality Check p {v['snoop']['reality_check_p']:.3f} (gate) SPA p {v['snoop']['spa_p']:.3f}")
|
|
94
97
|
if v["pbo"]:
|
|
@@ -12,6 +12,10 @@ and validation and holdout are scored after the fact. Tests, in order:
|
|
|
12
12
|
and the test ends up punishing the discovery. We report it as a diagnostic.
|
|
13
13
|
Counting every distinct formula as a trial overstates the number of
|
|
14
14
|
independent trials (near-copies are correlated), so the test is conservative.
|
|
15
|
+
The search also clusters its trials by train returns (|corr| above the
|
|
16
|
+
near-copy threshold joins a cluster); the cluster count estimates the
|
|
17
|
+
effective number of independent trials, and the DSR against it is reported
|
|
18
|
+
next to the gate, to show how much the conservative count matters.
|
|
15
19
|
2. Validation Deflated Sharpe. The finalists are fresh trials on unseen days,
|
|
16
20
|
deflated for the number of finalists.
|
|
17
21
|
3. White's Reality Check on validation: is the best finalist better than flat
|
|
@@ -56,15 +60,22 @@ def deflated(net: np.ndarray, n_trials: float) -> dict:
|
|
|
56
60
|
return {"sharpe": sr, "sr0": sr0, "dsr": dsr, "n": m["n"], "skew": m["skew"], "kurt": m["kurt"]}
|
|
57
61
|
|
|
58
62
|
|
|
59
|
-
def run(ledger_sharpe: np.ndarray, nets: np.ndarray, train: slice, val: slice,
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
def run(ledger_sharpe: np.ndarray, ledger_cluster: np.ndarray, nets: np.ndarray, train: slice, val: slice,
|
|
64
|
+
t: Thresholds) -> dict:
|
|
65
|
+
"""nets: finalists x days (pre-holdout panel), NaN where a day has no position or target.
|
|
66
|
+
ledger_cluster: each trial's cluster id (-1 if degenerate)."""
|
|
67
|
+
ok = np.isfinite(ledger_sharpe)
|
|
68
|
+
trials = ledger_sharpe[ok]
|
|
62
69
|
n_trials = len(trials)
|
|
70
|
+
n_effective = len(np.unique(ledger_cluster[ok & (ledger_cluster >= 0)]))
|
|
63
71
|
var_trials = float(np.var(trials, ddof=1)) if n_trials > 1 else 0.0
|
|
64
72
|
K = len(nets)
|
|
65
73
|
|
|
66
|
-
rows = [
|
|
67
|
-
|
|
74
|
+
rows = []
|
|
75
|
+
for k in range(K):
|
|
76
|
+
tr = deflated(nets[k, train], n_trials)
|
|
77
|
+
tr["dsr_effective"] = deflated(nets[k, train], n_effective)["dsr"]
|
|
78
|
+
rows.append({"train": tr, "validation": deflated(nets[k, val], K)})
|
|
68
79
|
T_train = train.stop - train.start
|
|
69
80
|
|
|
70
81
|
out = {
|
|
@@ -73,6 +84,8 @@ def run(ledger_sharpe: np.ndarray, nets: np.ndarray, train: slice, val: slice, t
|
|
|
73
84
|
"null_sharpe_sd": 1 / math.sqrt(T_train - 1),
|
|
74
85
|
"sr0_train": _core.expected_max_sharpe(1 / (T_train - 1), n_trials),
|
|
75
86
|
"sr0_train_paper": _core.expected_max_sharpe(var_trials, n_trials),
|
|
87
|
+
"n_effective": n_effective,
|
|
88
|
+
"sr0_train_effective": _core.expected_max_sharpe(1 / (T_train - 1), n_effective),
|
|
76
89
|
"n_finalists": K,
|
|
77
90
|
"finalists": rows,
|
|
78
91
|
"snoop": None,
|
|
@@ -92,7 +92,8 @@ def build(run_dir: str | Path) -> str:
|
|
|
92
92
|
"split": {"val": cfg["index"]["val_start"], "hold": hold_start},
|
|
93
93
|
"curves": [{"name": c["name"], "values": c["values"]} for c in curves],
|
|
94
94
|
"hist": {"counts": hist_counts.tolist(), "edges": edges.tolist(),
|
|
95
|
-
"luck": _finite(yearly(v["sr0_train"]))
|
|
95
|
+
"luck": _finite(yearly(v["sr0_train"])),
|
|
96
|
+
"luck_effective": _finite(yearly(v["sr0_train_effective"])) if "n_effective" in v else None},
|
|
96
97
|
"pbo": v["pbo"]["logits"] if v["pbo"] else [],
|
|
97
98
|
"progress": progress,
|
|
98
99
|
}
|
|
@@ -100,6 +101,11 @@ def build(run_dir: str | Path) -> str:
|
|
|
100
101
|
looks = holdout["looks"]
|
|
101
102
|
tiles = [
|
|
102
103
|
(f"{v['n_trials']:,}", "formulas tried", "every distinct formula counts against the winners"),
|
|
104
|
+
]
|
|
105
|
+
if "n_effective" in v:
|
|
106
|
+
tiles.append((f"≈{v['n_effective']:,}", "independent bets", "trial clusters (|corr| > "
|
|
107
|
+
f"{cfg['search']['max_corr']}); shown for scale, the tests count every formula"))
|
|
108
|
+
tiles += [
|
|
103
109
|
(str(v["n_finalists"]), "finalists", "diverse best of the search (|corr| ≤ "
|
|
104
110
|
f"{cfg['search']['max_corr']})"),
|
|
105
111
|
(str(len(survivors)), "survivors", "passed every test below"),
|
|
@@ -119,6 +125,7 @@ def build(run_dir: str | Path) -> str:
|
|
|
119
125
|
else f'<span class="st bad" title="{html.escape("; ".join(f["reasons"]))}">✕ fail</span>')
|
|
120
126
|
return (f"<tr><td class=num>{i + 1}</td><td class=formula><code>{html.escape(f['formula'])}</code></td>"
|
|
121
127
|
f"<td class=num>{_fmt(yearly(tr['sharpe']), '+.2f')}</td><td class=num>{_fmt(tr['dsr'], '.2f')}</td>"
|
|
128
|
+
f"<td class=num>{_fmt(tr.get('dsr_effective'), '.2f')}</td>"
|
|
122
129
|
f"<td class=num>{_fmt(yearly(va['sharpe']), '+.2f')}</td><td class=num>{_fmt(va['dsr'], '.2f')}</td>"
|
|
123
130
|
f"<td>{status}</td><td class=why>{html.escape('; '.join(f['reasons']))}</td></tr>")
|
|
124
131
|
|
|
@@ -153,6 +160,8 @@ def build(run_dir: str | Path) -> str:
|
|
|
153
160
|
.replace("%LOOKS%", look_html)
|
|
154
161
|
.replace("%LUCK%", _fmt(yearly(v["sr0_train"]), "+.2f"))
|
|
155
162
|
.replace("%NTRIALS%", f"{v['n_trials']:,}")
|
|
163
|
+
.replace("%LUCKEFF%", f"; counting only the ≈{v['n_effective']:,} trial clusters it is "
|
|
164
|
+
f"{_fmt(yearly(v['sr0_train_effective']), '+.2f')}/yr" if "n_effective" in v else "")
|
|
156
165
|
.replace("%DATA%", json.dumps(payload, allow_nan=False, default=_finite)
|
|
157
166
|
.replace("</", "<\\/")))
|
|
158
167
|
|
|
@@ -213,7 +222,7 @@ details{margin-top:8px;color:var(--ink2);font-size:13.5px}
|
|
|
213
222
|
<div class=grid2>
|
|
214
223
|
<div><h2>Trial Sharpe ratios vs the luck bar</h2>
|
|
215
224
|
<p class=muted>Train Sharpe of all %NTRIALS% trials, annualised. The line is the best Sharpe expected from that many
|
|
216
|
-
<em>unskilled</em> strategies (%LUCK%/yr)
|
|
225
|
+
<em>unskilled</em> strategies (%LUCK%/yr)%LUCKEFF%.</p><div class=card id=hist></div></div>
|
|
217
226
|
<div><h2>PBO: in-sample winner's out-of-sample rank</h2>
|
|
218
227
|
<p class=muted>One logit per train/validation split (CSCV). Left of 0 the in-sample winner lands in the bottom half
|
|
219
228
|
out of sample; the share there is the PBO.</p><div class=card id=pbo></div></div>
|
|
@@ -227,7 +236,7 @@ out of sample; the share there is the PBO.</p><div class=card id=pbo></div></div
|
|
|
227
236
|
<p class=muted>Pass = train DSR ≥ 0.95 against all trials, validation Sharpe > 0 with DSR ≥ 0.95 against the
|
|
228
237
|
finalists, and the Reality Check gate open. Sharpe ratios annualised.</p>
|
|
229
238
|
<div class="card tablewrap"><table><thead><tr><th class=num>#</th><th>formula</th><th class=num>train Sharpe</th>
|
|
230
|
-
<th class=num>train DSR</th><th class=num>val Sharpe</th><th class=num>val DSR</th><th>verdict</th><th>why</th></tr>
|
|
239
|
+
<th class=num>train DSR</th><th class=num title="train DSR against the number of trial clusters instead of every formula">DSR vs clusters</th><th class=num>val Sharpe</th><th class=num>val DSR</th><th>verdict</th><th>why</th></tr>
|
|
231
240
|
</thead><tbody>%TABLE%</tbody></table></div>
|
|
232
241
|
|
|
233
242
|
<h2>Holdout</h2>
|
|
@@ -272,9 +281,9 @@ function bars(id,counts,edges,refs,fmt){const host=document.getElementById(id);
|
|
|
272
281
|
hit.addEventListener("pointerleave",()=>f.tip.style.display="none")});
|
|
273
282
|
el("line",{x1:f.m.l,x2:f.W-f.m.r,y1:y(0),y2:y(0),stroke:css("--axis")},f.svg);
|
|
274
283
|
for(const v of ticks(lo,hi,6)){const t=el("text",{x:x(v),y:f.H-6,"text-anchor":"middle"},f.svg);t.textContent=fmt(v)}
|
|
275
|
-
|
|
276
|
-
el("line",{x1:x(r.at),x2:x(r.at),y1:f.m.t,y2:y(0),stroke:css("--ink"),"stroke-width":1.5,"stroke-dasharray":"4 3"},f.svg);
|
|
277
|
-
const t=el("text",{x:x(r.at)+5,y:f.m.t+10},f.svg);t.textContent=r.label;t.style.fill=css("--ink2")}}
|
|
284
|
+
refs.forEach((r,i)=>{if(r.at==null||r.at<lo||r.at>hi)return;
|
|
285
|
+
el("line",{x1:x(r.at),x2:x(r.at),y1:f.m.t,y2:y(0),stroke:css(i?"--ink2":"--ink"),"stroke-width":1.5,"stroke-dasharray":i?"2 3":"4 3"},f.svg);
|
|
286
|
+
const t=el("text",{x:x(r.at)+5,y:f.m.t+10+14*i},f.svg);t.textContent=r.label;t.style.fill=css("--ink2")})}
|
|
278
287
|
|
|
279
288
|
function lines(id,xs,series,opts){const host=document.getElementById(id);
|
|
280
289
|
if(!series.length){host.textContent="No finalists.";return}
|
|
@@ -308,7 +317,7 @@ function draw(){for(const id of ["eq","hist","pbo","prog"])document.getElementBy
|
|
|
308
317
|
const pct=v=>(v*100).toFixed(0)+"%";
|
|
309
318
|
lines("eq",D.dates,D.curves,{fmt:pct,xfmt:s=>s.slice(0,7),zero:true,
|
|
310
319
|
bands:[{from:D.split.val,to:D.split.hold,label:"validation"},{from:D.split.hold,to:D.dates.length-1,label:"holdout"}]});
|
|
311
|
-
bars("hist",D.hist.counts,D.hist.edges,[{at:D.hist.luck,label:"luck bar"}],v=>v.toFixed(1));
|
|
320
|
+
bars("hist",D.hist.counts,D.hist.edges,[{at:D.hist.luck,label:"luck bar"},{at:D.hist.luck_effective,label:"clusters"}],v=>v.toFixed(1));
|
|
312
321
|
if(D.pbo.length){const lo=Math.min(...D.pbo,-0.5),hi=Math.max(...D.pbo,0.5),k=24,e=[...Array(k+1)].map((_,i)=>lo+(hi-lo)*i/k),
|
|
313
322
|
c=Array(k).fill(0);for(const v of D.pbo)c[Math.min(k-1,Math.floor((v-lo)/(hi-lo)*k))]++;
|
|
314
323
|
bars("pbo",c,e,[{at:0,label:"0"}],v=>v.toFixed(1))}else document.getElementById("pbo").textContent="Not enough finalists.";
|
|
@@ -99,7 +99,8 @@ def mine(panel_path: str | Path, run_dir: str | Path, cfg: MineConfig, splits: S
|
|
|
99
99
|
|
|
100
100
|
hall = result["hall"]
|
|
101
101
|
nets = np.array([h["net"] for h in hall]) if hall else np.zeros((0, hold_start))
|
|
102
|
-
verdict = firewall.run(result["ledger"]["sharpe"], nets, train, val,
|
|
102
|
+
verdict = firewall.run(result["ledger"]["sharpe"], np.asarray(result["ledger"]["cluster"]), nets, train, val,
|
|
103
|
+
thresholds)
|
|
103
104
|
|
|
104
105
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
105
106
|
config = {
|
|
@@ -127,9 +128,9 @@ def mine(panel_path: str | Path, run_dir: str | Path, cfg: MineConfig, splits: S
|
|
|
127
128
|
ledger = result["ledger"]
|
|
128
129
|
with gzip.open(run_dir / "ledger.csv.gz", "wt", newline="") as f:
|
|
129
130
|
w = csv.writer(f)
|
|
130
|
-
w.writerow(["formula", "train_sharpe", "train_mean_ic", "train_turnover", "size", "generation"])
|
|
131
|
+
w.writerow(["formula", "train_sharpe", "train_mean_ic", "train_turnover", "size", "generation", "cluster"])
|
|
131
132
|
for row in zip(ledger["formula"], ledger["sharpe"], ledger["mean_ic"], ledger["turnover"],
|
|
132
|
-
ledger["size"], ledger["generation"]):
|
|
133
|
+
ledger["size"], ledger["generation"], ledger["cluster"]):
|
|
133
134
|
w.writerow(row)
|
|
134
135
|
|
|
135
136
|
finalists = []
|
|
@@ -105,3 +105,47 @@ def test_report_uses_the_panels_own_dates(tmp_path):
|
|
|
105
105
|
run.mine(tmp_path / "b.npz", tmp_path / "run", run.MineConfig(population=40, generations=2, threads=2))
|
|
106
106
|
html = report.build(tmp_path / "run")
|
|
107
107
|
assert str(bdays[-1]) in html and str(np.datetime64("2015-01-01") + 899) not in html
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _unit(net: np.ndarray) -> np.ndarray:
|
|
111
|
+
"""Train returns as the search clusters them: demeaned, NaN days at the mean, unit length."""
|
|
112
|
+
z = np.where(np.isfinite(net), net - np.nanmean(net), 0.0)
|
|
113
|
+
return z / np.linalg.norm(z)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_trials_are_clustered_into_independent_bets(planted_run):
|
|
117
|
+
import csv
|
|
118
|
+
import gzip
|
|
119
|
+
|
|
120
|
+
from falsealpha import _core
|
|
121
|
+
from falsealpha.panel import Panel
|
|
122
|
+
|
|
123
|
+
d, v = planted_run
|
|
124
|
+
with gzip.open(d / "run" / "ledger.csv.gz", "rt") as f:
|
|
125
|
+
rows = list(csv.DictReader(f))
|
|
126
|
+
ok = [r for r in rows if r["train_sharpe"] not in ("nan", "")]
|
|
127
|
+
clusters = [int(r["cluster"]) for r in ok]
|
|
128
|
+
assert sorted(set(clusters)) == list(range(len(set(clusters)))) # ids 0..K-1, in order of discovery
|
|
129
|
+
assert all(int(r["cluster"]) == -1 for r in rows if r not in ok)
|
|
130
|
+
assert v["n_effective"] == len(set(clusters)) < v["n_trials"] == len(ok)
|
|
131
|
+
assert v["sr0_train_effective"] < v["sr0_train"]
|
|
132
|
+
for f in v["finalists"]:
|
|
133
|
+
assert f["train"]["dsr_effective"] >= f["train"]["dsr"]
|
|
134
|
+
|
|
135
|
+
# Every member matches its leader (the cluster's first trial); leaders don't match each other.
|
|
136
|
+
cfg = json.loads((d / "run" / "config.json").read_text())
|
|
137
|
+
train = slice(*cfg["index"]["train"])
|
|
138
|
+
panel = Panel.load(d / "p.npz").head(cfg["index"]["hold_start"])
|
|
139
|
+
leader = {}
|
|
140
|
+
for r in ok:
|
|
141
|
+
leader.setdefault(int(r["cluster"]), r["formula"])
|
|
142
|
+
members = [r["formula"] for r in ok if leader[int(r["cluster"])] != r["formula"]][:40]
|
|
143
|
+
firsts = list(leader.values())[:40]
|
|
144
|
+
nets = {b_f: _unit(np.asarray(b["net"])[train]) for b_f, b in zip(
|
|
145
|
+
members + firsts, _core.backtest_many(members + firsts, "returns", panel.core(), delay=1, cost_bps=0))}
|
|
146
|
+
by_formula = {r["formula"]: int(r["cluster"]) for r in ok}
|
|
147
|
+
for m in members:
|
|
148
|
+
assert abs(nets[m] @ nets[leader[by_formula[m]]]) > 0.7 - 1e-4
|
|
149
|
+
for i, a in enumerate(firsts):
|
|
150
|
+
for b in firsts[:i]:
|
|
151
|
+
assert abs(nets[a] @ nets[b]) <= 0.7 + 1e-4
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|