btlab 0.3.0__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.
- btlab-0.3.0/LICENSE +21 -0
- btlab-0.3.0/PKG-INFO +220 -0
- btlab-0.3.0/README.md +199 -0
- btlab-0.3.0/pyproject.toml +27 -0
- btlab-0.3.0/setup.cfg +4 -0
- btlab-0.3.0/src/btlab/__init__.py +22 -0
- btlab-0.3.0/src/btlab/data.py +117 -0
- btlab-0.3.0/src/btlab/engine.py +154 -0
- btlab-0.3.0/src/btlab/metrics.py +171 -0
- btlab-0.3.0/src/btlab/plotting.py +108 -0
- btlab-0.3.0/src/btlab/strategies.py +70 -0
- btlab-0.3.0/src/btlab/validation.py +160 -0
- btlab-0.3.0/src/btlab.egg-info/PKG-INFO +220 -0
- btlab-0.3.0/src/btlab.egg-info/SOURCES.txt +18 -0
- btlab-0.3.0/src/btlab.egg-info/dependency_links.txt +1 -0
- btlab-0.3.0/src/btlab.egg-info/requires.txt +13 -0
- btlab-0.3.0/src/btlab.egg-info/top_level.txt +1 -0
- btlab-0.3.0/tests/test_engine.py +133 -0
- btlab-0.3.0/tests/test_metrics.py +87 -0
- btlab-0.3.0/tests/test_validation.py +132 -0
btlab-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yousuf Hossain Shafeen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
btlab-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: btlab
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A backtesting framework built around avoiding the common ways backtests lie
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/hossainyousuf751/backtest-lab
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: numpy>=1.24
|
|
11
|
+
Requires-Dist: scipy>=1.10
|
|
12
|
+
Requires-Dist: pandas>=2.0
|
|
13
|
+
Provides-Extra: plot
|
|
14
|
+
Requires-Dist: matplotlib>=3.7; extra == "plot"
|
|
15
|
+
Provides-Extra: data
|
|
16
|
+
Requires-Dist: yfinance>=0.2; extra == "data"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
19
|
+
Requires-Dist: matplotlib>=3.7; extra == "dev"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# backtest-lab
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
26
|
+
A backtesting framework built around one question: **should you believe this backtest?**
|
|
27
|
+
|
|
28
|
+
Most backtests that look good are wrong for one of a handful of reasons: look-ahead bias,
|
|
29
|
+
survivorship bias, ignored trading costs and capacity, returns that are really just factor
|
|
30
|
+
exposure, or picking the best of many configurations and reporting it as if it were the only one. This project builds the tools to detect each of
|
|
31
|
+
these, and validates them on synthetic markets where the true answer is known.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e ".[dev]"
|
|
35
|
+
pytest
|
|
36
|
+
cd examples && python 01_overfitting.py && python 02_survivorship.py && python 03_planted_signal.py && python 05_capacity.py
|
|
37
|
+
pip install -e "..[data]" && python 04_live_sector_momentum.py # needs internet: Yahoo Finance + Ken French
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Design
|
|
41
|
+
|
|
42
|
+
| Module | What it does |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `strategies.py` | Strategies return target weights; row *t* is decided at the close of day *t* |
|
|
45
|
+
| `engine.py` | Execution lag, drift between rebalances, turnover-based costs, square-root market impact, delistings, point-in-time universe |
|
|
46
|
+
| `validation.py` | Truncation test for look-ahead bias; walk-forward selection; Probability of Backtest Overfitting (CSCV); effective number of trials |
|
|
47
|
+
| `metrics.py` | Sharpe, Sortino, drawdown, Probabilistic and Deflated Sharpe ratios, block-bootstrap CIs, factor attribution with Newey-West t-stats |
|
|
48
|
+
| `data.py` | Synthetic markets with controllable signal, listings, delistings and traded volume; Yahoo Finance and Ken French factor loaders |
|
|
49
|
+
|
|
50
|
+
**Timing is explicit.** With close-only data, weights decided at the close of day *t* are
|
|
51
|
+
traded at the close of *t+1* by default (`execution_lag=1`). Positions then drift with
|
|
52
|
+
returns until the next rebalance, so turnover and costs are measured on what the portfolio
|
|
53
|
+
actually holds, not on idealised constant weights.
|
|
54
|
+
|
|
55
|
+
**Look-ahead is tested, not assumed.** `check_lookahead` recomputes a strategy's weights
|
|
56
|
+
on data truncated at random dates and requires them to match the full-history weights.
|
|
57
|
+
The test suite includes a deliberately cheating strategy: the engine reports a Sharpe
|
|
58
|
+
above 10 for it with zero execution lag, which is why the truncation test, not the engine,
|
|
59
|
+
is the line of defence.
|
|
60
|
+
|
|
61
|
+
**Walk-forward is a real backtest.** The out-of-sample series trades the stitched weights of
|
|
62
|
+
each fold's chosen configuration, so entering the market and switching configurations pay costs.
|
|
63
|
+
|
|
64
|
+
**Point-in-time universes need no special engine support.** Mask what the *strategy* sees, not
|
|
65
|
+
the prices the *engine* trades on:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
weights = strategy.weights(prices.where(index_membership)) # rank only current members
|
|
69
|
+
result = run_backtest(prices, weights) # exits sold at real prices
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Masking the engine's prices instead would turn every index deletion into a fake delisting.
|
|
73
|
+
|
|
74
|
+
## Study 1: overfitting and luck
|
|
75
|
+
|
|
76
|
+
On 30 independent markets with **no signal**, 12-1 momentum has a mean Sharpe of +0.01
|
|
77
|
+
but a standard deviation of 0.33, matching the theoretical 1/√years. Twelve years of noise
|
|
78
|
+
routinely produces Sharpe ratios of ±0.6.
|
|
79
|
+
|
|
80
|
+
On the luckiest of 12 noise markets (deliberately chosen, as a researcher who stops at the
|
|
81
|
+
first good result would), a search over 45 momentum configurations finds:
|
|
82
|
+
|
|
83
|
+
| Measure | Value | Verdict |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| Best full-sample Sharpe | 0.86 | looks like a discovery |
|
|
86
|
+
| Probabilistic Sharpe vs 0 | 0.997 | "significant" (ignores the search) |
|
|
87
|
+
| **Deflated Sharpe** | **0.92** | not significant at 95% once 45 trials are accounted for |
|
|
88
|
+
| Deflated Sharpe, N = 2 effective trials | 0.99 | **fooled**: the 45 configs are really ~2 independent bets |
|
|
89
|
+
| Probability of Backtest Overfitting | 0.34 | **fooled**: the in-sample winner usually stays above median |
|
|
90
|
+
| Walk-forward out-of-sample Sharpe | 0.82 | **fooled**: the whole sample is lucky, not just the selection |
|
|
91
|
+
| Chosen config on 20 fresh noise markets | +0.00 | the truth |
|
|
92
|
+
|
|
93
|
+

