Trading System Complete Guide (With Simple Breakout Example)

quant
trading
Author

Ray Lai

Published

March 28, 2025

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.

import pandas as pd
import numpy as np
import math

from itables import show

np.random.seed(260)

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

tickers = yf.Tickers(universe)

# SPY will be used as sample data for prelim test
SPY = tickers.tickers['SPY'].history(start = "2008-01-01", end = "2025-02-28")

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
  entry_n = 20
  exit_n  = 5
  atr_period = 14
  initial_stop_atr_multiplier  = 2
  trailing_stop_atr_multiplier = 2
  
  # Money management
  risk_per_trade = 0.01 # 1%
  
  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,
                      timeperiod=14)
    self.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
      signals_df = pd.DataFrame({
          '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: 
        trailing_sl = self.lowest_close_exit_n[-1] - self.trailing_stop_atr_multiplier * self.atr[-1]
        trade.sl = max(trade.sl, trailing_sl) 
      else: # short
        trailing_sl = self.highest_close_exit_n[-1] + self.trailing_stop_atr_multiplier * self.atr[-1]
        trade.sl = min(trade.sl, trailing_sl) 

    
  def next(self):
    
    if not self.position:
      # Get entry price - Entry at market
      last_close = self.data.Close[-1]
      last_atr = self.atr[-1]
      
      if self.entry_rule() == 1:
        sl = last_close - self.initial_stop_atr_multiplier * last_atr
        size = self.get_position_size(last_close, sl)
        tp = self.get_tp_level(last_close, size, 2)
        self.buy(size=size, sl=sl, tp=tp)
          
      elif self.entry_rule() == -1:
        sl = last_close + self.initial_stop_atr_multiplier * last_atr
        size = self.get_position_size(last_close, sl)
        tp = self.get_tp_level(last_close, size, False, 2)
        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
      last_close = self.data.Close[-1]
      last_atr = self.atr[-1]
      
      if self.entry_rule() == 1:
        sl = last_close - self.initial_stop_atr_multiplier * last_atr
        size = self.get_position_size(last_close, sl)
        
        self.buy(size=size, sl=sl, tp=tp)
          
      elif self.entry_rule() == -1:
        sl = last_close + self.initial_stop_atr_multiplier * last_atr
        size = self.get_position_size(last_close, sl)
        
        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

RANDOM_TEST_PERIOD_DAYS = 600
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):
    
    order = None
    
    if not self.position:
      # Get entry price - Entry at market
      last_close = self.data.Close[-1]
      last_atr = self.atr[-1]

      if self.entry_rule() == 1:
        initial_sl = self.lowest_close_exit_n[-1] - self.initial_stop_atr_multiplier * last_atr
        size = self.get_position_size(last_close, initial_sl)
        # size = 1
        tp = self.get_tp_level(last_close, size, 2)

        order = self.buy(size=size, sl=initial_sl, tp=tp)
          
      elif self.entry_rule() == -1:
        initial_sl = self.highest_close_exit_n[-1] + self.initial_stop_atr_multiplier * last_atr
        size = self.get_position_size(last_close, initial_sl)
        # size = 1
        tp = self.get_tp_level(last_close, size, False, 2)

        order = self.sell(size=size, sl=initial_sl, tp=tp)
    
    else:
      self.update_trailing_stop()
Show the code
from backtesting import Backtest

num_trades = 0

while(num_trades == 0):
  rnd_date = np.random.choice(SPY.index)
  selection = SPY.loc[rnd_date:rnd_date + pd.Timedelta(days=RANDOM_TEST_PERIOD_DAYS), :]
  
  bt = Backtest(selection,
                BreakoutStrategyEntryTest1,
                cash=10_000)
                
  stats = bt.run()
  signals_df = stats._strategy.store_indicators()
  # orders = stats._strategy.orders_log
  
  num_trades = stats["# 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]"))
GridPlot(
id = 'p3651', …)
Show the code
prelim1_stats = pd.DataFrame(stats)
Show the code
# Show all trades
show(stats["_trades"])
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):

  entry_index = None                # To track the bar where we entered
  num_bars_after_entry = 10         # Exit after 10 bars if no profit

  def next(self):
    
    if not self.position:
      # Get entry price - Entry at market
      last_close = self.data.Close[-1]
      last_atr = self.atr[-1]

      if self.entry_rule() == 1:
        initial_sl = self.lowest_close_exit_n[-1] - self.initial_stop_atr_multiplier * last_atr
        size = self.get_position_size(last_close, initial_sl)
        # size = 1
        tp = self.get_tp_level(last_close, size, 2)

        order = self.buy(size=size, sl=initial_sl, tp=tp)
          
      elif self.entry_rule() == -1:
        initial_sl = self.highest_close_exit_n[-1] + self.initial_stop_atr_multiplier * last_atr
        size = self.get_position_size(last_close, initial_sl)
        # size = 1
        tp = self.get_tp_level(last_close, size, False, 2)

        order = self.sell(size=size, sl=initial_sl, tp=tp)
    
    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
