March cohort is now open: How to secure your spot:

How to backtest 2,000,000 simulations for the best exits

How to backtest 2,000,000 simulations for the best exits. Backtests are not a way to brute force optimize parameters to maximize performance.

If you’ve been a reader of this newsletter for a while, or a student of Getting Started With Python for Quant Finance, you’ll recognize this statement:

Backtests are not a way to brute force optimize parameters to maximize a performance metric.

Doing that leads to overfitting and losses.

But optimization does play an important part in building trading strategies.

Today, we’ll see how.

How to backtest 2,000,000 simulations for the best exits

A backtest is a simulation of market dynamics which are used to test how trading strategies might perform behaved.

The problem is that the markets are noisy and difficult to model.

So when running backtests, optimized models often fit to noise, instead of market inefficiencies. When applying the optimized parameters to previously unseen data, the model falls apart (along with your portfolio).

All parameters of a strategy affect the result but only a few determine entry and exit dependent on the market price. These are the parameters that should be optimized.

For example, is a 1% trailing stop better than a $1 stop loss?

We’ll use the cutting-edge backtesting framework vectorbt to optimize the entry and exit type for a momentum strategy.

Strap on your seatbelt.

Let’s go!

Imports and set up

Given the power of what we’re about to do, there are very few imports required.

import pytz
from datetime import datetime, timedelta
import numpy as np
import pandas as pd
import vectorbt as vbt

We’ll set some variables for the analysis.

symbols = [
    "META",
    "AMZN",
    "AAPL",
    "NFLX",
    "GOOG",
]

start_date = datetime(2018, 1, 1, tzinfo=pytz.utc)
end_date = datetime(2021, 1, 1, tzinfo=pytz.utc)

traded_count = 3
window_len = timedelta(days=12 * 21)

seed = 42
window_count = 400
exit_types = ["SL", "TS", "TP"]
stops = np.arange(0.01, 1 + 0.01, 0.01)

vectorbt has built in data download capability using yFinance but it takes a bit of manipulation to get it right.

yfdata = vbt.YFData.download(symbols, start=start_date, end=end_date)
ohlcv = yfdata.concat()

split_ohlcv = {}

for k, v in ohlcv.items():
    split_df, split_indexes = v.vbt.range_split(
        range_len=window_len.days, n=window_count
    )
    split_ohlcv[k] = split_df
ohlcv = split_ohlcv

The code downloads historical and is then concatenated into a single DataFrame. We use the vectorbt range_split method to evenly split the market data into separate lookbacks.

We then initialize an empty dictionary called split_ohlcv to store the split data. Finally, we iterate through each symbol’s data and split it into smaller time windows and store the split data in the split_ohlcv dictionary.

Build the momentum strategy

Our strategy selects the top 3 stocks every split based on their mean return. The strategy equally allocates across the stocks at the beginning of the period, and exits at the end.

momentum = ohlcv["Close"].pct_change().mean()

sorted_momentum = (
    momentum
    .groupby(
        "split_idx", 
        group_keys=False, 
        sort=False
    )
    .apply(
        pd.Series.sort_values
    )
    .groupby("split_idx")
    .head(traded_count)
)

selected_open = ohlcv["Open"][sorted_momentum.index]
selected_high = ohlcv["High"][sorted_momentum.index]
selected_low = ohlcv["Low"][sorted_momentum.index]
selected_close = ohlcv["Close"][sorted_momentum.index]

The code calculates the momentum of each stock symbol based on the percentage change of their closing prices. It then sorts these values within each split and selects the top 3 stocks with the highest momentum.

Finally, it extracts the prices of the selected stocks using their indices and stores them in selected_open, selected_high, selected_low, and selected_close, respectively.

Test the order types

There’s a lot of code here. But we’re essentially creating the exit positions based on the different order types.

entries = pd.DataFrame.vbt.signals.empty_like(selected_open)
entries.iloc[0, :] = True

sl_exits = vbt.OHLCSTX.run(
    entries,
    selected_open,
    selected_high,
    selected_low,
    selected_close,
    sl_stop=list(stops),
    stop_type=None,
    stop_price=None,
).exits

ts_exits = vbt.OHLCSTX.run(
    entries,
    selected_open,
    selected_high,
    selected_low,
    selected_close,
    sl_stop=list(stops),
    sl_trail=True,
    stop_type=None,
    stop_price=None,
).exits

tp_exits = vbt.OHLCSTX.run(
    entries,
    selected_open,
    selected_high,
    selected_low,
    selected_close,
    tp_stop=list(stops),
    stop_type=None,
    stop_price=None,
).exits

sl_exits.vbt.rename_levels({"ohlcstx_sl_stop": "stop_value"}, inplace=True)
ts_exits.vbt.rename_levels({"ohlcstx_sl_stop": "stop_value"}, inplace=True)
tp_exits.vbt.rename_levels({"ohlcstx_tp_stop": "stop_value"}, inplace=True)
ts_exits.vbt.drop_levels("ohlcstx_sl_trail", inplace=True)

sl_exits.iloc[-1, :] = True
ts_exits.iloc[-1, :] = True
tp_exits.iloc[-1, :] = True

sl_exits = sl_exits.vbt.signals.first(reset_by=entries, allow_gaps=True)
ts_exits = ts_exits.vbt.signals.first(reset_by=entries, allow_gaps=True)
tp_exits = tp_exits.vbt.signals.first(reset_by=entries, allow_gaps=True)

exits = pd.DataFrame.vbt.concat(
    sl_exits,
    ts_exits,
    tp_exits,
    keys=pd.Index(exit_types, name="exit_type"),
)

The code creates an empty DataFrame with the same shape as selected_open and sets the first row to True which is our entry point.

It then calculates three types of exit signals: stop-loss (sl_exits), trailing stop (ts_exits), and take-profit (tp_exits).

The levels of these DataFrames are renamed for clarity, and the last row of each DataFrame is set to True to ensure an exit at the end of the split.

Finally, we concatenate the exit signals into a single DataFrame called exits, with a new index level to differentiate between the three exit strategies.

Run and analyze the backtest

Now that we have our data, entries, and exits, we can run the optimization and analyze the results.

portfolio = vbt.Portfolio.from_signals(selected_close, entries, exits)

total_return = portfolio.total_return()

total_return_by_type = total_return.unstack(level="exit_type")[exit_types]

total_return_by_type[exit_types].vbt.histplot(
    xaxis_title="Total return",
    xaxis_tickformat="%",
    yaxis_title="Count",
)

The result shows a histogram of total returns based on each stop type.

How to backtest 2,000,000 simulations for the best exits. Backtests are not a way to brute force optimize parameters to maximize performance.
How to backtest 2,000,000 simulations for the best exits. Backtests are not a way to brute force optimize parameters to maximize performance.

We can see that the trailing stop seems to have more negative returns while the take-profit has more positive returns. Let’s take a different look.

total_return_by_type.vbt.boxplot(
    yaxis_title='Total return',
    yaxis_tickformat='%'
)

The result is a box plot with the order types.

How to backtest 2,000,000 simulations for the best exits. Backtests are not a way to brute force optimize parameters to maximize performance.
How to backtest 2,000,000 simulations for the best exits. Backtests are not a way to brute force optimize parameters to maximize performance.

We can use the box plot to confirm that the take-profit order type outperforms the two others. Let’s quantify it further.

total_return_by_type.describe(percentiles=[])

You’ll see that the mean total return for the take-profit order type is 12.3% while that for the other two are below 10%.

Next steps

vectorbt is an advanced library suitable for walk-forward analysis and optimization.

The next step is to get get the code running on your machine and learn more about the framework by reading the documentation.