Skip to content

Commit 3d5eef9

Browse files
committed
[ENH] Improved Backtester
Decoupled `rebalance_freq` and `reopt_freq` enabling users to customize the portfolio style. Also refactored `Backtester` class for better readability. Updated documentation and Readme with changes.
1 parent 6293a4f commit 3d5eef9

13 files changed

Lines changed: 296 additions & 263 deletions

README.md

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ Visit the [documentation](https://opes.pages.dev) for detailed insights on OPES.
1212

1313
---
1414

15+
## Project Methodology
16+
17+
This project follows an Agile development approach. Every feature is designed to be extensible, exploratory and open to modification as the system evolves. Each GitHub commit represents a usable and coherent version of OPES. While not every commit is feature-complete or fully refined, each serves as a stable minimum viable product and a reliable snapshot of progress. Features marked as *experimental* are subject to active evaluation and will be either validated and promoted or removed entirely based on feasibility and empirical performance.
18+
19+
---
20+
1521
## Disclaimer
1622

1723
The information provided by OPES is for educational, research and informational purposes only. It is not intended as financial, investment or legal advice. Users should conduct their own due diligence and consult with licensed financial professionals before making any investment decisions. OPES and its contributors are not liable for any financial losses or decisions made based on this content. Past performance is not indicative of future results.
@@ -191,17 +197,4 @@ This will run three scripts, each dedicated to testing the optimizer, regularize
191197
GOOG, AAPL, AMZN, MSFT
192198
```
193199

194-
The price data is stored in the `prices.csv` file within the `tests/` directory. The number of tickers are limited to 4 since there are computationally heavy portfolio objectives (like `UniversalPortfolios`) included which may take an eternity to test well using multiple tickers.
195-
196-
Also it eats up RAM like pac-man.
197-
198-
---
199-
200-
## Upcoming Features (Unconfirmed)
201-
202-
These features are still in the works and may or may not appear in later updates:
203-
204-
| **Objective Name (Category)** |
205-
| ------------------------------------------------ |
206-
| Online Newton Step (Online Learning) |
207-
| ADA-BARRONS (Online Learning) |
200+
The price data is stored in the `prices.csv` file within the `tests/` directory. The number of tickers are limited to 4 since there are computationally heavy portfolio objectives (like `UniversalPortfolios`) included which may take an eternity to test well using multiple tickers.

docs/docs/backtesting.md

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -71,33 +71,33 @@ It also stores transaction cost parameters for portfolio simulations.
7171
```python
7272
def backtest(
7373
optimizer,
74-
rebalance_freq=None,
75-
seed=None,
74+
rebalance_freq=1,
75+
reopt_freq=1,
76+
seed=100,
7677
weight_bounds=None,
7778
clean_weights=False
7879
)
7980
```
8081

8182
Execute a portfolio backtest over the test dataset using a given optimizer.
8283

83-
This method performs either a static-weight backtest or a rolling-weight
84-
backtest depending on whether `rebalance_freq` is specified. It also
85-
applies transaction costs and ensures no lookahead bias during rebalancing.
84+
This method performs a walk-forward backtest using the user defined `rebalance_freq`
85+
and `reopt_freq`. It also applies transaction costs and ensures no lookahead bias.
8686
For a rolling backtest, any common date values are dropped, the first occurrence
8787
is considered to be original and kept.
8888

8989
!!! warning "Warning:"
9090
Some online learning methods such as `ExponentialGradient` update weights based
91-
on the most recent observations. Setting `rebalance_freq` to any value other
92-
than `1` (or possibly `None`) may result in suboptimal performance, as
93-
intermediate data points will be ignored and not used for weight updates.
94-
Proceed with caution when using other rebalancing frequencies with online learning algorithms.
91+
on the most recent observations. Setting `reopt_freq` to any value other
92+
than `1` may result in suboptimal performance, as intermediate data points will
93+
be ignored and not used for weight updates.
9594

9695
**Args:**
9796

9897
- `optimizer`: An optimizer object containing the optimization strategy. Accepts both OPES built-in objectives and externally constructed optimizer objects.
99-
- `rebalance_freq` (*int or None, optional*): Frequency of rebalancing (re-optimization) in time steps. If `None`, a static weight backtest is performed. Defaults to `None`.
100-
- `seed` (*int or None, optional*): Random seed for reproducible cost simulations. Defaults to `None`.
98+
- `rebalance_freq` (*int, optional*): Frequency of rebalancing in time steps. Must be `>= 1`. Defaults to `1`.
99+
- `reopt_freq` (*int, optional*): Frequency of re-optimization in time steps. Must be `>= 1`. Defaults to `1`.
100+
- `seed` (*int or None, optional*): Random seed for reproducible cost simulations. Defaults to `100`.
101101
- `weight_bounds` (*tuple, optional*): Bounds for portfolio weights passed to the optimizer if supported.
102102

103103
!!! abstract "Rules for `optimizer` Object"
@@ -107,33 +107,44 @@ is considered to be original and kept.
107107
- `**kwargs`: For safety against breaking changes.
108108
- `optimize` must output weights for the timestep.
109109

110+
!!! note "Note"
111+
- Re-optimization does not automatically imply rebalancing. When the portfolio is re-optimized at a given timestep, weights may or may not be updated depending on the value of `rebalance_freq`.
112+
- To ensure a coherent backtest, a common practice is to choose frequencies such that `reopt_freq % rebalance_freq == 0`. This guarantees that whenever optimization occurs, a rebalance is also performed.
113+
- Also note that within a given timestep, rebalancing, if it occurs, is performed after optimization when optimization is scheduled for that timestep.
114+
115+
!!! tip "Tip"
116+
Common portfolio styles can be constructed by appropriate choices of `rebalance_freq` and `reopt_freq`:
117+
118+
- Buy-and-Hold: `rebalance_freq > horizon`, `reopt_freq > horizon`
119+
- Constantly Rebalanced: `rebalance_freq = 1`, `reopt_freq > horizon`
120+
- Fully Dynamic: `rebalance_freq = 1`, `reopt_freq = 1`
121+
110122
**Returns:**
111123

112124
- `dict`: Backtest results containing the following keys:
113125
- `'returns'` (*np.ndarray*): Portfolio returns after accounting for costs.
114126
- `'weights'` (*np.ndarray*): Portfolio weights at each timestep.
115127
- `'costs'` (*np.ndarray*): Transaction costs applied at each timestep.
116-
- `'dates'` (*np.ndarray*): Dates on which the backtest was conducted.
128+
- `'timeline'` (*np.ndarray*): Timeline on which the backtest was conducted.
117129

118130
**Raises**
119131

120132
- `DataError`: If the optimizer does not accept weight bounds but `weight_bounds` are provided.
121133
- `PortfolioError`: If input validation fails (via `_backtest_integrity_check`).
134+
- `OptimizationError`: If the underlying optimizer uses optimization and if it fails to optimize.
122135

123136

124137
!!! note "Notes:"
125138
- All returned arrays are aligned in time and have length equal to the test dataset.
126-
- Static weight backtest: Uses a single set of optimized weights for all test data. This denotes a constant rebalanced portfolio.
127-
- Rolling weight backtest: Re-optimizes weights at intervals defined by `rebalance_freq` using only historical data up to the current point to prevent lookahead bias.
128139
- Returns and weights are stored in arrays aligned with test data indices.
129140

130141
!!! example "Example:"
131142
```python
132143
import numpy as np
133144

134145
# Importing necessary OPES modules
135-
from opes.objectives.utility_theory import Kelly
136-
from opes.backtester import Backtester
146+
from opes.objectives import Kelly
147+
from opes import Backtester
137148

138149
# Place holder for your price data
139150
from some_random_module import trainData, testData
@@ -149,7 +160,11 @@ is considered to be original and kept.
149160
tester = Backtester(train_data=training, test_data=testing)
150161

151162
# Obtaining backtest data for kelly optimizer
152-
kelly_backtest = tester.backtest(optimizer=kelly_optimizer, rebalance_freq=21)
163+
kelly_backtest = tester.backtest(
164+
optimizer=kelly_optimizer,
165+
rebalance_freq=1, # Rebalance daily
166+
reopt_freq=21 # Re-optimize monthly
167+
)
153168

154169
# Printing results
155170
for key in kelly_backtest:
@@ -214,8 +229,8 @@ commonly used in finance, including volatility, drawdowns and tail risk metrics.
214229
!!! example "Example:"
215230
```python
216231
# Importing portfolio method and backtester
217-
from opes.objectives.markowitz import MaxSharpe
218-
from opes.backtester import Backtester
232+
from opes.objectives import MaxSharpe
233+
from opes import Backtester
219234

220235
# Place holder for your price data
221236
from some_random_module import trainData, testData
@@ -280,8 +295,8 @@ a file.
280295
!!! example "Example:"
281296
```python
282297
# Importing portfolio methods and backtester
283-
from opes.objectives.markowitz import MaxMean, MeanVariance
284-
from opes.backtester import Backtester
298+
from opes.objectives import MaxMean, MeanVariance
299+
from opes import Backtester
285300

286301
# Place holder for your price data
287302
from some_random_module import trainData, testData
@@ -297,9 +312,9 @@ a file.
297312
# Initializing Backtest with constant costs
298313
tester = Backtester(train_data=training, test_data=testing)
299314

300-
# Obtaining returns array from backtest for both optimizers (Monthly Rebalancing)
301-
scenario_1 = tester.backtest(optimizer=maxmeanl2, rebalance_freq=21)
302-
scenario_2 = tester.backtest(optimizer=mvo1_5, rebalance_freq=21)['returns']
315+
# Obtaining returns array from backtest for both optimizers
316+
scenario_1 = tester.backtest(optimizer=maxmeanl2)
317+
scenario_2 = tester.backtest(optimizer=mvo1_5)['returns']
303318

304319
# Plotting wealth
305320
tester.plot_wealth(

docs/docs/examples/good_strategy.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,16 @@ tester = Backtester(train_data=train, test_data=test, cost={'gamma' : (5, 1)})
9595

9696
# Obtaining returns
9797
# For now, weights and costs dont matter, so we discard them
98-
return_scenario = tester.backtest(optimizer=mvo_ra08, rebalance_freq=1, clean_weights=True, seed=100)['returns']
98+
return_scenario = tester.backtest(
99+
optimizer=mvo_ra08,
100+
rebalance_freq=1,
101+
reopt_freq=1,
102+
clean_weights=True,
103+
seed=100
104+
)['returns']
99105
```
100106

101-
We use `rebalance_freq=1` so we can see how the portfolio adapts to changes quickly. `seed=100` gaurantees reproducibility and Gamma slippage captures asymmetric execution costs where extreme liquidity events are rare but painful. After obtaining `return_scenario` we can get the metrics and plot wealth.
107+
We use `rebalance_freq=1` and `reopt_freq=1` so we can see how the portfolio adapts to changes quickly. `seed=100` gaurantees reproducibility and Gamma slippage captures asymmetric execution costs where extreme liquidity events are rare but painful. After obtaining `return_scenario` we can get the metrics and plot wealth.
102108

103109
---
104110

docs/docs/examples/if_you_knew_the_future.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,14 @@ The in-sample backtester can be constructed by enforcing `train_data=test` as we
9090
# In-sample backtester
9191
# zero-cost backtesting
9292
tester_in_sample = Backtester(train_data=test, test_data=test, cost={'const' : 0})
93-
in_sample_results = tester_in_sample.backtest(optimizer=mean_variance, clean_weights=True)
93+
in_sample_results = tester_in_sample.backtest(optimizer=mean_variance, clean_weights=True, reopt_freq=1000)
9494

9595
# Obtaining weights and returns from the backtest
9696
in_weights = in_sample_results["weights"][0]
9797
return_scenario_in = in_sample_results["returns"]
9898
```
9999

100-
The `rebalance_freq` parameter is defaulted to `None`, imposing a static weight backtest.
100+
The `rebalance_freq` parameter is defaulted to `1` and `reopt_freq` is set to `1000`, imposing a constant rebalanced backtest.
101101

102102
### Out-of-Sample Backtester
103103

@@ -107,21 +107,21 @@ The out-of-sample backtester is normally written by feeding training and testing
107107
# Out-of-sample backtester
108108
# Zero-cost backtesting
109109
tester_out_of_sample = Backtester(train_data=train, test_data=test, cost={'const' : 0})
110-
out_of_sample_results = tester_out_of_sample.backtest(optimizer=mean_variance, clean_weights=True)
110+
out_of_sample_results = tester_out_of_sample.backtest(optimizer=mean_variance, clean_weights=True, reopt_freq=1000)
111111

112112
# Obtaining weights and returns from the backtest
113113
out_weights = out_of_sample_results["weights"][0]
114114
return_scenario_out = out_of_sample_results["returns"]
115115
```
116116

117-
This is also a static weight backtest.
117+
This is also a constant rebalanced backtest.
118118

119119
### Uniform Portfolio Backtester
120120

121121
Since uniform equal weight has constant weights, regardless of test and train data, we can use any backtester to obtain returns. Here we use `tester_in_sample`.
122122

123123
```python
124-
uniform_results = tester_in_sample.backtest(optimizer=uniform_port)
124+
uniform_results = tester_in_sample.backtest(optimizer=uniform_port, reopt_freq=1000)
125125
uniform_weights = uniform_results["weights"][0]
126126
uniform_scenario = uniform_results["returns"]
127127
```

docs/docs/examples/the_alpha_engine.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,8 @@ alpha_strategy = SuperDuperAlphaEngine()
192192
# Initialize our backtester
193193
tester = Backtester(train_data=train, test_data=test, cost={'const': 40})
194194

195-
# Backtest with `rebalance_freq` set to 1 for daily momentum
196-
alpha_returns = tester.backtest(optimizer=alpha_strategy, rebalance_freq=1)
195+
# Backtest with `rebalance_freq` and `reopt_freq` set to 1 for daily momentum
196+
alpha_returns = tester.backtest(optimizer=alpha_strategy, rebalance_freq=1, reopt_freq=1)
197197
```
198198

199199
Upon having `alpha_returns` we can use it to plot wealth and get metrics.

docs/docs/examples/which_kelly_is_best.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ The Kelly Criterion, proposed by John Larry Kelly Jr., is the mathematically opt
66
There are numerous variants of the Kelly Criterion introduced to combat this fragile dependency, such as fractional Kelly, popularized by Ed Thorpe, and distributionally robust Kelly models. In this example, we compare several of the most well-known Kelly variants under identical out-of-sample conditions, evaluating their realized performance and wealth dynamics using `opes`.
77

88
!!! warning "Warning:"
9-
This example may be computationally heavy because of multiple optimization models running with a low `rebalance_freq=5`. If you prefer better performance, increase `rebalance_freq` to monthly (`21`) or any value much greater than `5`.
9+
This example may be computationally heavy because of multiple optimization models running with a low `reopt_freq=5`. If you prefer better performance, increase `reopt_freq` to monthly (`21`) or any value much greater than `5`.
1010

1111
---
1212

@@ -121,20 +121,20 @@ for distributionally robust variants, we utilize `KLradius` for the ambiguity ra
121121

122122
## Backtesting
123123

124-
Using the `Backtester` class from `opes`, we backtest these strategies under a constant, but high, cost of 20 bps and `rebalance_freq=5` (weekly). Oh, and we clean weights too.
124+
Using the `Backtester` class from `opes`, we backtest these strategies under a constant, but high, cost of 20 bps and `reopt_freq=5` (weekly). `rebalance_freq` is defaulted to `1`. Oh, and we clean weights too.
125125

126126
```python
127127
# A constant slippage backtest
128128
tester = Backtester(train_data=train, test_data=test, cost={'const' : 20})
129129

130130
# Obtaining returns
131131
# For now, weights and costs dont matter, so we discard them
132-
ck_scenario = tester.backtest(optimizer=classic_kelly, rebalance_freq=5, clean_weights=True)['returns']
133-
hk_scenario = tester.backtest(optimizer=half_kelly, rebalance_freq=5, clean_weights=True)['returns']
134-
qk_scenario = tester.backtest(optimizer=quarter_kelly, rebalance_freq=5, clean_weights=True)['returns']
135-
kldrk_scenario = tester.backtest(optimizer=kldr_kelly, rebalance_freq=5, clean_weights=True)['returns']
136-
kldrhk_scenario = tester.backtest(optimizer=kldr_halfkelly, rebalance_freq=5, clean_weights=True)['returns']
137-
kldrqk_scenario = tester.backtest(optimizer=kldr_quarterkelly, rebalance_freq=5, clean_weights=True)['returns']
132+
ck_scenario = tester.backtest(optimizer=classic_kelly, reopt_freq=5, clean_weights=True)['returns']
133+
hk_scenario = tester.backtest(optimizer=half_kelly, reopt_freq=5, clean_weights=True)['returns']
134+
qk_scenario = tester.backtest(optimizer=quarter_kelly, reopt_freq=5, clean_weights=True)['returns']
135+
kldrk_scenario = tester.backtest(optimizer=kldr_kelly, reopt_freq=5, clean_weights=True)['returns']
136+
kldrhk_scenario = tester.backtest(optimizer=kldr_halfkelly, reopt_freq=5, clean_weights=True)['returns']
137+
kldrqk_scenario = tester.backtest(optimizer=kldr_quarterkelly, reopt_freq=5, clean_weights=True)['returns']
138138
```
139139

140140
---

docs/docs/objectives/heuristics.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class HierarchicalRiskParity(cluster_method='average')
2323

2424
Hierarchical Risk Parity (HRP) optimization.
2525

26-
Hierarchical Risk Parity (HRP), introduced by Lpez de Prado,
26+
Hierarchical Risk Parity (HRP), introduced by Lopez de Prado,
2727
is a portfolio construction methodology that allocates capital
2828
through hierarchical clustering and recursive risk balancing
2929
rather than direct optimization of a scalar objective. HRP

opes/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Version Log
2-
__version__ = "0.10.0"
2+
__version__ = "0.11.0"
33

44
# Backtester easy import
55
from .backtester import Backtester

0 commit comments

Comments
 (0)