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 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
+ ![tests](https://github.com/hossainyousuf751/backtest-lab/actions/workflows/ci.yml/badge.svg)
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
+ ![Trial Sharpes](https://raw.githubusercontent.com/hossainyousuf751/backtest-lab/main/docs/overfitting_trials.png)
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
+ ![Survivorship](https://raw.githubusercontent.com/hossainyousuf751/backtest-lab/main/docs/survivorship.png)
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
+ ![Cost sensitivity](https://raw.githubusercontent.com/hossainyousuf751/backtest-lab/main/docs/planted_costs.png)
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
+ ![Sector momentum](https://raw.githubusercontent.com/hossainyousuf751/backtest-lab/main/docs/live_sector_momentum.png)
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
+ ![Capacity](https://raw.githubusercontent.com/hossainyousuf751/backtest-lab/main/docs/capacity.png)
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
+ ![tests](https://github.com/hossainyousuf751/backtest-lab/actions/workflows/ci.yml/badge.svg)
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
+ ![Trial Sharpes](https://raw.githubusercontent.com/hossainyousuf751/backtest-lab/main/docs/overfitting_trials.png)
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
+ ![Survivorship](https://raw.githubusercontent.com/hossainyousuf751/backtest-lab/main/docs/survivorship.png)
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
+ ![Cost sensitivity](https://raw.githubusercontent.com/hossainyousuf751/backtest-lab/main/docs/planted_costs.png)
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
+ ![Sector momentum](https://raw.githubusercontent.com/hossainyousuf751/backtest-lab/main/docs/live_sector_momentum.png)
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
+ ![Capacity](https://raw.githubusercontent.com/hossainyousuf751/backtest-lab/main/docs/capacity.png)
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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")