|
|
94
|
+
|
|
95
|
+
The lesson is that every in-sample defence (DSR, PBO, walk-forward) protects against
|
|
96
|
+
*selection* bias but not against a lucky *sample*. Only independent data (other markets,
|
|
97
|
+
periods, or asset classes) does that.
|
|
98
|
+
|
|
99
|
+
The two refinements cut in opposite directions. Counting all 45 near-duplicate configurations as
|
|
100
|
+
independent trials makes the Deflated Sharpe too strict; clustering them (`effective_trials`)
|
|
101
|
+
is more accurate, but it removes the margin that rejected this lucky sample. The cluster count also
|
|
102
|
+
depends on the correlation threshold: here it is 1, 2, 10 and 35 at ρ > 0.3, 0.5, 0.7 and 0.9.
|
|
103
|
+
The threshold-free eigenvalue participation ratio is about 3, which supports the 0.5 default.
|
|
104
|
+
|
|
105
|
+
## Study 2: survivorship bias
|
|
106
|
+
|
|
107
|
+
150 firms are listed over 12 years; 57 fail, after a decline, with a −50% final return.
|
|
108
|
+
Testing only on the 93 survivors:
|
|
109
|
+
|
|
110
|
+
| Strategy | Point-in-time universe | Survivors only |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| Equal-weight: annual return | 9.3% | **12.8%** |
|
|
113
|
+
| Equal-weight: Sharpe | 0.67 | 0.88 |
|
|
114
|
+
| Long-only momentum: annual return | 16.1% | 15.6% |
|
|
115
|
+
|
|
116
|
+
The bias is large for strategies that would have held the failures, and nearly absent for
|
|
117
|
+
momentum, which sells declining firms before they delist. The engine handles this correctly:
|
|
118
|
+
assets cannot be bought before listing, and delisted holdings become cash.
|
|
119
|
+
|
|
120
|
+

|
|
121
|
+
|
|
122
|
+
## Study 3: a genuine signal, and what costs leave of it
|
|
123
|
+
|
|
124
|
+
With a planted persistent-return effect, every check agrees the signal is real: the
|
|
125
|
+
look-ahead test passes, the Deflated Sharpe is 0.996 (1.000 with effective N = 2), and walk-forward
|
|
126
|
+
out-of-sample Sharpe, after the costs of switching configuration, is 1.01 against 1.06 in the full sample.
|
|
127
|
+
|
|
128
|
+
PBO is 0.54, no better than on noise, and that is the correct answer to a narrower question.
|
|
129
|
+
PBO asks whether picking the in-sample winner *among these configurations* beats picking one at
|
|
130
|
+
random. All 45 variants capture the same signal, so choosing among them adds nothing. A high PBO
|
|
131
|
+
says "don't trust the tuning", not "there is no edge".
|
|
132
|
+
|
|
133
|
+
Factor attribution against the market (equal-weight index) separates the edge from exposure:
|
|
134
|
+
|
|
135
|
+
| | Alpha | t-stat | Market beta | R² |
|
|
136
|
+
|---|---|---|---|---|
|
|
137
|
+
| Long-short momentum | 6.1%/yr | 3.3 | 0.01 | 0.00 |
|
|
138
|
+
| Long-only momentum | 6.8%/yr | 3.0 | 1.03 | 0.82 |
|
|
139
|
+
|
|
140
|
+
The long-only version earns 16%/yr in raw terms, but most of that is beta.
|
|
141
|
+
|
|
142
|
+
The remaining question is economic. At ~6.4× annual turnover, net Sharpe falls from 1.11
|
|
143
|
+
gross to 0.79 at 30 bps and 0.58 at 50 bps. Borrowing the short leg costs more: a 50 bps/yr
|
|
144
|
+
general-collateral fee takes Sharpe from 1.06 to 1.01, and a 300 bps/yr hard-to-borrow fee takes it to 0.80.
|
|
145
|
+
|
|
146
|
+