num_trades = 0

while(num_trades == 0):
  rnd_date = np.random.choice(SPY.index)
  selection = SPY.loc[rnd_date:rnd_date + pd.Timedelta(days=RANDOM_TEST_PERIOD_DAYS), :]
  
  bt = Backtest(selection,
                BreakoutStrategyEntryTest2,
                cash=10_000)
  stats = bt.run()
  
  num_trades = stats["# 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]"))
GridPlot(
id = 'p4098', …)
Show the code
prelim2_stats = pd.DataFrame(stats)
Show the code
# Show all trades
show(stats["_trades"])
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):

  entry_index = None                # To track the bar where we entered

  def next(self):
    
    if not self.position:
      # Get entry price - Entry at market
      last_close = self.data.Close[-1]
      last_atr = self.atr[-1]

      if self.entry_rule() == 1:
        initial_sl = self.lowest_close_exit_n[-2] - self.initial_stop_atr_multiplier * last_atr
        size = self.get_position_size(last_close, initial_sl)
        # size = 1
        tp = self.get_tp_level(last_close, size, 2)

        order = self.buy(size=size, sl=initial_sl, tp=tp)
          
      elif self.entry_rule() == -1:
        initial_sl = self.highest_close_exit_n[-2] + self.initial_stop_atr_multiplier * last_atr
        size = self.get_position_size(last_close, initial_sl)
        # size = 1
        tp = self.get_tp_level(last_close, size, False, 2)

        order = self.sell(size=size, sl=initial_sl, tp=tp)
    
    else:
      # Random Exit
      if (np.random.uniform(0, 1) > 0.5):
        self.position.close() 
Show the code
num_trades = 0

while(num_trades == 0):
  rnd_date = np.random.choice(SPY.index)
  selection = SPY.loc[rnd_date:rnd_date + pd.Timedelta(days=RANDOM_TEST_PERIOD_DAYS), :]
  
  bt = Backtest(selection,
                BreakoutStrategyEntryTest3,
                cash=10_000)
  stats = bt.run()
  
  num_trades = stats["# 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]"))
GridPlot(
id = 'p4545', …)
prelim3_stats = pd.DataFrame(stats)
# Show all trades
show(stats["_trades"])
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
  trailing_stop_atr_multiplier = 4
  
  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: 
        trailing_sl = self.data.Close[-2] - self.trailing_stop_atr_multiplier * self.atr[-2]
        trade.sl = max(trade.sl, trailing_sl) 
      else: # short
        trailing_sl = self.data.Close[-2] + self.trailing_stop_atr_multiplier * self.atr[-2]
        trade.sl = min(trade.sl, trailing_sl) 

  def next(self):
    
    if not self.position:
      # Get entry price - Entry at market
      last_close = self.data.Close[-1]
      last_atr = self.atr[-1]

      if self.entry_rule() == 1:
        sl = last_close - self.initial_stop_atr_multiplier * last_atr
        size = self.get_position_size(last_close, sl)
        tp = self.get_tp_level(last_close, size, 2)

        self.buy(size=size, sl=sl, tp=tp)
          
      elif self.entry_rule() == -1:
        sl = last_close + self.initial_stop_atr_multiplier * last_atr
        size = self.get_position_size(last_close, sl)
        tp = self.get_tp_level(last_close, size, False, 2)

        self.sell(size=size, sl=sl, tp=tp)
    
    else:
      self.update_trailing_stop()
Show the code
num_trades = 0

while(num_trades == 0):
  rnd_date = np.random.choice(SPY.index)
  selection = SPY.loc[rnd_date:rnd_date + pd.Timedelta(days=RANDOM_TEST_PERIOD_DAYS), :]
  
  bt = Backtest(selection,
                BreakoutStrategyExitTest1,
                cash=10_000)
  stats = bt.run()
  
  num_trades = stats["# 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]"))
GridPlot(
id = 'p4992', …)
exit_prelim1_stats = pd.DataFrame(stats)
# Show all trades
show(stats["_trades"])
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
entry_n_parameters = [5, 15, 30, 60, 90, 120]
exit_n_parameters = [5, 10, 15, 20]
exit_rule_trialing_stop_parameters = [1, 3, 4, 99]

num_trades = 0

if '__spec__' not in dir():
    sys.modules['__main__'].__spec__ = types.SimpleNamespace()

