Trading System Optimization

Backesting a trading system amounts to perform a simulation of the trading rules on historical data. All trading rules depend to some extent on a set of parameters. These parameters can be the lookback periods used for defining technical indicators or the hyperparameters of a complex machine learning model.

It is very important to study the parameter dependence of the key statistical indicators, for example the Sharpe ratio. A parameter choice which maximizes the value of the Sharpe ratio when the simulation is performed on the past data is a source of backtest overfitting and leads to poor performance on live data.

In this template we provide a tool for studying the parameter dependence of the statistical indicators used for assessing the quality of a trading system.

We recommend optimizing your strategy in a separate notebook because a parametric scan is a time consuming task.

Alternatively it is possible to mark the cells which perform scans using the #DEBUG# tag. When you submit your notebook, the backtesting engine which performs the evaluation on the Quantiacs server will skip these cells.

You can use the optimizer also in your local environment on your machine. Here you can use more workers and take advantage of parallelization to speed up the grid scan process.

In [1]:
%%javascript
IPython.OutputArea.prototype._should_scroll = function(lines) { return false; }
// disable widget scrolling
In [2]:
import qnt.data as qndata
import qnt.ta as qnta
import qnt.output as qnout
import qnt.stats as qns
import qnt.log as qnlog
import qnt.optimizer as qnop
import qnt.backtester as qnbt

import xarray as xr

For defining the strategy we use a single-pass implementation where all data are accessed at once. This implementation is very fast and will speed up the parametric scan.

You should make sure that your strategy is not implicitly forward looking before submission, see how to prevent forward looking.

The strategy is going long only when the rate of change in the last roc_period trading days (in this case 10) of the linear-weighted moving average over the last wma_period trading days (in this case 20) is positive.

In [3]:
def single_pass_strategy(data, wma_period=20, roc_period=10):
    wma = qnta.lwma(data.sel(field='close'), wma_period)
    sroc = qnta.roc(wma, roc_period)
    weights = xr.where(sroc > 0, 1, 0)
    weights = weights / len(data.asset) # normalize weights so that sum=1, fully invested
    with qnlog.Settings(info=False, err=False): # suppress log messages
        weights = qnout.clean(weights, data) # check for problems
    return weights

Let us first check the performance of the strategy with the chosen parameters:

In [4]:
#DEBUG#
# evaluator will remove all cells with this tag before evaluation

data = qndata.futures.load_data(min_date='2004-01-01') # indicators need warmup, so prepend data
single_pass_output = single_pass_strategy(data)
single_pass_stat = qns.calc_stat(data, single_pass_output.sel(time=slice('2006-01-01', None)))
display(single_pass_stat.to_pandas().tail())
100% (36091432 of 36091432) |############| Elapsed Time: 0:00:00 Time:  0:00:00
field equity relative_return volatility underwater max_drawdown sharpe_ratio mean_return bias instruments avg_turnover avg_holding_time
time
2024-10-25 1.196180 0.000422 0.040266 -0.075048 -0.166367 0.237467 0.009562 1.0 71.0 0.040870 26.316034
2024-10-28 1.197412 0.001030 0.040262 -0.074096 -0.166367 0.238810 0.009615 1.0 71.0 0.040870 26.316239
2024-10-29 1.196886 -0.000439 0.040258 -0.074502 -0.166367 0.238199 0.009589 1.0 71.0 0.040868 26.317344
2024-10-30 1.193700 -0.002662 0.040259 -0.076966 -0.166367 0.234598 0.009445 1.0 71.0 0.040860 26.317344
2024-10-31 1.190708 -0.002507 0.040259 -0.079280 -0.166367 0.231209 0.009308 1.0 71.0 0.040855 26.454206

A parametric scan over pre-defined ranges of wma_period and roc_period can be performed with the Quantiacs optimizer function:

In [5]:
#DEBUG#
# evaluator will remove all cells with this tag before evaluation

data = qndata.futures.load_data(min_date='2004-01-01') # indicators need warmup, so prepend data

result = qnop.optimize_strategy(
    data,
    single_pass_strategy,
    qnop.full_range_args_generator(
        wma_period=range(10, 150, 5), # min, max, step
        roc_period=range(5, 100, 5)   # min, max, step
    ),
    workers=1 # you can set more workers when you run this code on your local PC to speed it up
)