|
|
147
|
+
|
|
148
|
+
## Study 4: real data
|
|
149
|
+
|
|
150
|
+
`04_live_sector_momentum.py` runs long-short momentum across the 11 SPDR sector ETFs since
|
|
151
|
+
2000. Sector ETFs are a deliberately survivorship-free universe; late launches (XLRE, XLC)
|
|
152
|
+
enter only once listed. For single stocks, a point-in-time constituent list is required.
|
|
153
|
+
|
|
154
|
+
Over 2000 to October 2026, the result at 3 bps is a Sharpe of 0.12 (95% CI −0.23 to 0.47), and it
|
|
155
|
+
is gone by 20 bps. Regressed on the Fama-French factors plus momentum:
|
|
156
|
+
|
|
157
|
+
| | Coef | Newey-West t |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| Alpha | −0.5%/yr | −0.4 |
|
|
160
|
+
| Market | 0.02 | 2.0 |
|
|
161
|
+
| Size (SMB) | −0.02 | −1.3 |
|
|
162
|
+
| Value (HML) | −0.08 | −5.5 |
|
|
163
|
+
| **Momentum** | **0.40** | **39.6** |
|
|
164
|
+
|
|
165
|
+
R² is 0.58. Sector momentum is not a new strategy: it is a weak, diluted copy of the stock-level
|
|
166
|
+
momentum factor, which you could hold directly.
|
|
167
|
+
|
|
168
|
+

|
|
169
|
+
|
|
170
|
+
## Study 5: capacity
|
|
171
|
+
|
|
172
|
+
Flat basis-point costs ignore size. `run_backtest(..., dollar_volume=, capital=)` adds
|
|
173
|
+
square-root market impact, charging `σ_daily × √(trade / ADV)` per unit traded. On the Study 3
|
|
174
|
+
market, with a median stock trading $20m/day:
|
|
175
|
+
|
|
176
|
+
| Capital | Net Sharpe, monthly | Net Sharpe, quarterly | Cost/yr, monthly |
|
|
177
|
+
|---|---|---|---|
|
|
178
|
+
| $1m | 0.96 | 0.94 | 0.8% |
|
|
179
|
+
| $10m | 0.76 | 0.82 | 1.9% |
|
|
180
|
+
| $100m | 0.13 | 0.43 | 5.3% |
|
|
181
|
+
| $300m | −0.49 | 0.06 | 8.9% |
|
|
182
|
+
| $1bn | −1.41 | −0.52 | 16.1% |
|
|
183
|
+
|
|
184
|
+
The edge that survives 30 bps of flat costs is gone by about $150m. Rebalancing quarterly
|
|
185
|
+
gives up a little at small size but roughly doubles capacity. The square-root law is
|
|
186
|
+
calibrated for trades below about 10–20% of daily volume, so the far right of the curve
|
|
187
|
+
(where trades exceed ADV) is indicative only.
|
|
188
|
+
|
|
189
|
+