while(num_trades == 0):
  rnd_date = np.random.choice(SPY.index)
  selection = SPY.loc[rnd_date:rnd_date + pd.Timedelta(days=RANDOM_TEST_PERIOD_DAYS), :]
  selection.set_index(selection.index.tz_localize(tz=None), inplace=True)

  bt_opt = Backtest(selection,
                BreakoutStrategy,
                cash=10_000)

  stats, heatmap = bt_opt.optimize(
                      entry_n=entry_n_parameters,
                      exit_n=exit_n_parameters,
                      trailing_stop_atr_multiplier=exit_rule_trialing_stop_parameters,
                      maximize='Sharpe Ratio',
                      max_tries=200,
                      random_state=42,
                      return_heatmap=True)

  num_trades = stats["# 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...
hm = heatmap.groupby(['entry_n', 'exit_n']).mean().unstack()
hm = hm[::-1]
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()
GridPlot(
id = 'p5439', …)

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)

An illustration of Walk-forward test
from strategy.utils import walk_forward, make_walkforward_report
from strategy.BreakoutStrategy import BreakoutStrategy

# Parameters to test
entry_n_parameters = [5, 15, 30, 60, 90, 120]
exit_n_parameters = [1, 3, 5, 10]
exit_rule_trialing_stop_parameters = [1, 3, 4, 99]

