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.
Files changed (49) hide show
  1. {falsealpha-0.1.0 → falsealpha-0.2.1}/PKG-INFO +18 -6
  2. {falsealpha-0.1.0 → falsealpha-0.2.1}/README.md +17 -5
  3. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/gp.hpp +6 -1
  4. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/bindings.cpp +3 -1
  5. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/gp.cpp +51 -0
  6. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/ops.cpp +33 -19
  7. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/score.cpp +4 -2
  8. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/tests/test_ops.cpp +33 -0
  9. {falsealpha-0.1.0 → falsealpha-0.2.1}/pyproject.toml +1 -1
  10. {falsealpha-0.1.0 → falsealpha-0.2.1}/scripts/noise_calibration.py +9 -2
  11. {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/cli.py +3 -0
  12. {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/firewall.py +18 -5
  13. {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/report.py +16 -7
  14. {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/run.py +4 -3
  15. {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/test_firewall.py +44 -0
  16. {falsealpha-0.1.0 → falsealpha-0.2.1}/.github/workflows/ci.yml +0 -0
  17. {falsealpha-0.1.0 → falsealpha-0.2.1}/.github/workflows/publish.yml +0 -0
  18. {falsealpha-0.1.0 → falsealpha-0.2.1}/.gitignore +0 -0
  19. {falsealpha-0.1.0 → falsealpha-0.2.1}/CMakeLists.txt +0 -0
  20. {falsealpha-0.1.0 → falsealpha-0.2.1}/LICENSE +0 -0
  21. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/eval.hpp +0 -0
  22. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/expr.hpp +0 -0
  23. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/mat.hpp +0 -0
  24. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/ops.hpp +0 -0
  25. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/score.hpp +0 -0
  26. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/include/falsealpha/stats.hpp +0 -0
  27. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/eval.cpp +0 -0
  28. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/expr.cpp +0 -0
  29. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/src/stats.cpp +0 -0
  30. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/tests/helpers.hpp +0 -0
  31. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/tests/test_expr.cpp +0 -0
  32. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/tests/test_gp.cpp +0 -0
  33. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/tests/test_score.cpp +0 -0
  34. {falsealpha-0.1.0 → falsealpha-0.2.1}/cpp/tests/test_stats.cpp +0 -0
  35. {falsealpha-0.1.0 → falsealpha-0.2.1}/docs/demo.gif +0 -0
  36. {falsealpha-0.1.0 → falsealpha-0.2.1}/docs/demo.tape +0 -0
  37. {falsealpha-0.1.0 → falsealpha-0.2.1}/docs/report.png +0 -0
  38. {falsealpha-0.1.0 → falsealpha-0.2.1}/scripts/snoop_size.py +0 -0
  39. {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/__init__.py +0 -0
  40. {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/bench.py +0 -0
  41. {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/binance.py +0 -0
  42. {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/panel.py +0 -0
  43. {falsealpha-0.1.0 → falsealpha-0.2.1}/src/falsealpha/synthetic.py +0 -0
  44. {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/conftest.py +0 -0
  45. {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/reference.py +0 -0
  46. {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/test_bench.py +0 -0
  47. {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/test_binance.py +0 -0
  48. {falsealpha-0.1.0 → falsealpha-0.2.1}/tests/test_cli.py +0 -0
  49. {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.0
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
  [![CI](https://github.com/owen-alderson/falsealpha/actions/workflows/ci.yml/badge.svg)](https://github.com/owen-alderson/falsealpha/actions/workflows/ci.yml)
25
+ [![PyPI](https://img.shields.io/pypi/v/falsealpha.svg)](https://pypi.org/project/falsealpha/)
25
26
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
- MIT licence.
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
  [![CI](https://github.com/owen-alderson/falsealpha/actions/workflows/ci.yml/badge.svg)](https://github.com/owen-alderson/falsealpha/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/falsealpha.svg)](https://pypi.org/project/falsealpha/)
4
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
- MIT licence.
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| with a better hall member a formula is a near-copy
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 || op == Op::TsRank) {
250
- // O(d) per cell; windows are short and these ops are rare in the search.
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
- if (op == Op::TsRank) {
259
- double below = 0, equal = 0;
260
- for (std::size_t k = 0; k < D; ++k) {
261
- double v = a(t - k, n);
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
- if (sig.size() >= static_cast<std::size_t>(c.min_assets)) b.ic[t] = pearson(average_ranks(sig), average_ranks(tgt));
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.0"
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 and SPA / Reality Check
5
- rejections. Usage: python scripts/noise_calibration.py [workdir]
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, t: Thresholds) -> dict:
60
- """nets: finalists x days (pre-holdout panel), NaN where a day has no position or target."""
61
- trials = ledger_sharpe[np.isfinite(ledger_sharpe)]
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 = [{"train": deflated(nets[k, train], n_trials), "validation": deflated(nets[k, val], K)}
67
- for k in range(K)]
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).</p><div class=card id=hist></div></div>
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 &gt; 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
- for(const r of refs){if(r.at==null||r.at<lo||r.at>hi)continue;
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, thresholds)
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