|
|
190
|
+
|
|
191
|
+
## Roadmap
|
|
192
|
+
|
|
193
|
+
- [x] **M1** Engine, strategies, look-ahead test, walk-forward, PSR/DSR, bootstrap CIs, four studies
|
|
194
|
+
- [x] **M2** Probability of Backtest Overfitting via combinatorially symmetric cross-validation
|
|
195
|
+
- [x] **M3** Effective number of trials (cluster correlated configurations) for a sharper DSR
|
|
196
|
+
- [x] **M4** Realistic costs: spread plus square-root market impact as a function of trade size and volume
|
|
197
|
+
- [x] **M5** Factor attribution: regress returns on market, size, value and momentum factors to separate alpha from beta
|
|
198
|
+
- [x] **M6** Walk-forward pays to switch configuration; borrow fees on shorts; point-in-time universe recipe
|
|
199
|
+
|
|
200
|
+
A real-data single-stock study remains out of scope: free sources such as Yahoo Finance drop
|
|
201
|
+
delisted tickers, so it needs a survivorship-free database (e.g. CRSP or Norgate) plus
|
|
202
|
+
historical index membership.
|
|
203
|
+
|
|
204
|
+
## Contributing
|
|
205
|
+
|
|
206
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the project's invariants, roles, workflow and
|
|
207
|
+
testing rules, and [CHANGELOG.md](CHANGELOG.md) for what changed between versions.
|
|
208
|
+
|
|
209
|
+
## References
|
|
210
|
+
|
|
211
|
+
- Bailey, D. & López de Prado, M. (2012). The Sharpe Ratio Efficient Frontier. *Journal of Risk* 15(2).
|
|
212
|
+
- Bailey, D. & López de Prado, M. (2014). The Deflated Sharpe Ratio. *Journal of Portfolio Management* 40(5).
|
|
213
|
+
- Bailey, D., Borwein, J., López de Prado, M. & Zhu, Q. (2017). The Probability of Backtest Overfitting. *Journal of Computational Finance* 20(4).
|
|
214
|
+
- Politis, D. & Romano, J. (1994). The Stationary Bootstrap. *JASA* 89(428).
|
|
215
|
+
- Jegadeesh, N. & Titman, S. (1993). Returns to Buying Winners and Selling Losers. *Journal of Finance* 48(1).
|
|
216
|
+
- López de Prado, M. & Lewis, M. (2019). Detection of False Investment Strategies Using Unsupervised Learning Methods. *Quantitative Finance* 19(9).
|
|
217
|
+
- Almgren, R., Thum, C., Hauptmann, E. & Li, H. (2005). Direct Estimation of Equity Market Impact. *Risk* 18(7).
|
|
218
|
+
- Newey, W. & West, K. (1987). A Simple, Positive Semi-Definite, Heteroskedasticity and Autocorrelation Consistent Covariance Matrix. *Econometrica* 55(3).
|
|
219
|
+
- Fama, E. & French, K. (1993). Common Risk Factors in the Returns on Stocks and Bonds. *Journal of Financial Economics* 33(1).
|
|
220
|
+
- Carhart, M. (1997). On Persistence in Mutual Fund Performance. *Journal of Finance* 52(1).
|
btlab-0.3.0/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# backtest-lab
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
A backtesting framework built around one question: **should you believe this backtest?**
|
|
6
|
+
|
|
7
|
+
Most backtests that look good are wrong for one of a handful of reasons: look-ahead bias,
|
|
8
|
+
survivorship bias, ignored trading costs and capacity, returns that are really just factor
|
|
9
|
+
exposure, or picking the best of many configurations and reporting it as if it were the only one. This project builds the tools to detect each of
|
|
10
|
+
these, and validates them on synthetic markets where the true answer is known.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install -e ".[dev]"
|
|
14
|
+
pytest
|
|
15
|
+
cd examples && python 01_overfitting.py && python 02_survivorship.py && python 03_planted_signal.py && python 05_capacity.py
|
|
16
|
+
pip install -e "..[data]" && python 04_live_sector_momentum.py # needs internet: Yahoo Finance + Ken French
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Design
|
|
20
|
+
|
|
21
|
+
| Module | What it does |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `strategies.py` | Strategies return target weights; row *t* is decided at the close of day *t* |
|
|
24
|
+
| `engine.py` | Execution lag, drift between rebalances, turnover-based costs, square-root market impact, delistings, point-in-time universe |
|
|
25
|
+
| `validation.py` | Truncation test for look-ahead bias; walk-forward selection; Probability of Backtest Overfitting (CSCV); effective number of trials |
|
|
26
|
+
| `metrics.py` | Sharpe, Sortino, drawdown, Probabilistic and Deflated Sharpe ratios, block-bootstrap CIs, factor attribution with Newey-West t-stats |
|
|
27
|
+
| `data.py` | Synthetic markets with controllable signal, listings, delistings and traded volume; Yahoo Finance and Ken French factor loaders |
|
|
28
|
+
|
|
29
|
+
**Timing is explicit.** With close-only data, weights decided at the close of day *t* are
|
|
30
|
+
traded at the close of *t+1* by default (`execution_lag=1`). Positions then drift with
|
|
31
|
+
returns until the next rebalance, so turnover and costs are measured on what the portfolio
|
|
32
|
+
actually holds, not on idealised constant weights.
|
|
33
|
+
|
|
34
|
+
**Look-ahead is tested, not assumed.** `check_lookahead` recomputes a strategy's weights
|
|
35
|
+
on data truncated at random dates and requires them to match the full-history weights.
|
|
36
|
+
The test suite includes a deliberately cheating strategy: the engine reports a Sharpe
|
|
37
|
+
above 10 for it with zero execution lag, which is why the truncation test, not the engine,
|
|
38
|
+
is the line of defence.
|
|
39
|
+
|
|
40
|
+
**Walk-forward is a real backtest.** The out-of-sample series trades the stitched weights of
|
|
41
|
+
each fold's chosen configuration, so entering the market and switching configurations pay costs.
|
|
42
|
+
|
|
43
|
+
**Point-in-time universes need no special engine support.** Mask what the *strategy* sees, not
|
|
44
|
+
the prices the *engine* trades on:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
weights = strategy.weights(prices.where(index_membership)) # rank only current members
|
|
48
|
+
result = run_backtest(prices, weights) # exits sold at real prices
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Masking the engine's prices instead would turn every index deletion into a fake delisting.
|
|
52
|
+
|
|
53
|
+
## Study 1: overfitting and luck
|
|
54
|
+
|
|
55
|
+
On 30 independent markets with **no signal**, 12-1 momentum has a mean Sharpe of +0.01
|
|
56
|
+
but a standard deviation of 0.33, matching the theoretical 1/√years. Twelve years of noise
|
|
57
|
+
routinely produces Sharpe ratios of ±0.6.
|
|
58
|
+
|
|
59
|
+
On the luckiest of 12 noise markets (deliberately chosen, as a researcher who stops at the
|
|
60
|
+
first good result would), a search over 45 momentum configurations finds:
|
|
61
|
+
|
|
62
|
+
| Measure | Value | Verdict |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| Best full-sample Sharpe | 0.86 | looks like a discovery |
|
|
65
|
+
| Probabilistic Sharpe vs 0 | 0.997 | "significant" (ignores the search) |
|
|
66
|
+
| **Deflated Sharpe** | **0.92** | not significant at 95% once 45 trials are accounted for |
|
|
67
|
+
| Deflated Sharpe, N = 2 effective trials | 0.99 | **fooled**: the 45 configs are really ~2 independent bets |
|
|
68
|
+
| Probability of Backtest Overfitting | 0.34 | **fooled**: the in-sample winner usually stays above median |
|
|
69
|
+
| Walk-forward out-of-sample Sharpe | 0.82 | **fooled**: the whole sample is lucky, not just the selection |
|
|
70
|
+
| Chosen config on 20 fresh noise markets | +0.00 | the truth |
|
|
71
|
+
|
|
72
|
+

