Stateful Strategy Optimization¶
This template uses the QNT optimizer to find the best parameters for a stateful strategy
You can clone and edit this example there (tab Examples).
This template demonstrates a method for finding optimal values for each parameter in your stateful/multi-pass strategy. These strategies use day-by-day processing and rely on previous day's input to make decisions. If you are not familiar with stateful strategies, you can take a look at Stateful Long-Short with Exits. For single-pass submissions, please take a look at Trading System Optimization.
When you begin writing your trading strategies, the simplest method is to employ technical analysis. However, relying solely on pure technical analysis is often insufficient. You need to adjust the parameters of technical indicators to get an idea of how they affect the Sharpe Ratio and other statistics.
There is a significant risk of creating an overfitted strategy when using such optimization techniques. Overfitting can be mitigated by using less parameters, bigger ranges between them and examining the results to see how system behaves with the changes. This example illustrates how to implement this approach effectively, reducing the impact of overfitting and enhancing the robustness of your trading strategies.
import xarray as xr
import numpy as np
import pandas as pd
import plotly.express as px
import qnt.backtester as qnbt
import qnt.data as qndata
import qnt.ta as qnta
import qnt.optimizer as qno
import qnt.state as qnstate
import qnt.exits as qnte
Strategy Function
Here you can define your strategy and enter desired parameters as the arguments.
def strategy(data, state, p1=7.5, p2 = 3, p3 = 18, p4 = 6, p5 = 0.5, p6 = -17):
#Technical indicators
close = data.sel(field='close')
open_ = data.sel(field='open')
atr14 = qnta.atr(data.sel(field='high'), data.sel(field='low'), data.sel(field='close'), 14)
last_atr = atr14.isel(time=-1)
atr_perc = xr.where(atr14/close > 0.01, atr14/close, 0.01)
sma40 = qnta.sma(close, 40)
sma70 = qnta.sma(close, 70)
sma200 = qnta.sma(close, 200)
rsi120 = qnta.rsi(close, 120)
candle = close - open_
candlesma100 = qnta.sma(abs(candle), 100)
roc1day = qnta.roc(close, 1)
if state is None:
state = {
"weights": xr.zeros_like(close),
"open_price": xr.full_like(data.isel(time=-1).asset, np.nan, dtype=int),
"holding_time": xr.zeros_like(data.isel(time=-1).asset, dtype=int),
}
weights_prev = state['weights']
#To reuse the template, define your trading signals here ---------------------
long_signal = xr.where(sma40 > sma200, xr.where(sma40.shift(time=1) < sma200.shift(time=1), p4, 0), 0)
long_signal_2 = xr.where(candle > candlesma100.shift(time=1) * 4.5, p5, 0)
short_signal = xr.where(rsi120 > 65 , p6, 0)
exit1 = xr.where(close < sma70, 0, 1)
exit2 = xr.where(roc1day < -5, 0, 1)
entry_signal = short_signal + (long_signal + long_signal_2) * exit1 * exit2
entry_signal = entry_signal/atr_perc
# ----------------------------------------------------------------------------
#Keeping track of the previous position
weights_prev, entry_signal = xr.align(weights_prev, entry_signal, join='right')
weights = xr.where(entry_signal == 0, weights_prev.shift(time=1), entry_signal)
weights = weights.fillna(0)
#Define additional exit parameters here----------------------------------
open_price = qnte.update_open_price(data, weights, state) #Update open prices on position change to use with exits
signal_tp = qnte.take_profit_long_atr(data, weights, open_price, last_atr, atr_amount = p1) #Exit long positions if current close is bigger than entry price + 7*ATR
signal_sl = qnte.stop_loss_long_atr(data, weights, open_price, last_atr, atr_amount = p2) #Exit long positions if current close is lower than entry price - 3*ATR
signal_dc = qnte.max_hold_short(weights, state, max_period = p3) #Exit short positions after 10 periods (depending on the data - days, hours etc)
weights = weights * signal_tp * signal_sl * signal_dc
#------------------------------------------------------------------------
state['weights'] = weights
return weights, state
All optimization code cells in this notebook begin with the comment #DEBUG#
. This ensures that the Quantiacs backtester will ignore these cells during submission, preventing execution and simplifying the submission process without requiring you to delete or comment out optimizers.
#DEBUG#
data = qndata.stocks.load_ndx_data(min_date="2004-01-01") #Load the data for selected competition type
Optimization Guidelines for Quantiacs Backtester¶
The Quantiacs library offers two functions for parameter optimization:
full_range_args_generator
: This function exhaustively explores all possible parameter combinations you define.random_range_args_generator
: This function performs a fixed number of random iterations.
To save time, we recommend using random_range_args_generator
. Running a defined number of iterations (usually 1-5% of the total grid) often provides sufficient insight into the overall results of the parametric analysis.
In the example strategy, the 3 iterations defined in the function will take approximately 20 minutes to complete. You can adjust the number of iterations based on your needs—either reducing it for quicker results or increasing it for more thorough analysis, especially if you can let the notebook run for extended periods, such as overnight. Furthermore, it is not necessary to put all of the parameters into the optimizer at once. You can tune them separately to get a better idea of what gives you good results for each one.
#DEBUG#
results = qno.optimize_strategy(
data,
strategy,
qno.random_range_args_generator(iterations = 3,
p3=range(5, 25, 2),
p4=np.arange(3, 6.5, 0.5),
p5=np.arange(0.5, 2, 0.5),
p6=range(-10, -21, -5)),
lookback_period = 365, #Lookback period, default is 365 days
workers=1 # you can set more workers on your local PC to speed up
)
Optimizer will create a dictionary with all the results which you can then sort by Sharpe Ratio - see below. This can help you pick the best combination and also see how different parameters influence the score.
When you want to use the selected parameters in your strategy, simply replace the arguments in the strategy function with the optimizer output
#DEBUG#
all_points = [(elem['weight'], elem['args']) for elem in results['iterations']]
best_points = sorted(all_points, reverse=True)[:10]
best_points
Visualization and Analysis of Optimization Results¶
You can find 2 visualization functions below. The first one helps identify how different parameters affect the Sharpe Ratio, facilitating the selection of optimal parameters for the strategy. The second one displays all parameters on a 3D plot, displaying the overall distribution and behavior of the results. You can use these to change (narrow down or expand) parameter ranges, exclude certain parameters and generally get a better grip on where to look for potential improvements. When you are done, simply run the backtester with optimized parameters.
#DEBUG#
sharpe_ratio = [item[0] for item in all_points]
parameters = [item[1] for item in all_points]
param_df = pd.DataFrame(parameters)
param_df['Sharpe Ratio'] = sharpe_ratio
param_columns = param_df.columns[:-1]
labels = {col: col for col in param_columns}
labels['Sharpe Ratio'] = 'Sharpe Ratio'
fig = px.scatter(
param_df,
x=param_columns[0],
y='Sharpe Ratio',
color='Sharpe Ratio',
title='Parameter influence on Sharpe Ratio',
labels=labels,
color_continuous_scale='temps_r',
range_color=[0, 1]
)
fig.update_traces(marker={'size': 15})
dropdown_buttons = [
{
'args': [{'x': [param_df[col]], 'color': 'Sharpe Ratio'}, {'xaxis.title.text': col}],
'label': col,
'method': 'update',
}
for col in param_columns
]
fig.update_layout(
updatemenus=[{
'buttons': dropdown_buttons,
'direction': 'down',
'showactive': True,
'x': 1.15,
'xanchor': 'right',
'y': 1.15,
'yanchor': 'top'
}]
)
fig.show()
print("---")
print("Best iteration:")
display(results['best_iteration'])
#DEBUG#
qno.build_plot(results)
Final Strategy Submission
This section runs the strategy with finalized parameters to generate the weight outputs for submission. Once you find the best combination of parameters, simply put them in the best_params
dictionary, and it will run the backtest using them.
best_params = dict(p1=7.5, p2 = 3, p3 = 18, p4 = 6, p5 = 0.5, p6 = -17) #Choice of optimized parameters
def best_strategy(data, state):
return strategy(data, state, **best_params)
weights, state = qnbt.backtest(
competition_type="stocks_nasdaq100",
lookback_period=365, # lookback in calendar days
start_date="2006-01-01",
strategy=best_strategy,
analyze=True,
build_plots=True,
collect_all_states=False
)