opt_stats, walkforward_stats = walk_forward(BreakoutStrategy,
             data=SPY,
             objective="Sharpe Ratio",
             cash=10_000,
             chunk_size=100,
             training_chunk=10,
             validation_chunk=5,
             entry_n=entry_n_parameters,
             exit_n=exit_n_parameters,
             trailing_stop_atr_multiplier=exit_rule_trialing_stop_parameters)
{'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?)
stats = make_walkforward_report(opt_stats, walkforward_stats)

walkforward_params = stats.loc[:, ["Walkforward_Start",
              "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
entry_n_parameters = [5, 15, 30, 60, 90, 120]
exit_n_parameters = [1, 3, 5, 10]
exit_rule_trialing_stop_parameters = [1, 3, 4, 99]

data = SPY
data.set_index(data.index.tz_localize(tz=None), inplace=True)
selection = data.loc["2011-12-19":]

bt_opt = Backtest(selection,
              BreakoutStrategy,
              cash=10_000,
              finalize_trades=True)

stats_opt = bt_opt.optimize(
                    entry_n=entry_n_parameters,
                    exit_n=exit_n_parameters,
                    trailing_stop_atr_multiplier=exit_rule_trialing_stop_parameters,
                    maximize='Sharpe Ratio',
                    max_tries=200,
                    random_state=42,
                    return_heatmap=False)
                    
print(stats_opt._strategy)

bt_opt = Backtest(selection,
              BreakoutStrategy,
              cash=10_000,
              finalize_trades=True)
              
stats_opt = bt_opt.run(entry_n=5,
                       exit_n=1,
                       trailing_stop_atr_multiplier=99)


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,
                        timeperiod=14)
      self.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: 
          trailing_sl = long_exit_ind[-1] - trailing_stop_multiplier * self.atr[-1]
          trailing_sl = max(trailing_sl, 0)
          trade.sl = max(trade.sl, trailing_sl) 
        else: # short
          trailing_sl = short_exit_ind[-1] + trailing_stop_multiplier * self.atr[-1]
          trailing_sl = max(trailing_sl, 0)
          trade.sl = min(trade.sl, trailing_sl) 

      
    def next(self):
      
      long_entry_ind = None
      if (self.data.index[-1] < pd.Timestamp("2013-12-13")):
        long_entry_ind = self.highest_close_entry_90
      elif (self.data.index[-1] < pd.Timestamp("2015-12-09")):
        long_entry_ind = self.highest_close_entry_90
      elif (self.data.index[-1] < pd.Timestamp("2017-12-04")):
        long_entry_ind = self.highest_close_entry_30
      elif (self.data.index[-1] < pd.Timestamp("2019-11-29")):
        long_entry_ind = self.highest_close_entry_5
      elif (self.data.index[-1] < pd.Timestamp("2021-11-23")):
        long_entry_ind = self.highest_close_entry_90
      elif (self.data.index[-1] < pd.Timestamp("2023-11-20")):
        long_entry_ind = self.highest_close_entry_60
      else:
        long_entry_ind = self.highest_close_entry_60
        
      short_entry_ind = None
      if (self.data.index[-1] < pd.Timestamp("2013-12-13")):
        short_entry_ind = self.highest_close_entry_90
      elif (self.data.index[-1] < pd.Timestamp("2015-12-09")):
        short_entry_ind = self.highest_close_entry_90
      elif (self.data.index[-1] < pd.Timestamp("2017-12-04")):
        short_entry_ind = self.highest_close_entry_30
      elif (self.data.index[-1] < pd.Timestamp("2019-11-29")):
        short_entry_ind = self.highest_close_entry_5
      elif (self.data.index[-1] < pd.Timestamp("2021-11-23")):
        short_entry_ind = self.highest_close_entry_90
      elif (self.data.index[-1] < pd.Timestamp("2023-11-20")):
        short_entry_ind = self.highest_close_entry_60
      else:
        short_entry_ind = self.highest_close_entry_60
          
      long_exit_ind = None
      if (self.data.index[-1] < pd.Timestamp("2013-12-13")):
        long_exit_ind = self.lowest_close_exit_1
      elif (self.data.index[-1] < pd.Timestamp("2015-12-09")):
        long_exit_ind = self.lowest_close_exit_5
      elif (self.data.index[-1] < pd.Timestamp("2017-12-04")):
        long_exit_ind = self.lowest_close_exit_1
      elif (self.data.index[-1] < pd.Timestamp("2019-11-29")):
        long_exit_ind = self.lowest_close_exit_10
      elif (self.data.index[-1] < pd.Timestamp("2021-11-23")):
        long_exit_ind = self.lowest_close_exit_5
      elif (self.data.index[-1] < pd.Timestamp("2023-11-20")):
        long_exit_ind = self.lowest_close_exit_5
      else:
        long_exit_ind = self.lowest_close_exit_5
          
      short_exit_ind = None
      if (self.data.index[-1] < pd.Timestamp("2013-12-13")):
        short_exit_ind = self.lowest_close_exit_1
      elif (self.data.index[-1] < pd.Timestamp("2015-12-09")):
        short_exit_ind = self.lowest_close_exit_5
      elif (self.data.index[-1] < pd.Timestamp("2017-12-04")):
        short_exit_ind = self.lowest_close_exit_1
      elif (self.data.index[-1] < pd.Timestamp("2019-11-29")):
        short_exit_ind = self.lowest_close_exit_10
      elif (self.data.index[-1] < pd.Timestamp("2021-11-23")):
        short_exit_ind = self.lowest_close_exit_5
      elif (self.data.index[-1] < pd.Timestamp("2023-11-20")):
        short_exit_ind = self.lowest_close_exit_5
      else:
        short_exit_ind = self.lowest_close_exit_5
                
      trailing_stop_multiplier = None
      if (self.data.index[-1] < pd.Timestamp("2013-12-13")):
        trailing_stop_multiplier = 4
      elif (self.data.index[-1] < pd.Timestamp("2015-12-09")):
        trailing_stop_multiplier = 99
      elif (self.data.index[-1] < pd.Timestamp("2017-12-04")):
        trailing_stop_multiplier = 99
      elif (self.data.index[-1] < pd.Timestamp("2019-11-29")):
        trailing_stop_multiplier = 99
      elif (self.data.index[-1] < pd.Timestamp("2021-11-23")):
        trailing_stop_multiplier = 4
      elif (self.data.index[-1] < pd.Timestamp("2023-11-20")):
        trailing_stop_multiplier = 4
      else:
        trailing_stop_multiplier = 4
        
      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
        last_close = self.data.Close[-1]
        last_atr = self.atr[-1]
        
        
        if last_close > long_entry_ind[-1]:
          initial_sl = long_exit_ind[-1] - self.initial_stop_atr_multiplier * last_atr
          initial_sl = max(0, initial_sl)
          size = self.get_position_size(last_close, initial_sl)
          # size = 1
          tp = self.get_tp_level(last_close, size, 2)
          
          if (initial_sl > 0 and tp > 0):
            order = self.buy(size=size, sl=initial_sl)
  
        elif last_close > short_entry_ind[-1] == -1:
          initial_sl = short_exit_ind[-1] + self.initial_stop_atr_multiplier * last_atr
          initial_sl = max(0, initial_sl)
          size = self.get_position_size(last_close, initial_sl)
          # size = 1
          tp = self.get_tp_level(last_close, size, False, 2)
          
          if (initial_sl > 0 and tp > 0 and initial_sl > last_close):
            order = self.sell(size=size, sl=initial_sl)
      
      else:
        self.update_trailing_stop(long_exit_ind, short_exit_ind, trailing_stop_multiplier)
        
        if self.exit_rule() == 1:
          self.position.close()
data = SPY
data.set_index(data.index.tz_localize(tz=None), inplace=True)
selection = data.loc["2011-12-19":]
  
bt = Backtest(selection,
              BreakoutStrategyWalkaway,
              cash=10_000,
              finalize_trades=True)
              
stats = bt.run()

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,
                   labels=["Optimized", "Walkforward"])

stats_opt.loc["Win Rate [%]"]
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.