|
|
73
|
+
|
|
74
|
+
The lesson is that every in-sample defence (DSR, PBO, walk-forward) protects against
|
|
75
|
+
*selection* bias but not against a lucky *sample*. Only independent data (other markets,
|
|
76
|
+
periods, or asset classes) does that.
|
|
77
|
+
|
|
78
|
+
The two refinements cut in opposite directions. Counting all 45 near-duplicate configurations as
|
|
79
|
+
independent trials makes the Deflated Sharpe too strict; clustering them (`effective_trials`)
|
|
80
|
+
is more accurate, but it removes the margin that rejected this lucky sample. The cluster count also
|
|
81
|
+
depends on the correlation threshold: here it is 1, 2, 10 and 35 at ρ > 0.3, 0.5, 0.7 and 0.9.
|
|
82
|
+
The threshold-free eigenvalue participation ratio is about 3, which supports the 0.5 default.
|
|
83
|
+
|
|
84
|
+
## Study 2: survivorship bias
|
|
85
|
+
|
|
86
|
+
150 firms are listed over 12 years; 57 fail, after a decline, with a −50% final return.
|
|
87
|
+
Testing only on the 93 survivors:
|
|
88
|
+
|
|
89
|
+
| Strategy | Point-in-time universe | Survivors only |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| Equal-weight: annual return | 9.3% | **12.8%** |
|
|
92
|
+
| Equal-weight: Sharpe | 0.67 | 0.88 |
|
|
93
|
+
| Long-only momentum: annual return | 16.1% | 15.6% |
|
|
94
|
+
|
|
95
|
+
The bias is large for strategies that would have held the failures, and nearly absent for
|
|
96
|
+
momentum, which sells declining firms before they delist. The engine handles this correctly:
|
|
97
|
+
assets cannot be bought before listing, and delisted holdings become cash.
|
|
98
|
+
|
|
99
|
+

|
|
100
|
+
|
|
101
|
+
## Study 3: a genuine signal, and what costs leave of it
|
|
102
|
+
|
|
103
|
+
With a planted persistent-return effect, every check agrees the signal is real: the
|
|
104
|
+
look-ahead test passes, the Deflated Sharpe is 0.996 (1.000 with effective N = 2), and walk-forward
|
|
105
|
+
out-of-sample Sharpe, after the costs of switching configuration, is 1.01 against 1.06 in the full sample.
|
|
106
|
+
|
|
107
|
+
PBO is 0.54, no better than on noise, and that is the correct answer to a narrower question.
|
|
108
|
+
PBO asks whether picking the in-sample winner *among these configurations* beats picking one at
|
|
109
|
+
random. All 45 variants capture the same signal, so choosing among them adds nothing. A high PBO
|
|
110
|
+
says "don't trust the tuning", not "there is no edge".
|
|
111
|
+
|
|
112
|
+
Factor attribution against the market (equal-weight index) separates the edge from exposure:
|
|
113
|
+
|
|
114
|
+
| | Alpha | t-stat | Market beta | R² |
|
|
115
|
+
|---|---|---|---|---|
|
|
116
|
+
| Long-short momentum | 6.1%/yr | 3.3 | 0.01 | 0.00 |
|
|
117
|
+
| Long-only momentum | 6.8%/yr | 3.0 | 1.03 | 0.82 |
|
|
118
|
+
|
|
119
|
+
The long-only version earns 16%/yr in raw terms, but most of that is beta.
|
|
120
|
+
|
|
121
|
+
The remaining question is economic. At ~6.4× annual turnover, net Sharpe falls from 1.11
|
|
122
|
+
gross to 0.79 at 30 bps and 0.58 at 50 bps. Borrowing the short leg costs more: a 50 bps/yr
|
|
123
|
+
general-collateral fee takes Sharpe from 1.06 to 1.01, and a 300 bps/yr hard-to-borrow fee takes it to 0.80.
|
|
124
|
+
|
|
125
|
+

