import pandas as pd
import numpy as np
import math
from itables import show
260) np.random.seed(
Objective
To be able to speed up the system evaluation in a robust manner, here is the template that will be used for develop trading strategy. This notebook will contain all the code required to translate from idea to prelim test. At the end, the strategy will be saved and send to the more robust testing and evaluation platform.
Here is the general workflow of formulating and testing a strategy:
I will use a n-day breakout strategy for demonstration. We will optimise the parameter \(n\) in the evaluation platform.
Developing the strategy
A strategy will have the following components:
Market Selection: Balance between robustness and adaptation
Entry rules
Exit rules
- Stop and reverse, Technical-based exits, Breakeven stops, Stop-losses, Profit targets, Trailing stops
Timeframe/bar size: Daily, Weekly, Monthly. Intentially exclude intra-day to fit my requirements
Consideration between noise and sensitivity
Consideration between more trades and less trades
Consideration of slippage and transaction costs
Programming Consideration: Can you program the strategy?
Data Consideration
How much data should you use?
Futures: Should you use continuous contract data?
Forex: How do you test with Forex data?
Market Selection
Equity:
US: IYY, SPY, QQQ, IWM
Asia: FXI, EWJ, VNM, INDA, EWY, EWT
Europe: EWQ, EWI, EWG, EWU
South America: ARGT, EWW, EWZ
Emerging markets: ACWX, EEM
Bonds: SHY(1-3 years TBond), TLT (20+ Year Treasury Bond)
Commodities: GC, SI, HG, CL, NG, HO, RB, CT, ZC, ZS, ZL, ZW, SB
Forex: 6A, 6B, 6C, 6E, 6J, 6S, 6N, DX
= [
universe "IYY", "SPY", "QQQ", "IWM",
"FXI", "EWJ", "VNM", "INDA", "EWY", "EWT",
"EWQ", "EWI", "EWG", "EWU",
"ARGT", "EWW", "EWZ",
"SHY", "TLT",
"GC=F", "SI=F", "HG=F", "CL=F", "NG=F", "HO=F", "RB=F", "CT=F", "ZC=F", "ZS=F",
"ZL=F", "ZW=F", "SB=F",
"6A=F", "6B=F", "6C=F", "6E=F", "6J=F", "6S=F", "6N=F", "DX=F"
]
import yfinance as yf
= yf.Tickers(universe)
tickers
# SPY will be used as sample data for prelim test
= tickers.tickers['SPY'].history(start = "2008-01-01", end = "2025-02-28") SPY
Strategy Template
The following block is the strategy template. In the following test, we will subclass the major class BreakoutStrategy
to modify the input and output rules.
from backtesting.lib import crossover
from backtesting import Strategy
from strategy.BaseStrategy import BaseStrategy
from strategy.Indicators import HighestInNBars, LowestInNBars
import talib
class BreakoutStrategy(BaseStrategy):
"""
Base Strategy Class
"""
# Parameters that can be optimised
= 20
entry_n = 5
exit_n = 14
atr_period = 2
initial_stop_atr_multiplier = 2
trailing_stop_atr_multiplier
# Money management
= 0.01 # 1%
risk_per_trade
def init(self):
self.highest_close_entry_n = self.I(HighestInNBars, self.data.High, self.entry_n)
self.lowest_close_entry_n = self.I(LowestInNBars, self.data.Low, self.entry_n)
self.highest_close_exit_n = self.I(HighestInNBars, self.data.High, self.exit_n)
self.lowest_close_exit_n = self.I(LowestInNBars, self.data.Low, self.exit_n)
self.atr = self.I(talib.ATR,
self.data.High,
self.data.Low,
self.data.Close,
=14)
timeperiodself.risk_unit = self.equity * self.risk_per_trade
print(self.risk_unit)
self.long_entry_signal = self.data.Close > self.highest_close_entry_n
self.short_entry_signal = self.data.Close < self.lowest_close_entry_n
def store_indicators(self):
# Store the data you want to display later in a DataFrame
= pd.DataFrame({
signals_df 'Date': self.data.index,
'Close': self.data.Close,
'highest_n_entry': self.highest_close_entry_n,
'lowest_n_entry': self.lowest_close_entry_n,
'long_highest_n_exit': self.highest_close_exit_n,
'short_lowest_n_exit': self.lowest_close_exit_n,
'atr': self.atr,
'long_entry_signal': self.long_entry_signal,
'short_entry_signal': self.short_entry_signal
})return signals_df
def entry_rule(self):
"""
Entry Long if price breaks out from last n high.
"""
if self.data.Close > self.highest_close_entry_n:
return 1
elif self.data.Close < self.lowest_close_entry_n:
return -1
else:
return 0
def exit_rule(self):
"""
Here we define the exit rule
"""
return 0
def update_trailing_stop(self):
# Use fixed ATR trailing stop
for trade in self.trades:
if trade.is_long:
= self.lowest_close_exit_n[-1] - self.trailing_stop_atr_multiplier * self.atr[-1]
trailing_sl = max(trade.sl, trailing_sl)
trade.sl else: # short
= self.highest_close_exit_n[-1] + self.trailing_stop_atr_multiplier * self.atr[-1]
trailing_sl = min(trade.sl, trailing_sl)
trade.sl
def next(self):
if not self.position:
# Get entry price - Entry at market
= self.data.Close[-1]
last_close = self.atr[-1]
last_atr
if self.entry_rule() == 1:
= last_close - self.initial_stop_atr_multiplier * last_atr
sl = self.get_position_size(last_close, sl)
size = self.get_tp_level(last_close, size, 2)
tp self.buy(size=size, sl=sl, tp=tp)
elif self.entry_rule() == -1:
= last_close + self.initial_stop_atr_multiplier * last_atr
sl = self.get_position_size(last_close, sl)
size = self.get_tp_level(last_close, size, False, 2)
tp self.sell(size=size, sl=sl, tp=tp)
else:
self.update_trailing_stop()
if self.exit_rule() == 1:
self.position.close()
Entry rules
- Entry if the price break out from past 20 day new high
def entry_rule(self):
"""
Entry Long if price breaks out from last n high.
"""
if self.data.Close > self.highest_close_entry_n:
return 1
elif self.data.Close < self.lowest_close_entry_n:
return -1
else:
return 0
Exit rules
This strategy will only use stop loss and trailing stop as exit rules.
Initial stop at entry - 2 x ATR
Last 5 day lowest low - 1 ATR
At this stage, this is define within the self.buy(sl=..., tp=...)
and self.sell(sl=..., tp=...)
function
def next(self):
if not self.position:
# Get entry price - Entry at market
= self.data.Close[-1]
last_close = self.atr[-1]
last_atr
if self.entry_rule() == 1:
= last_close - self.initial_stop_atr_multiplier * last_atr
sl = self.get_position_size(last_close, sl)
size
self.buy(size=size, sl=sl, tp=tp)
elif self.entry_rule() == -1:
= last_close + self.initial_stop_atr_multiplier * last_atr
sl = self.get_position_size(last_close, sl)
size
self.sell(size=size, sl=sl, tp=tp)
else:
self.update_trailing_stop()
if self.exit_rule() == 1:
self.position.close()
Prelim test
Objective
The objective of Prelim test is to see if the system has potential. If the system survive perlim test, then it makes sense to progress to more rigorous full test.
Data Consideration
In prelim test, we will only run a strategy on a set of data one time, and one time only. If it works, great, but if it doesn’t work, we should just move on to the next data set or instrument.
If we have 10 years of data for full test, in prelim test we will just use 1/5 of the data. We should try to squeeze it as little as possible, while still getting enough trades to be statistically meaningful. We can take the section of data random, by not using the same data all the time or favouring any particular years.
Coverage
Prelim test will cover Entry Test, Exit Test, and Grid Test.
- Random Entry and Exit test
Prelim Test 1 - Entry Test
Objective: We want to know whether the entry has any usefulness
Options:
Fixed-stop and target exit: Exit based on a fixed trailing stop of 4xATR of previous close, and TP at 2R profit
Fixed-bar exit: Exit always at the 10th bar
Random exit: Flip a coin. If head, then exit.
Evaluation Criteria:
Winning Percentage: If without commission and slippage, a good strategy should have over 50% win rate
Expectancy: We could argue that for some strategies, win rate tend to be lower. We can also check the expectancy and that should be positive.
We don’t need to worry about drawdowns or any other metrics in Prelim test
= 600 RANDOM_TEST_PERIOD_DAYS
class BreakoutStrategyEntryTest1(BreakoutStrategy):
def init(self):
super().init()
# Trailing stop ATR multiplier
self.initial_stop_atr_multiplier = 2
self.trailing_stop_atr_multiplier = 2
self.orders_log = []
def next(self):
= None
order
if not self.position:
# Get entry price - Entry at market
= self.data.Close[-1]
last_close = self.atr[-1]
last_atr
if self.entry_rule() == 1:
= self.lowest_close_exit_n[-1] - self.initial_stop_atr_multiplier * last_atr
initial_sl = self.get_position_size(last_close, initial_sl)
size # size = 1
= self.get_tp_level(last_close, size, 2)
tp
= self.buy(size=size, sl=initial_sl, tp=tp)
order
elif self.entry_rule() == -1:
= self.highest_close_exit_n[-1] + self.initial_stop_atr_multiplier * last_atr
initial_sl = self.get_position_size(last_close, initial_sl)
size # size = 1
= self.get_tp_level(last_close, size, False, 2)
tp
= self.sell(size=size, sl=initial_sl, tp=tp)
order
else:
self.update_trailing_stop()
Show the code
from backtesting import Backtest
= 0
num_trades
while(num_trades == 0):
= np.random.choice(SPY.index)
rnd_date = SPY.loc[rnd_date:rnd_date + pd.Timedelta(days=RANDOM_TEST_PERIOD_DAYS), :]
selection
= Backtest(selection,
bt
BreakoutStrategyEntryTest1,=10_000)
cash
= bt.run()
stats = stats._strategy.store_indicators()
signals_df # orders = stats._strategy.orders_log
= stats["# Trades"] num_trades
100.0
bt.plot()
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/bokeh/util/serialization.py:242: UserWarning: no explicit representation of timezones available for np.datetime64
return convert(array.astype("datetime64[us]"))
Show the code
= pd.DataFrame(stats) prelim1_stats
Show the code
# Show all trades
"_trades"]) show(stats[
Size | EntryBar | ExitBar | EntryPrice | ExitPrice | SL | TP | PnL | ReturnPct | EntryTime | ExitTime | Duration | Tag | Entry_HighestIn…(H,20) | Exit_HighestIn…(H,20) | Entry_LowestInN…(L,20) | Exit_LowestInN…(L,20) | Entry_HighestIn…(H,5) | Exit_HighestIn…(H,5) | Entry_LowestInN…(L,5) | Exit_LowestInN…(L,5) | Entry_ATR(H,L,C,14) | Exit_ATR(H,L,C,14) |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Loading ITables v2.2.5 from the internet... (need help?) |
class BreakoutStrategyEntryTest2(BreakoutStrategy):
= None # To track the bar where we entered
entry_index = 10 # Exit after 10 bars if no profit
num_bars_after_entry
def next(self):
if not self.position:
# Get entry price - Entry at market
= self.data.Close[-1]
last_close = self.atr[-1]
last_atr
if self.entry_rule() == 1:
= self.lowest_close_exit_n[-1] - self.initial_stop_atr_multiplier * last_atr
initial_sl = self.get_position_size(last_close, initial_sl)
size # size = 1
= self.get_tp_level(last_close, size, 2)
tp
= self.buy(size=size, sl=initial_sl, tp=tp)
order
elif self.entry_rule() == -1:
= self.highest_close_exit_n[-1] + self.initial_stop_atr_multiplier * last_atr
initial_sl = self.get_position_size(last_close, initial_sl)
size # size = 1
= self.get_tp_level(last_close, size, False, 2)
tp
= self.sell(size=size, sl=initial_sl, tp=tp)
order
else:
if (self.entry_index is not None and (self.data.index[-1] - self.entry_index).days >= self.num_bars_after_entry):
self.position.close() # Close all positions
self.entry_index = None # Reset entry tracking
Show the code
= 0
num_trades
while(num_trades == 0):
= np.random.choice(SPY.index)
rnd_date = SPY.loc[rnd_date:rnd_date + pd.Timedelta(days=RANDOM_TEST_PERIOD_DAYS), :]
selection
= Backtest(selection,
bt
BreakoutStrategyEntryTest2,=10_000)
cash= bt.run()
stats
= stats["# Trades"] num_trades
100.0
bt.plot()
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/bokeh/util/serialization.py:242: UserWarning: no explicit representation of timezones available for np.datetime64
return convert(array.astype("datetime64[us]"))
Show the code
= pd.DataFrame(stats) prelim2_stats
Show the code
# Show all trades
"_trades"]) show(stats[
Size | EntryBar | ExitBar | EntryPrice | ExitPrice | SL | TP | PnL | ReturnPct | EntryTime | ExitTime | Duration | Tag | Entry_HighestIn…(H,20) | Exit_HighestIn…(H,20) | Entry_LowestInN…(L,20) | Exit_LowestInN…(L,20) | Entry_HighestIn…(H,5) | Exit_HighestIn…(H,5) | Entry_LowestInN…(L,5) | Exit_LowestInN…(L,5) | Entry_ATR(H,L,C,14) | Exit_ATR(H,L,C,14) |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Loading ITables v2.2.5 from the internet... (need help?) |
class BreakoutStrategyEntryTest3(BreakoutStrategy):
= None # To track the bar where we entered
entry_index
def next(self):
if not self.position:
# Get entry price - Entry at market
= self.data.Close[-1]
last_close = self.atr[-1]
last_atr
if self.entry_rule() == 1:
= self.lowest_close_exit_n[-2] - self.initial_stop_atr_multiplier * last_atr
initial_sl = self.get_position_size(last_close, initial_sl)
size # size = 1
= self.get_tp_level(last_close, size, 2)
tp
= self.buy(size=size, sl=initial_sl, tp=tp)
order
elif self.entry_rule() == -1:
= self.highest_close_exit_n[-2] + self.initial_stop_atr_multiplier * last_atr
initial_sl = self.get_position_size(last_close, initial_sl)
size # size = 1
= self.get_tp_level(last_close, size, False, 2)
tp
= self.sell(size=size, sl=initial_sl, tp=tp)
order
else:
# Random Exit
if (np.random.uniform(0, 1) > 0.5):
self.position.close()
Show the code
= 0
num_trades
while(num_trades == 0):
= np.random.choice(SPY.index)
rnd_date = SPY.loc[rnd_date:rnd_date + pd.Timedelta(days=RANDOM_TEST_PERIOD_DAYS), :]
selection
= Backtest(selection,
bt
BreakoutStrategyEntryTest3,=10_000)
cash= bt.run()
stats
= stats["# Trades"] num_trades
100.0
bt.plot()
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/bokeh/util/serialization.py:242: UserWarning: no explicit representation of timezones available for np.datetime64
return convert(array.astype("datetime64[us]"))
= pd.DataFrame(stats) prelim3_stats
# Show all trades
"_trades"]) show(stats[
Size | EntryBar | ExitBar | EntryPrice | ExitPrice | SL | TP | PnL | ReturnPct | EntryTime | ExitTime | Duration | Tag | Entry_HighestIn…(H,20) | Exit_HighestIn…(H,20) | Entry_LowestInN…(L,20) | Exit_LowestInN…(L,20) | Entry_HighestIn…(H,5) | Exit_HighestIn…(H,5) | Entry_LowestInN…(L,5) | Exit_LowestInN…(L,5) | Entry_ATR(H,L,C,14) | Exit_ATR(H,L,C,14) |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Loading ITables v2.2.5 from the internet... (need help?) |
Result
Win Rate | Expectancy | |
---|---|---|
Fixed Stop and Target | 38.461538 | -1.802268 |
10-bar Exit | 100.000000 | 17.930682 |
Random Exit | 51.219512 | -0.034099 |
Prelim Test 2 - Exit Test
Objective: We want to know whether the exit has any usefulness
Options:
Similar-approach Entry: If Trend-following, use n-bar breakout; If countertrend, use RSI entry
Random Entry
Since our entry strategy has been using n-bar breakout. We will just test Random Entry. Our exit rule is 4xATR of previous close
class BreakoutStrategyExitTest1(BreakoutStrategy):
# Trailing stop ATR multiplier
= 4
trailing_stop_atr_multiplier
def entry_rule(self):
"""
Random Entry rule
Flip a coin, if head, Flip again to enter either long or short
"""
if np.random.uniform(0, 1) > 0.5:
return 1 if np.random.uniform(0, 1) > 0.5 else -1
else:
return 0
def update_trailing_stop(self):
# Use fixed ATR trailing stop
for trade in self.trades:
if trade.is_long:
= self.data.Close[-2] - self.trailing_stop_atr_multiplier * self.atr[-2]
trailing_sl = max(trade.sl, trailing_sl)
trade.sl else: # short
= self.data.Close[-2] + self.trailing_stop_atr_multiplier * self.atr[-2]
trailing_sl = min(trade.sl, trailing_sl)
trade.sl
def next(self):
if not self.position:
# Get entry price - Entry at market
= self.data.Close[-1]
last_close = self.atr[-1]
last_atr
if self.entry_rule() == 1:
= last_close - self.initial_stop_atr_multiplier * last_atr
sl = self.get_position_size(last_close, sl)
size = self.get_tp_level(last_close, size, 2)
tp
self.buy(size=size, sl=sl, tp=tp)
elif self.entry_rule() == -1:
= last_close + self.initial_stop_atr_multiplier * last_atr
sl = self.get_position_size(last_close, sl)
size = self.get_tp_level(last_close, size, False, 2)
tp
self.sell(size=size, sl=sl, tp=tp)
else:
self.update_trailing_stop()
Show the code
= 0
num_trades
while(num_trades == 0):
= np.random.choice(SPY.index)
rnd_date = SPY.loc[rnd_date:rnd_date + pd.Timedelta(days=RANDOM_TEST_PERIOD_DAYS), :]
selection
= Backtest(selection,
bt
BreakoutStrategyExitTest1,=10_000)
cash= bt.run()
stats
= stats["# Trades"] num_trades
100.0
bt.plot()
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/bokeh/util/serialization.py:242: UserWarning: no explicit representation of timezones available for np.datetime64
return convert(array.astype("datetime64[us]"))
= pd.DataFrame(stats) exit_prelim1_stats
# Show all trades
"_trades"]) show(stats[
Size | EntryBar | ExitBar | EntryPrice | ExitPrice | SL | TP | PnL | ReturnPct | EntryTime | ExitTime | Duration | Tag | Entry_HighestIn…(H,20) | Exit_HighestIn…(H,20) | Entry_LowestInN…(L,20) | Exit_LowestInN…(L,20) | Entry_HighestIn…(H,5) | Exit_HighestIn…(H,5) | Entry_LowestInN…(L,5) | Exit_LowestInN…(L,5) | Entry_ATR(H,L,C,14) | Exit_ATR(H,L,C,14) |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Loading ITables v2.2.5 from the internet... (need help?) |
0 | |
---|---|
Start | 2018-05-04 00:00:00-04:00 |
End | 2019-12-24 00:00:00-05:00 |
Duration | 599 days 01:00:00 |
Exposure Time [%] | 84.541063 |
Equity Final [$] | 9122.465625 |
Equity Peak [$] | 10387.226181 |
Return [%] | -8.775344 |
Buy & Hold Return [%] | 20.843784 |
Return (Ann.) [%] | -5.437163 |
Volatility (Ann.) [%] | 5.00404 |
CAGR [%] | -3.789973 |
Sharpe Ratio | -1.086555 |
Sortino Ratio | -1.300484 |
Calmar Ratio | -0.36223 |
Alpha [%] | -12.268918 |
Beta | 0.167608 |
Max. Drawdown [%] | -15.010238 |
Avg. Drawdown [%] | -2.179718 |
Max. Drawdown Duration | 474 days 00:00:00 |
Avg. Drawdown Duration | 61 days 00:00:00 |
# Trades | 32 |
Win Rate [%] | 21.875 |
Best Trade [%] | 10.436857 |
Worst Trade [%] | -5.211667 |
Avg. Trade [%] | -0.686613 |
Max. Trade Duration | 55 days 00:00:00 |
Avg. Trade Duration | 15 days 00:00:00 |
Profit Factor | 0.626365 |
Expectancy [%] | -0.632254 |
SQN | -1.187401 |
Kelly Criterion | -0.131341 |
_strategy | BreakoutStrategyExitTest1 |
_equity_curve | Equity Drawd... |
_trades | Size EntryBar ExitBar EntryPrice Exit... |
Prelim Test 3 - Grid Test
In Grid test, we will do limited optimisation on the parameters.
Entry Rules: We will test the number of days for the breakout. [5, 15, 30, 60, 90, 120]
Exit Rules: We will test the multiplier of ATR at the trailing stop [1, 2, 3, 4, 99 (No stop loss)], and the profit taking multiplier [2, 3, 4, 5]
Evaluation Criteria
We would expect a good strategy will have >= 70% of iterations to be profitable
In the range between 30% to 70%, we will see if there’s anything worth to work on
Below 30%, either do the flip side, or discard the system.
import types
import sys
from strategy.BreakoutStrategy import BreakoutStrategy
# Parameters to test
= [5, 15, 30, 60, 90, 120]
entry_n_parameters = [5, 10, 15, 20]
exit_n_parameters = [1, 3, 4, 99]
exit_rule_trialing_stop_parameters
= 0
num_trades
if '__spec__' not in dir():
'__main__'].__spec__ = types.SimpleNamespace()
sys.modules[
while(num_trades == 0):
= np.random.choice(SPY.index)
rnd_date = SPY.loc[rnd_date:rnd_date + pd.Timedelta(days=RANDOM_TEST_PERIOD_DAYS), :]
selection =None), inplace=True)
selection.set_index(selection.index.tz_localize(tz
= Backtest(selection,
bt_opt
BreakoutStrategy,=10_000)
cash
= bt_opt.optimize(
stats, heatmap =entry_n_parameters,
entry_n=exit_n_parameters,
exit_n=exit_rule_trialing_stop_parameters,
trailing_stop_atr_multiplier='Sharpe Ratio',
maximize=200,
max_tries=42,
random_state=True)
return_heatmap
= stats["# Trades"] num_trades
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/numpy/lib/_function_base_impl.py:552: RuntimeWarning: Mean of empty slice.
avg = a.mean(axis, **keepdims_kw)
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/numpy/_core/_methods.py:137: RuntimeWarning: invalid value encountered in divide
ret = um.true_divide(
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_stats.py:157: RuntimeWarning: Degrees of freedom <= 0 for slice
cov_matrix = np.cov(equity_log_returns, market_log_returns)
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/numpy/lib/_function_base_impl.py:2894: RuntimeWarning: divide by zero encountered in divide
c *= np.true_divide(1, fact)
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/numpy/lib/_function_base_impl.py:2894: RuntimeWarning: invalid value encountered in multiply
c *= np.true_divide(1, fact)
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
0 | |
---|---|
Start | 2012-12-14 00:00:00 |
End | 2014-08-06 00:00:00 |
Duration | 600 days 00:00:00 |
Exposure Time [%] | 51.815981 |
Equity Final [$] | 10025.487778 |
Equity Peak [$] | 10030.943649 |
Return [%] | 0.254878 |
Buy & Hold Return [%] | 26.176484 |
Return (Ann.) [%] | 0.155441 |
Volatility (Ann.) [%] | 0.137324 |
CAGR [%] | 0.10697 |
Sharpe Ratio | 1.131934 |
Sortino Ratio | 1.577773 |
Calmar Ratio | 1.720393 |
Alpha [%] | -0.032248 |
Beta | 0.010969 |
Max. Drawdown [%] | -0.090352 |
Avg. Drawdown [%] | -0.020682 |
Max. Drawdown Duration | 150 days 00:00:00 |
Avg. Drawdown Duration | 16 days 00:00:00 |
# Trades | 1 |
Win Rate [%] | 100.0 |
Best Trade [%] | 14.15988 |
Worst Trade [%] | 14.15988 |
Avg. Trade [%] | 14.15988 |
Max. Trade Duration | 308 days 00:00:00 |
Avg. Trade Duration | 308 days 00:00:00 |
Profit Factor | NaN |
Expectancy [%] | 14.15988 |
SQN | NaN |
Kelly Criterion | NaN |
_strategy | BreakoutStrategy(entry_n=60,exit_n=15,trailing... |
_equity_curve | Equity DrawdownPct Drawdown... |
_trades | Size EntryBar ExitBar EntryPrice ExitPr... |
= heatmap.groupby(['entry_n', 'exit_n']).mean().unstack()
hm = hm[::-1]
hm hm
exit_n | 5 | 10 | 15 | 20 |
---|---|---|---|---|
entry_n | ||||
120 | 0.300914 | 0.380769 | 0.411729 | 0.404996 |
90 | 0.283435 | 0.286116 | 0.416319 | 0.549208 |
60 | 0.323398 | 0.451844 | 0.573877 | 0.694695 |
30 | -2.478173 | -2.505373 | -2.929729 | -2.522413 |
15 | -1.920467 | -2.045167 | -2.291447 | -2.301150 |
5 | -1.969672 | -2.044275 | -1.960377 | -1.669571 |
bt_opt.plot()
Walk-Forward test & Optimisation
Once we have passed the prelim test, then we can run the walk-forward test to confirm the robustness of the strategy. We will basically test a grid of values for the parameters that we are using in the rules.
Let’s first clarify some terminologies that we will be using in Walk-Forward Test
- In-Period
-
This is the chunk of historical data that will be optimised
- Out-Period
-
This is the chunk of historical data that will be evaluated using optimised parameters from the adjacent in-period
- Fitness Factor
-
This is the criterion used to determine the “best” result, allowing us to select the optimised parametrs
- Anchored/Unanchored test
-
This tells us whether or not the in-period start date shifts with time (Un-anchored), or if the start date is always the same (Anchored)
from strategy.utils import walk_forward, make_walkforward_report
from strategy.BreakoutStrategy import BreakoutStrategy
# Parameters to test
= [5, 15, 30, 60, 90, 120]
entry_n_parameters = [1, 3, 5, 10]
exit_n_parameters = [1, 3, 4, 99]
exit_rule_trialing_stop_parameters
= walk_forward(BreakoutStrategy,
opt_stats, walkforward_stats =SPY,
data="Sharpe Ratio",
objective=10_000,
cash=100,
chunk_size=10,
training_chunk=5,
validation_chunk=entry_n_parameters,
entry_n=exit_n_parameters,
exit_n=exit_rule_trialing_stop_parameters) trailing_stop_atr_multiplier
{'entry_n': [5, 15, 30, 60, 90, 120], 'exit_n': [1, 3, 5, 10], 'trailing_stop_atr_multiplier': [1, 3, 4, 99]}
Training chunk 0: from 0 to 1000
Testing chunk 0: from 1000 to 1500
Training chunk 1: from 500 to 1500
Testing chunk 1: from 1500 to 2000
Training chunk 2: from 1000 to 2000
Testing chunk 2: from 2000 to 2500
Training chunk 3: from 1500 to 2500
Testing chunk 3: from 2500 to 3000
Training chunk 4: from 2000 to 3000
Testing chunk 4: from 3000 to 3500
Training chunk 5: from 2500 to 3500
Testing chunk 5: from 3500 to 4000
show(make_walkforward_report(opt_stats, walkforward_stats))
start | end | entry_n | exit_n | trailing_stop_atr_multiplier | Walkforward_Start | Walkforward_end | # Trades | Return [%] | Return (Ann.) [%] | Sharpe Ratio |
---|---|---|---|---|---|---|---|---|---|---|
Loading ITables v2.2.5 from the internet... (need help?) |
= make_walkforward_report(opt_stats, walkforward_stats)
stats
= stats.loc[:, ["Walkforward_Start",
walkforward_params "Walkforward_end",
"entry_n",
"exit_n",
"trailing_stop_atr_multiplier"]]
walkforward_params
Walkforward_Start | Walkforward_end | entry_n | exit_n | trailing_stop_atr_multiplier | |
---|---|---|---|---|---|
0 | 2011-12-19 | 2013-12-13 | 60 | 3 | 3 |
1 | 2013-12-16 | 2015-12-09 | 90 | 1 | 1 |
2 | 2015-12-10 | 2017-12-04 | 120 | 1 | 99 |
3 | 2017-12-05 | 2019-11-29 | 60 | 1 | 1 |
4 | 2019-12-02 | 2021-11-23 | 90 | 1 | 99 |
5 | 2021-11-24 | 2023-11-20 | 120 | 1 | 1 |
# Full optimisation Strategy
# Parameters to test
= [5, 15, 30, 60, 90, 120]
entry_n_parameters = [1, 3, 5, 10]
exit_n_parameters = [1, 3, 4, 99]
exit_rule_trialing_stop_parameters
= SPY
data =None), inplace=True)
data.set_index(data.index.tz_localize(tz= data.loc["2011-12-19":]
selection
= Backtest(selection,
bt_opt
BreakoutStrategy,=10_000,
cash=True)
finalize_trades
= bt_opt.optimize(
stats_opt =entry_n_parameters,
entry_n=exit_n_parameters,
exit_n=exit_rule_trialing_stop_parameters,
trailing_stop_atr_multiplier='Sharpe Ratio',
maximize=200,
max_tries=42,
random_state=False)
return_heatmap
print(stats_opt._strategy)
= Backtest(selection,
bt_opt
BreakoutStrategy,=10_000,
cash=True)
finalize_trades
= bt_opt.run(entry_n=5,
stats_opt =1,
exit_n=99)
trailing_stop_atr_multiplier
pd.DataFrame(stats_opt)
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/numpy/lib/_function_base_impl.py:552: RuntimeWarning: Mean of empty slice.
avg = a.mean(axis, **keepdims_kw)
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/numpy/_core/_methods.py:137: RuntimeWarning: invalid value encountered in divide
ret = um.true_divide(
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_stats.py:157: RuntimeWarning: Degrees of freedom <= 0 for slice
cov_matrix = np.cov(equity_log_returns, market_log_returns)
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/numpy/lib/_function_base_impl.py:2894: RuntimeWarning: divide by zero encountered in divide
c *= np.true_divide(1, fact)
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/numpy/lib/_function_base_impl.py:2894: RuntimeWarning: invalid value encountered in multiply
c *= np.true_divide(1, fact)
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
warnings.warn('Jupyter Notebook detected. '
BreakoutStrategy(entry_n=5,exit_n=1,trailing_stop_atr_multiplier=99)
0 | |
---|---|
Start | 2011-12-19 00:00:00 |
End | 2025-02-27 00:00:00 |
Duration | 4819 days 00:00:00 |
Exposure Time [%] | 99.397046 |
Equity Final [$] | 10491.678424 |
Equity Peak [$] | 10507.710214 |
Return [%] | 4.916784 |
Buy & Hold Return [%] | 473.348476 |
Return (Ann.) [%] | 0.365312 |
Volatility (Ann.) [%] | 0.484519 |
CAGR [%] | 0.251308 |
Sharpe Ratio | 0.753969 |
Sortino Ratio | 1.050367 |
Calmar Ratio | 0.338704 |
Alpha [%] | -8.036503 |
Beta | 0.027365 |
Max. Drawdown [%] | -1.078559 |
Avg. Drawdown [%] | -0.044021 |
Max. Drawdown Duration | 709 days 00:00:00 |
Avg. Drawdown Duration | 18 days 00:00:00 |
# Trades | 1 |
Win Rate [%] | 100.0 |
Best Trade [%] | 475.596319 |
Worst Trade [%] | 475.596319 |
Avg. Trade [%] | 475.596319 |
Max. Trade Duration | 4788 days 00:00:00 |
Avg. Trade Duration | 4788 days 00:00:00 |
Profit Factor | NaN |
Expectancy [%] | 475.596319 |
SQN | NaN |
Kelly Criterion | NaN |
_strategy | BreakoutStrategy(entry_n=5,exit_n=1,trailing_s... |
_equity_curve | Equity DrawdownPct Drawdown... |
_trades | Size EntryBar ExitBar EntryPrice ExitP... |
Now, we have to re-code the strategy such that it adapts to the optimised walk-forward parameters. (Note: This part NEEDS to be re-factored s the current way is very inefficient)
Show the code
# Walkaway strategy for comparison
class BreakoutStrategyWalkaway(BreakoutStrategy):
def init(self):
self.highest_close_entry_5 = self.I(HighestInNBars, self.data.High, 5)
self.highest_close_entry_30 = self.I(HighestInNBars, self.data.High, 30)
self.highest_close_entry_60 = self.I(HighestInNBars, self.data.High, 60)
self.highest_close_entry_90 = self.I(HighestInNBars, self.data.High, 90)
self.lowest_close_entry_5 = self.I(LowestInNBars, self.data.Low, 5)
self.lowest_close_entry_30 = self.I(LowestInNBars, self.data.Low, 30)
self.lowest_close_entry_60 = self.I(LowestInNBars, self.data.Low, 60)
self.lowest_close_entry_90 = self.I(LowestInNBars, self.data.Low, 90)
self.highest_close_exit_1 = self.I(HighestInNBars, self.data.High, 1)
self.highest_close_exit_5 = self.I(HighestInNBars, self.data.High, 5)
self.highest_close_exit_10 = self.I(HighestInNBars, self.data.High, 10)
self.lowest_close_exit_1 = self.I(LowestInNBars, self.data.Low, 1)
self.lowest_close_exit_5 = self.I(LowestInNBars, self.data.Low, 5)
self.lowest_close_exit_10 = self.I(LowestInNBars, self.data.Low, 10)
self.atr = self.I(talib.ATR,
self.data.High,
self.data.Low,
self.data.Close,
=14)
timeperiodself.risk_unit = self.equity * self.risk_per_trade
def update_trailing_stop(self, long_exit_ind, short_exit_ind, trailing_stop_multiplier):
# Use fixed ATR trailing stop
for trade in self.trades:
if trade.is_long:
= long_exit_ind[-1] - trailing_stop_multiplier * self.atr[-1]
trailing_sl = max(trailing_sl, 0)
trailing_sl = max(trade.sl, trailing_sl)
trade.sl else: # short
= short_exit_ind[-1] + trailing_stop_multiplier * self.atr[-1]
trailing_sl = max(trailing_sl, 0)
trailing_sl = min(trade.sl, trailing_sl)
trade.sl
def next(self):
= None
long_entry_ind if (self.data.index[-1] < pd.Timestamp("2013-12-13")):
= self.highest_close_entry_90
long_entry_ind elif (self.data.index[-1] < pd.Timestamp("2015-12-09")):
= self.highest_close_entry_90
long_entry_ind elif (self.data.index[-1] < pd.Timestamp("2017-12-04")):
= self.highest_close_entry_30
long_entry_ind elif (self.data.index[-1] < pd.Timestamp("2019-11-29")):
= self.highest_close_entry_5
long_entry_ind elif (self.data.index[-1] < pd.Timestamp("2021-11-23")):
= self.highest_close_entry_90
long_entry_ind elif (self.data.index[-1] < pd.Timestamp("2023-11-20")):
= self.highest_close_entry_60
long_entry_ind else:
= self.highest_close_entry_60
long_entry_ind
= None
short_entry_ind if (self.data.index[-1] < pd.Timestamp("2013-12-13")):
= self.highest_close_entry_90
short_entry_ind elif (self.data.index[-1] < pd.Timestamp("2015-12-09")):
= self.highest_close_entry_90
short_entry_ind elif (self.data.index[-1] < pd.Timestamp("2017-12-04")):
= self.highest_close_entry_30
short_entry_ind elif (self.data.index[-1] < pd.Timestamp("2019-11-29")):
= self.highest_close_entry_5
short_entry_ind elif (self.data.index[-1] < pd.Timestamp("2021-11-23")):
= self.highest_close_entry_90
short_entry_ind elif (self.data.index[-1] < pd.Timestamp("2023-11-20")):
= self.highest_close_entry_60
short_entry_ind else:
= self.highest_close_entry_60
short_entry_ind
= None
long_exit_ind if (self.data.index[-1] < pd.Timestamp("2013-12-13")):
= self.lowest_close_exit_1
long_exit_ind elif (self.data.index[-1] < pd.Timestamp("2015-12-09")):
= self.lowest_close_exit_5
long_exit_ind elif (self.data.index[-1] < pd.Timestamp("2017-12-04")):
= self.lowest_close_exit_1
long_exit_ind elif (self.data.index[-1] < pd.Timestamp("2019-11-29")):
= self.lowest_close_exit_10
long_exit_ind elif (self.data.index[-1] < pd.Timestamp("2021-11-23")):
= self.lowest_close_exit_5
long_exit_ind elif (self.data.index[-1] < pd.Timestamp("2023-11-20")):
= self.lowest_close_exit_5
long_exit_ind else:
= self.lowest_close_exit_5
long_exit_ind
= None
short_exit_ind if (self.data.index[-1] < pd.Timestamp("2013-12-13")):
= self.lowest_close_exit_1
short_exit_ind elif (self.data.index[-1] < pd.Timestamp("2015-12-09")):
= self.lowest_close_exit_5
short_exit_ind elif (self.data.index[-1] < pd.Timestamp("2017-12-04")):
= self.lowest_close_exit_1
short_exit_ind elif (self.data.index[-1] < pd.Timestamp("2019-11-29")):
= self.lowest_close_exit_10
short_exit_ind elif (self.data.index[-1] < pd.Timestamp("2021-11-23")):
= self.lowest_close_exit_5
short_exit_ind elif (self.data.index[-1] < pd.Timestamp("2023-11-20")):
= self.lowest_close_exit_5
short_exit_ind else:
= self.lowest_close_exit_5
short_exit_ind
= None
trailing_stop_multiplier if (self.data.index[-1] < pd.Timestamp("2013-12-13")):
= 4
trailing_stop_multiplier elif (self.data.index[-1] < pd.Timestamp("2015-12-09")):
= 99
trailing_stop_multiplier elif (self.data.index[-1] < pd.Timestamp("2017-12-04")):
= 99
trailing_stop_multiplier elif (self.data.index[-1] < pd.Timestamp("2019-11-29")):
= 99
trailing_stop_multiplier elif (self.data.index[-1] < pd.Timestamp("2021-11-23")):
= 4
trailing_stop_multiplier elif (self.data.index[-1] < pd.Timestamp("2023-11-20")):
= 4
trailing_stop_multiplier else:
= 4
trailing_stop_multiplier
assert long_entry_ind is not None
assert short_entry_ind is not None
assert long_exit_ind is not None
assert short_exit_ind is not None
assert trailing_stop_multiplier is not None
if not self.position:
# Get entry price - Entry at market
= self.data.Close[-1]
last_close = self.atr[-1]
last_atr
if last_close > long_entry_ind[-1]:
= long_exit_ind[-1] - self.initial_stop_atr_multiplier * last_atr
initial_sl = max(0, initial_sl)
initial_sl = self.get_position_size(last_close, initial_sl)
size # size = 1
= self.get_tp_level(last_close, size, 2)
tp
if (initial_sl > 0 and tp > 0):
= self.buy(size=size, sl=initial_sl)
order
elif last_close > short_entry_ind[-1] == -1:
= short_exit_ind[-1] + self.initial_stop_atr_multiplier * last_atr
initial_sl = max(0, initial_sl)
initial_sl = self.get_position_size(last_close, initial_sl)
size # size = 1
= self.get_tp_level(last_close, size, False, 2)
tp
if (initial_sl > 0 and tp > 0 and initial_sl > last_close):
= self.sell(size=size, sl=initial_sl)
order
else:
self.update_trailing_stop(long_exit_ind, short_exit_ind, trailing_stop_multiplier)
if self.exit_rule() == 1:
self.position.close()
= SPY
data =None), inplace=True)
data.set_index(data.index.tz_localize(tz= data.loc["2011-12-19":]
selection
= Backtest(selection,
bt
BreakoutStrategyWalkaway,=10_000,
cash=True)
finalize_trades
= bt.run()
stats
pd.DataFrame(stats)
0 | |
---|---|
Start | 2011-12-19 00:00:00 |
End | 2025-02-27 00:00:00 |
Duration | 4819 days 00:00:00 |
Exposure Time [%] | 66.716913 |
Equity Final [$] | 20280.917357 |
Equity Peak [$] | 20710.199932 |
Return [%] | 102.809174 |
Buy & Hold Return [%] | 427.013937 |
Return (Ann.) [%] | 5.518872 |
Volatility (Ann.) [%] | 9.025592 |
CAGR [%] | 3.766826 |
Sharpe Ratio | 0.611469 |
Sortino Ratio | 0.895833 |
Calmar Ratio | 0.303781 |
Alpha [%] | -31.782907 |
Beta | 0.315194 |
Max. Drawdown [%] | -18.16725 |
Avg. Drawdown [%] | -1.205641 |
Max. Drawdown Duration | 931 days 00:00:00 |
Avg. Drawdown Duration | 31 days 00:00:00 |
# Trades | 13 |
Win Rate [%] | 53.846154 |
Best Trade [%] | 99.228841 |
Worst Trade [%] | -8.066754 |
Avg. Trade [%] | 6.723912 |
Max. Trade Duration | 2254 days 00:00:00 |
Avg. Trade Duration | 247 days 00:00:00 |
Profit Factor | 6.025399 |
Expectancy [%] | 9.102068 |
SQN | 1.164099 |
Kelly Criterion | 0.473604 |
_strategy | BreakoutStrategyWalkaway |
_equity_curve | Equity DrawdownPct Drawdown... |
_trades | Size EntryBar ExitBar EntryPrice Exit... |
We can see the difference between the most optimised equity curve and the walk-forward equity curve
from strategy.utils import plot_equity_curves
plot_equity_curves(stats_opt._equity_curve, stats._equity_curve,=["Optimized", "Walkforward"]) labels
"Win Rate [%]"] stats_opt.loc[
100.0
Win Rate | Expectancy | Return % | Sharpe ratio | |
---|---|---|---|---|
Optimised | 100.000000 | 475.596319 | 4.916784 | 0.753969 |
Walkforward | 53.846154 | 9.102068 | 102.809174 | 0.611469 |
Note: This is a weird result because of the position sizing algorithm. Since the most optimised trade in terms of sharpe ratio is to buy and hold, hence there is only 1 trade, and only 1% of capital is committed at entry.
Therefore, it is better to compare the expectancy instead of the equity curve, but the function itself is correct.
Learnings
Setting up this template with all the robust test is kind of a pain. Here are still some areas need to be improved
In prelim test, there are still to many code to change. Need to further re-factor.
In walk-forward test, to compute the walk-forward equity curve, it is too complicated and involved too many extra coding. Need to find a way to embed the date-wise parameters into the original class
Position sizing algorithm - This gives wrong results in equity curve comparison. From Kevin Davey’s book Ch16, he mentioned at the beginning of strategy development, he always use single contract / single unit entry to make sure the strategies are comparable.
As of now, we will end here. For the next step, I will start to work on the trending strategy with what we have here, and to smoothen the steps bit by bit.