qnop.build_plot(result) # interactive chart in the notebook

print("---")
print("Best iteration:")
display(result['best_iteration']) # as a reference, display the iteration with the highest Sharpe ratio
100% (532 of 532) |######################| Elapsed Time: 0:06:20 Time:  0:06:20
---
Best iteration:
{'args': {'wma_period': 15, 'roc_period': 80},
 'result': {'equity': 1.4642440322980057,
  'relative_return': -0.0018122111701901478,
  'volatility': 0.03996468550748145,
  'underwater': -0.05456290810043907,
  'max_drawdown': -0.11421964059461365,
  'sharpe_ratio': 0.5116560529650769,
  'mean_return': 0.02044817324474857,
  'bias': 1.0,
  'instruments': 71.0,
  'avg_turnover': 0.016924306856507122,
  'avg_holding_time': 75.20318391286227},
 'weight': 0.5116560529650769,
 'exception': None}

The arguments for the iteration with the highest Sharpe ratio can be later defined manually or calling result['best_iteration']['args'] for the final strategy. Note that cells with the tag #DEBUG# are disabled.

The final multi-pass call backtest for the optimized strategy is very simple, and it amounts to calling the last iteration of the single-pass implementation with the desired parameters:

In [6]:
best_args = dict(wma_period=20, roc_period=80) # highest Sharpe ratio iteration (not recommended, overfitting!)

def best_strategy(data):
    return single_pass_strategy(data, **best_args).isel(time=-1)

weights = qnbt.backtest(
    competition_type="futures",
    lookback_period=2 * 365,
    start_date='2006-01-01',
    strategy=best_strategy,
    analyze=True,
    build_plots=True
)
Run last pass...
Load data...
Run strategy...
Load data for cleanup...
Output cleaning...
fix uniq
Normalization...
Output cleaning is complete.
Write result...
Write output: /root/fractions.nc.gz
---
Run first pass...
Load data...
100% (16521772 of 16521772) |############| Elapsed Time: 0:00:00 Time:  0:00:00
Run strategy...
---
Load full data...
---
Run iterations...

100% (4917 of 4917) |####################| Elapsed Time: 0:02:21 Time:  0:02:21
Merge outputs...
Load data for cleanup and analysis...
Output cleaning...
fix uniq
ffill if the current price is None...
Check missed dates...
Ok.
Normalization...
Output cleaning is complete.
Write result...
Write output: /root/fractions.nc.gz
---
Analyze results...
Check...
Check missed dates...
Ok.
Check the sharpe ratio...
Period: 2006-01-01 - 2024-10-31
Sharpe Ratio = 0.5029707885353688
ERROR! The Sharpe Ratio is too low. 0.5029707885353688 < 1
Improve the strategy and make sure that the in-sample Sharpe Ratio more than 1.
Check correlation.
WARNING! Can't calculate correlation.
Correlation check failed.
---
Align...
Calc global stats...
---
Calc stats per asset...
Build plots...
---
Output:
asset F_AD F_AE F_AG F_AH F_AX F_BC F_BG F_BO F_BP F_C
time
2024-10-18 0.014085 0.0 0.014085 0.0 0.014085 0.0 0.0 0.0 0.014085 0.014085
2024-10-21 0.014085 0.0 0.014085 0.0 0.014085 0.0 0.0 0.0 0.014085 0.014085
2024-10-22 0.014085 0.0 0.014085 0.0 0.014085 0.0 0.0 0.0 0.014085 0.014085
2024-10-23 0.014085 0.0 0.014085 0.0 0.014085 0.0 0.0 0.0 0.014085 0.014085
2024-10-24 0.014085 0.0 0.014085 0.0 0.014085 0.0 0.0 0.0 0.014085 0.014085
2024-10-25 0.014085 0.0 0.014085 0.0 0.014085 0.0 0.0 0.0 0.014085 0.014085
2024-10-28 0.000000 0.0 0.014085 0.0 0.014085 0.0 0.0 0.0 0.014085 0.014085
2024-10-29 0.000000 0.0 0.014085 0.0 0.014085 0.0 0.0 0.0 0.014085 0.014085
2024-10-30 0.000000 0.0 0.014085 0.0 0.014085 0.0 0.0 0.0 0.014085 0.014085
2024-10-31 0.000000 0.0 0.014085 0.0 0.014085 0.0 0.0 0.0 0.014085 0.014085
Stats:
field equity relative_return volatility underwater max_drawdown sharpe_ratio mean_return bias instruments avg_turnover avg_holding_time
time
2024-10-18 1.468972 0.001079 0.040389 -0.057812 -0.117684 0.511546 0.020661 1.0 71.0 0.015375 85.518302
2024-10-21 1.464053 -0.003349 0.040393 -0.060967 -0.117684 0.506889 0.020475 1.0 71.0 0.015373 85.518302
2024-10-22 1.464133 0.000054 0.040389 -0.060916 -0.117684 0.506910 0.020473 1.0 71.0 0.015376 85.480254
2024-10-23 1.463080 -0.000719 0.040385 -0.061591 -0.117684 0.505887 0.020430 1.0 71.0 0.015374 85.480254
2024-10-24 1.465942 0.001956 0.040383 -0.059755 -0.117684 0.508429 0.020532 1.0 71.0 0.015371 85.480254
2024-10-25 1.467323 0.000942 0.040380 -0.058869 -0.117684 0.509634 0.020579 1.0 71.0 0.015369 85.480254
2024-10-28 1.467929 0.000413 0.040376 -0.058480 -0.117684 0.510135 0.020597 1.0 71.0 0.015366 85.480254
2024-10-29 1.467224 -0.000480 0.040372 -0.058933 -0.117684 0.509434 0.020567 1.0 71.0 0.015367 85.494639
2024-10-30 1.463079 -0.002825 0.040373 -0.061591 -0.117684 0.505515 0.020409 1.0 71.0 0.015365 85.494639
2024-10-31 1.460397 -0.001833 0.040372 -0.063312 -0.117684 0.502971 0.020306 1.0 71.0 0.015365 85.633715
---

