Notebook

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.

In [1]:
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.

In [2]:
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.

In [3]:
#DEBUG#
data = qndata.stocks.load_ndx_data(min_date="2004-01-01") #Load the data for selected competition type
100% (367973 of 367973) |################| Elapsed Time: 0:00:00 Time:  0:00:00
100% (39443 of 39443) |##################| Elapsed Time: 0:00:00 Time:  0:00:00
100% (12916080 of 12916080) |############| Elapsed Time: 0:00:00 Time:  0:00:00
fetched chunk 1/7 1s
100% (12916080 of 12916080) |############| Elapsed Time: 0:00:00 Time:  0:00:00
fetched chunk 2/7 1s
100% (12916076 of 12916076) |############| Elapsed Time: 0:00:00 Time:  0:00:00
fetched chunk 3/7 2s
100% (12916052 of 12916052) |############| Elapsed Time: 0:00:00 Time:  0:00:00
fetched chunk 4/7 3s
100% (12915976 of 12915976) |############| Elapsed Time: 0:00:00 Time:  0:00:00
fetched chunk 5/7 4s
100% (12915976 of 12915976) |############| Elapsed Time: 0:00:00 Time:  0:00:00
fetched chunk 6/7 4s
100% (4980788 of 4980788) |##############| Elapsed Time: 0:00:00 Time:  0:00:00
fetched chunk 7/7 5s
Data loaded 5s

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.

In [4]:
#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
)
100% (39443 of 39443) |##################| Elapsed Time: 0:00:00 Time:  0:00:00
100% (3988812 of 3988812) |##############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (609532 of 609532) |################| Elapsed Time: 0:00:00 Time:  0:00:00
Load data...
100% (39443 of 39443) |##################| Elapsed Time: 0:00:00 Time:  0:00:00
100% (4020692 of 4020692) |##############| Elapsed Time: 0:00:00 Time:  0:00:00
Run strategy...
100% (39443 of 39443) |##################| Elapsed Time: 0:00:00 Time:  0:00:00
100% (13229608 of 13229608) |############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (13229608 of 13229608) |############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (13229576 of 13229576) |############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (13229492 of 13229492) |############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (13229492 of 13229492) |############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (12286052 of 12286052) |############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (4662 of 4662) |####################| Elapsed Time: 0:05:45 Time:  0:05:45
100% (39443 of 39443) |##################| Elapsed Time: 0:00:00 Time:  0:00:00
100% (13020220 of 13020220) |############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (13020220 of 13020220) |############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (13020188 of 13020188) |############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (13020188 of 13020188) |############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (13020108 of 13020108) |############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (13020188 of 13020188) |############| Elapsed Time: 0:00:00 Time:  0:00:00
100% (971604 of 971604) |################| Elapsed Time: 0:00:00 Time:  0:00:00
 33% (1 of 3) |########                  | Elapsed Time: 0:06:09 ETA:   0:12:18
Load data...
Run strategy...
100% (4662 of 4662) |####################| Elapsed Time: 0:05:45 Time:  0:05:45
 66% (2 of 3) |#################         | Elapsed Time: 0:12:00 ETA:   0:05:50
Load data...
Run strategy...
100% (4662 of 4662) |####################| Elapsed Time: 0:05:45 Time:  0:05:45
100% (3 of 3) |##########################| Elapsed Time: 0:17:51 Time:  0:17:51

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

In [5]:
#DEBUG#
all_points = [(elem['weight'], elem['args']) for elem in results['iterations']]
best_points = sorted(all_points, reverse=True)[:10]

best_points
Out[5]:
[(0.8369086085847144, {'p3': 19, 'p4': 3.5, 'p5': 0.5, 'p6': -15}),
 (0.7053717763297558, {'p3': 11, 'p4': 3.5, 'p5': 1.5, 'p6': -15}),
 (0.6974657956581223, {'p3': 23, 'p4': 3.5, 'p5': 0.5, 'p6': -15})]

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.

In [6]:
#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'])
---
Best iteration:
{'args': {'p3': 19, 'p4': 3.5, 'p5': 0.5, 'p6': -15},
 'result': {'equity': 13.560110937545458,
  'relative_return': 0.007051058213722428,
  'volatility': 0.1808316168032345,
  'underwater': 0.0,
  'max_drawdown': -0.5132879264877115,
  'sharpe_ratio': 0.8369086085847144,
  'mean_return': 0.15133953680691925,
  'bias': 1.0,
  'instruments': 228.0,
  'avg_turnover': 0.07414342372948644,
  'avg_holding_time': 30.388183419804694},
 'weight': 0.8369086085847144,
 'exception': None}
In [7]:
#DEBUG#
qno.build_plot(results)
VBox(children=(HBox(children=(Dropdown(description='coord_x', options=('p3', 'p4', 'p5', 'p6', 'sharpe_ratio',…

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.

In [8]:
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 
)
Run last pass...
Load data...
fetched chunk 1/1 0s
Data loaded 0s
Run strategy...
Load data for cleanup...
fetched chunk 1/1 0s
Data loaded 0s
Output cleaning...
fix uniq
ffill if the current price is None...
Check liquidity...
WARNING! Strategy trades non-liquid assets.
Fix liquidity...
Ok.
Check missed dates...
Ok.
Normalization...
Output cleaning is complete.
Write result...
Write output: /root/fractions.nc.gz
State saved.
---
Run first pass...
Load data...
fetched chunk 1/1 0s
Data loaded 0s
Run strategy...
---
Load full data...
fetched chunk 1/6 0s
fetched chunk 2/6 0s
fetched chunk 3/6 0s
fetched chunk 4/6 0s
fetched chunk 5/6 0s
fetched chunk 6/6 0s
Data loaded 0s
---
Run iterations...

100% (4662 of 4662) |####################| Elapsed Time: 0:05:44 Time:  0:05:44
Merge outputs...
Load data for cleanup and analysis...
fetched chunk 1/7 0s
fetched chunk 2/7 0s
fetched chunk 3/7 0s
fetched chunk 4/7 0s
fetched chunk 5/7 0s
fetched chunk 6/7 0s
fetched chunk 7/7 0s
Data loaded 0s
Output cleaning...
fix uniq
ffill if the current price is None...
Check liquidity...
WARNING! Strategy trades non-liquid assets.
Fix liquidity...
Ok.
Check missed dates...
Ok.
Normalization...
Output cleaning is complete.
Write result...
Write output: /root/fractions.nc.gz
State saved.
---
Analyze results...
Check...
Check liquidity...
Ok.
Check missed dates...
Ok.
Check the sharpe ratio...
Period: 2006-01-01 - 2024-07-12
Sharpe Ratio = 0.799982118926227
Ok.
---
Align...
Calc global stats...
---
Calc stats per asset...
Build plots...
---
Select the asset (or leave blank to display the overall stats):
interactive(children=(Combobox(value='', description='asset', options=('', 'NAS:AAL', 'NAS:AAPL', 'NAS:ABNB', …