|
|
126
|
+
|
|
127
|
+
## Study 4: real data
|
|
128
|
+
|
|
129
|
+
`04_live_sector_momentum.py` runs long-short momentum across the 11 SPDR sector ETFs since
|
|
130
|
+
2000. Sector ETFs are a deliberately survivorship-free universe; late launches (XLRE, XLC)
|
|
131
|
+
enter only once listed. For single stocks, a point-in-time constituent list is required.
|
|
132
|
+
|
|
133
|
+
Over 2000 to October 2026, the result at 3 bps is a Sharpe of 0.12 (95% CI −0.23 to 0.47), and it
|
|
134
|
+
is gone by 20 bps. Regressed on the Fama-French factors plus momentum:
|
|
135
|
+
|
|
136
|
+
| | Coef | Newey-West t |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| Alpha | −0.5%/yr | −0.4 |
|
|
139
|
+
| Market | 0.02 | 2.0 |
|
|
140
|
+
| Size (SMB) | −0.02 | −1.3 |
|
|
141
|
+
| Value (HML) | −0.08 | −5.5 |
|
|
142
|
+
| **Momentum** | **0.40** | **39.6** |
|
|
143
|
+
|
|
144
|
+
R² is 0.58. Sector momentum is not a new strategy: it is a weak, diluted copy of the stock-level
|
|
145
|
+
momentum factor, which you could hold directly.
|
|
146
|
+
|
|
147
|
+

|
|
148
|
+
|
|
149
|
+
## Study 5: capacity
|
|
150
|
+
|
|
151
|
+
Flat basis-point costs ignore size. `run_backtest(..., dollar_volume=, capital=)` adds
|
|
152
|
+
square-root market impact, charging `σ_daily × √(trade / ADV)` per unit traded. On the Study 3
|
|
153
|
+
market, with a median stock trading $20m/day:
|
|
154
|
+
|
|
155
|
+
| Capital | Net Sharpe, monthly | Net Sharpe, quarterly | Cost/yr, monthly |
|
|
156
|
+
|---|---|---|---|
|
|
157
|
+
| $1m | 0.96 | 0.94 | 0.8% |
|
|
158
|
+
| $10m | 0.76 | 0.82 | 1.9% |
|
|
159
|
+
| $100m | 0.13 | 0.43 | 5.3% |
|
|
160
|
+
| $300m | −0.49 | 0.06 | 8.9% |
|
|
161
|
+
| $1bn | −1.41 | −0.52 | 16.1% |
|
|
162
|
+
|
|
163
|
+
The edge that survives 30 bps of flat costs is gone by about $150m. Rebalancing quarterly
|
|
164
|
+
gives up a little at small size but roughly doubles capacity. The square-root law is
|
|
165
|
+
calibrated for trades below about 10–20% of daily volume, so the far right of the curve
|
|
166
|
+
(where trades exceed ADV) is indicative only.
|
|
167
|
+
|
|
168
|
+