The full code for the optimized strategy

import qnt.data as qndata
import qnt.ta as qnta
import qnt.log as qnlog
import qnt.backtester as qnbt
import qnt.output as qnout

import xarray as xr


best_args = dict(wma_period=20, roc_period=80) # highest Sharpe ratio iteration (not recommended, overfit!)


def single_pass_strategy(data, wma_period=20, roc_period=10):
    wma = qnta.lwma(data.sel(field='close'), wma_period)
    sroc = qnta.roc(wma, roc_period)
    weights = xr.where(sroc > 0, 1, 0)
    weights = weights / len(data.asset)
    with qnlog.Settings(info=False, err=False): # suppress log messages
        weights = qnout.clean(weights, data) # check for problems
    return weights


def best_strategy(data):
    return single_pass_strategy(data, **best_args).isel(time=-1)


weights = qnbt.backtest(
    competition_type="futures",
    lookback_period=2 * 365,
    start_date='2006-01-01',
    strategy=best_strategy,
    analyze=True,
    build_plots=True
)

Preventing forward-looking

You can use this code snippet for checking forward looking. A large difference in the Sharpe ratios is a sign of forward looking for the single-pass implementation used for the parametric scan.

#DEBUG#
# evaluator will remove all cells with this tag before evaluation

# single pass
data = qndata.futures.load_data(min_date='2004-01-01') # warmup period for indicators, prepend data
single_pass_output = single_pass_strategy(data)
single_pass_stat = qns.calc_stat(data, single_pass_output.sel(time=slice('2006-01-01', None)))

# multi pass
multi_pass_output = qnbt.backtest(
    competition_type="futures",
    lookback_period=2*365,
    start_date='2006-01-01',
    strategy=single_pass_strategy,
    analyze=False,
)
multi_pass_stat = qns.calc_stat(data, multi_pass_output.sel(time=slice('2006-01-01', None)))

print('''
---
Compare multi-pass and single pass performance to be sure that there is no forward looking. Small differences can arise because of numerical accuracy issues and differences in the treatment of missing values.
---
''')

print("Single-pass result:")
display(single_pass_stat.to_pandas().tail())

print("Multi-pass result:")
display(multi_pass_stat.to_pandas().tail())