|
|
169
|
+
|
|
170
|
+
## Roadmap
|
|
171
|
+
|
|
172
|
+
- [x] **M1** Engine, strategies, look-ahead test, walk-forward, PSR/DSR, bootstrap CIs, four studies
|
|
173
|
+
- [x] **M2** Probability of Backtest Overfitting via combinatorially symmetric cross-validation
|
|
174
|
+
- [x] **M3** Effective number of trials (cluster correlated configurations) for a sharper DSR
|
|
175
|
+
- [x] **M4** Realistic costs: spread plus square-root market impact as a function of trade size and volume
|
|
176
|
+
- [x] **M5** Factor attribution: regress returns on market, size, value and momentum factors to separate alpha from beta
|
|
177
|
+
- [x] **M6** Walk-forward pays to switch configuration; borrow fees on shorts; point-in-time universe recipe
|
|
178
|
+
|
|
179
|
+
A real-data single-stock study remains out of scope: free sources such as Yahoo Finance drop
|
|
180
|
+
delisted tickers, so it needs a survivorship-free database (e.g. CRSP or Norgate) plus
|
|
181
|
+
historical index membership.
|
|
182
|
+
|
|
183
|
+
## Contributing
|
|
184
|
+
|
|
185
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the project's invariants, roles, workflow and
|
|
186
|
+
testing rules, and [CHANGELOG.md](CHANGELOG.md) for what changed between versions.
|
|
187
|
+
|
|
188
|
+
## References
|
|
189
|
+
|
|
190
|
+
- Bailey, D. & López de Prado, M. (2012). The Sharpe Ratio Efficient Frontier. *Journal of Risk* 15(2).
|
|
191
|
+
- Bailey, D. & López de Prado, M. (2014). The Deflated Sharpe Ratio. *Journal of Portfolio Management* 40(5).
|
|
192
|
+
- Bailey, D., Borwein, J., López de Prado, M. & Zhu, Q. (2017). The Probability of Backtest Overfitting. *Journal of Computational Finance* 20(4).
|
|
193
|
+
- Politis, D. & Romano, J. (1994). The Stationary Bootstrap. *JASA* 89(428).
|
|
194
|
+
- Jegadeesh, N. & Titman, S. (1993). Returns to Buying Winners and Selling Losers. *Journal of Finance* 48(1).
|
|
195
|
+
- López de Prado, M. & Lewis, M. (2019). Detection of False Investment Strategies Using Unsupervised Learning Methods. *Quantitative Finance* 19(9).
|
|
196
|
+
- Almgren, R., Thum, C., Hauptmann, E. & Li, H. (2005). Direct Estimation of Equity Market Impact. *Risk* 18(7).
|
|
197
|
+
- Newey, W. & West, K. (1987). A Simple, Positive Semi-Definite, Heteroskedasticity and Autocorrelation Consistent Covariance Matrix. *Econometrica* 55(3).
|
|
198
|
+
- Fama, E. & French, K. (1993). Common Risk Factors in the Returns on Stocks and Bonds. *Journal of Financial Economics* 33(1).
|
|
199
|
+
- Carhart, M. (1997). On Persistence in Mutual Fund Performance. *Journal of Finance* 52(1).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "btlab"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "A backtesting framework built around avoiding the common ways backtests lie"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = ["numpy>=1.24", "scipy>=1.10", "pandas>=2.0"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Repository = "https://github.com/hossainyousuf751/backtest-lab"
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
plot = ["matplotlib>=3.7"]
|
|
20
|
+
data = ["yfinance>=0.2"]
|
|
21
|
+
dev = ["pytest>=7", "matplotlib>=3.7"]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
testpaths = ["tests"]
|
btlab-0.3.0/setup.cfg
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""btlab: honest backtesting, with tools to detect look-ahead, survivorship and overfitting."""
|
|
2
|
+
|
|
3
|
+
from btlab.data import synthetic_dollar_volume, synthetic_market
|
|
4
|
+
from btlab.engine import BacktestResult, cost_sensitivity, rebalance_dates, run_backtest
|
|
5
|
+
from btlab.metrics import (
|
|
6
|
+
annual_return, annual_vol, bootstrap_sharpe_ci, deflated_sharpe, expected_max_sharpe,
|
|
7
|
+
factor_attribution, max_drawdown, probabilistic_sharpe, sharpe, sortino, summary,
|
|
8
|
+
)
|
|
9
|
+
from btlab.strategies import CrossSectionalMomentum, Strategy, TimeSeriesMomentum
|
|
10
|
+
from btlab.validation import (
|
|
11
|
+
WalkForwardResult, check_lookahead, effective_trials, probability_of_backtest_overfitting,
|
|
12
|
+
walk_forward,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BacktestResult", "CrossSectionalMomentum", "Strategy", "TimeSeriesMomentum",
|
|
17
|
+
"WalkForwardResult", "annual_return", "annual_vol", "bootstrap_sharpe_ci", "check_lookahead",
|
|
18
|
+
"cost_sensitivity", "deflated_sharpe", "effective_trials", "expected_max_sharpe",
|
|
19
|
+
"factor_attribution", "max_drawdown", "probabilistic_sharpe",
|
|
20
|
+
"probability_of_backtest_overfitting", "rebalance_dates", "run_backtest", "sharpe", "sortino",
|
|
21
|
+
"summary", "synthetic_dollar_volume", "synthetic_market", "walk_forward",
|
|
22
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Synthetic markets with controllable signal, listings and delistings.
|
|
2
|
+
|
|
3
|
+
Because the data-generating process is known, every claim a backtest makes
|
|
4
|
+
can be checked: on a pure-noise market, no strategy should be significant.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
TRADING_DAYS = 252
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def synthetic_market(n_assets: int = 100, n_years: int = 10, momentum_strength: float = 0.0,
|
|
16
|
+
momentum_half_life: int = 126, delist_rate: float = 0.0,
|
|
17
|
+
delisting_return: float = -0.5, late_listing_frac: float = 0.0,
|
|
18
|
+
seed: int | None = 0, start: str = "2014-01-01") -> pd.DataFrame:
|
|
19
|
+
"""Daily prices (dates x assets) with NaN before listing and after delisting.
|
|
20
|
+
|
|
21
|
+
Returns = beta * market + mu_t + idiosyncratic noise, where mu_t is a
|
|
22
|
+
persistent AR(1) expected return with annualised cross-sectional standard
|
|
23
|
+
deviation `momentum_strength`. Past returns are informative about mu_t,
|
|
24
|
+
so momentum works exactly when momentum_strength > 0.
|
|
25
|
+
|
|
26
|
+
Firms that delist (annual hazard `delist_rate`) drift down for six months,
|
|
27
|
+
then suffer `delisting_return` on their final day.
|
|
28
|
+
"""
|
|
29
|
+
rng = np.random.default_rng(seed)
|
|
30
|
+
n_days = TRADING_DAYS * n_years
|
|
31
|
+
dates = pd.bdate_range(start, periods=n_days)
|
|
32
|
+
|
|
33
|
+
vol = rng.uniform(0.20, 0.50, n_assets) / np.sqrt(TRADING_DAYS)
|
|
34
|
+
beta = rng.uniform(0.6, 1.4, n_assets)
|
|
35
|
+
market = rng.normal(0.07 / TRADING_DAYS, 0.15 / np.sqrt(TRADING_DAYS), n_days)
|
|
36
|
+
|
|
37
|
+
phi = 0.5 ** (1 / momentum_half_life)
|
|
38
|
+
mu_sd = momentum_strength / TRADING_DAYS
|
|
39
|
+
mu = np.zeros((n_days, n_assets))
|
|
40
|
+
mu[0] = rng.normal(0, mu_sd, n_assets)
|
|
41
|
+
shocks = rng.normal(0, mu_sd * np.sqrt(1 - phi**2), (n_days, n_assets))
|
|
42
|
+
for t in range(1, n_days):
|
|
43
|
+
mu[t] = phi * mu[t - 1] + shocks[t]
|
|
44
|
+
|
|
45
|
+
rets = beta * market[:, None] + mu + rng.standard_normal((n_days, n_assets)) * vol
|
|
46
|
+
|
|
47
|
+
alive = np.ones((n_days, n_assets), dtype=bool)
|
|
48
|
+
daily_hazard = 1 - (1 - delist_rate) ** (1 / TRADING_DAYS)
|
|
49
|
+
for j in range(n_assets):
|
|
50
|
+
if late_listing_frac and rng.random() < late_listing_frac:
|
|
51
|
+
alive[: rng.integers(1, int(0.6 * n_days)), j] = False
|
|
52
|
+
if delist_rate:
|
|
53
|
+
hits = np.flatnonzero(rng.random(n_days) < daily_hazard)
|
|
54
|
+
hits = hits[hits > 130]
|
|
55
|
+
if len(hits):
|
|
56
|
+
d = hits[0]
|
|
57
|
+
rets[d - 126:d, j] -= 0.4 / 126 # slow decline before failure
|
|
58
|
+
rets[d, j] = delisting_return
|
|
59
|
+
alive[d + 1:, j] = False
|
|
60
|
+
|
|
61
|
+
rets = np.clip(rets, -0.95, None)
|
|
62
|
+
rets[0] = 0.0
|
|
63
|
+
prices = 100 * np.cumprod(1 + rets, axis=0)
|
|
64
|
+
prices[~alive] = np.nan
|
|
65
|
+
return pd.DataFrame(prices, index=dates, columns=[f"A{j:03d}" for j in range(n_assets)])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def synthetic_dollar_volume(prices: pd.DataFrame, median_adv: float = 2e7, size_dispersion: float = 1.0,
|
|
69
|
+
seed: int | None = 0) -> pd.DataFrame:
|
|
70
|
+
"""Daily traded value per asset: a lognormal firm size times lognormal daily noise.
|
|
71
|
+
|
|
72
|
+
Firm sizes span roughly two orders of magnitude around `median_adv`
|
|
73
|
+
(default $20m/day, a small/mid-cap stock), which is what makes capacity
|
|
74
|
+
depend on which names a strategy trades.
|
|
75
|
+
"""
|
|
76
|
+
rng = np.random.default_rng(seed)
|
|
77
|
+
size = median_adv * np.exp(rng.normal(0, size_dispersion, prices.shape[1]))
|
|
78
|
+
noise = np.exp(rng.normal(-0.045, 0.3, prices.shape)) # mean-one daily variation
|
|
79
|
+
return pd.DataFrame(size * noise, index=prices.index, columns=prices.columns).where(prices.notna())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
FRENCH_URL = "https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_french_factors(start: str = "1990-01-01") -> pd.DataFrame:
|
|
86
|
+
"""Daily US factors from Kenneth French's data library, as decimals.
|
|
87
|
+
|
|
88
|
+
Columns: Mkt-RF, SMB (size), HML (value), RF (T-bill), Mom (momentum).
|
|
89
|
+
"""
|
|
90
|
+
import io
|
|
91
|
+
import urllib.request
|
|
92
|
+
import zipfile
|
|
93
|
+
|
|
94
|
+
frames = []
|
|
95
|
+
for name in ("F-F_Research_Data_Factors_daily_CSV.zip", "F-F_Momentum_Factor_daily_CSV.zip"):
|
|
96
|
+
with urllib.request.urlopen(FRENCH_URL + name, timeout=60) as resp:
|
|
97
|
+
zf = zipfile.ZipFile(io.BytesIO(resp.read()))
|
|
98
|
+
lines = zf.read(zf.namelist()[0]).decode("latin1").splitlines()
|
|
99
|
+
header = next(i for i, ln in enumerate(lines) if ln.startswith(","))
|
|
100
|
+
body = [ln for ln in lines[header + 1:] if ln[:8].isdigit()]
|
|
101
|
+
df = pd.read_csv(io.StringIO("\n".join(body)), header=None, index_col=0,
|
|
102
|
+
names=["date"] + [c.strip() for c in lines[header].split(",")[1:]])
|
|
103
|
+
df.index = pd.to_datetime(df.index.astype(str), format="%Y%m%d")
|
|
104
|
+
frames.append(df)
|
|
105
|
+
out = pd.concat(frames, axis=1, join="inner").rename(columns={"MOM": "Mom"}) / 100
|
|
106
|
+
return out.loc[start:]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_yfinance(tickers: list[str], start: str = "2005-01-01", end: str | None = None,
|
|
110
|
+
field: str = "Close") -> pd.DataFrame:
|
|
111
|
+
"""Adjusted prices (or field="Volume"). Today's ticker list is survivorship-biased for stocks."""
|
|
112
|
+
try:
|
|
113
|
+
import yfinance as yf
|
|
114
|
+
except ImportError as exc:
|
|
115
|
+
raise ImportError("install the data extra: pip install -e '.[data]'") from exc
|
|
116
|
+
data = yf.download(tickers, start=start, end=end, auto_adjust=True, progress=False)
|
|
117
|
+
return data[field].dropna(how="all")
|