Multiple Time Frames¶
Best trading strategies that rely on technical analysis might take into account price action on multiple time frames. This tutorial will show how to do that with backtesting.py, offloading most of the work to pandas resampling. It is assumed you're already familiar with basic framework usage.
We will put to the test this long-only, supposed 400%-a-year trading strategy, which uses daily and weekly relative strength index (RSI) values and moving averages (MA).
In practice, one should use functions from an indicator library, such as TA-Lib or Tulipy, but among us, let's introduce the two indicators we'll be using.
import pandas as pd
def SMA(array, n):
"""Simple moving average"""
return array.rolling(n).mean()
def RSI(array, n):
"""Relative strength index"""
# Approximate; good enough
gain = array.diff()
loss = gain.copy()
gain[gain < 0] = 0
loss[loss > 0] = 0
rs = gain.ewm(n).mean() / loss.abs().ewm(n).mean()
return 100 - 100 / (1 + rs)
The strategy roughly goes like this:
Buy a position when:
- weekly RSI(30) $\geq$ daily RSI(30) $>$ 70
- Close $>$ MA(10) $>$ MA(20) $>$ MA(50) $>$ MA(100)
Close the position when:
- Daily close is more than 2% below MA(10)
- 8% fixed stop loss is hit
We need to provide bars data in the lowest time frame (i.e. daily) and resample it to any higher time frame (i.e. weekly) that our strategy requires.
from minitrade.backtest import Strategy, Backtest
from minitrade.backtest.core.lib import resample_apply
class System(Strategy):
d_rsi = 30 # Daily RSI lookback periods
w_rsi = 30 # Weekly
level = 70
def init(self):
# Compute moving averages the strategy demands
self.ma10 = self.I(SMA, self.data.Close.df, 10)
self.ma20 = self.I(SMA, self.data.Close.df, 20)
self.ma50 = self.I(SMA, self.data.Close.df, 50)
self.ma100 = self.I(SMA, self.data.Close.df, 100)
# Compute daily RSI(30)
self.daily_rsi = self.I(RSI, self.data.Close.df, self.d_rsi)
# To construct weekly RSI, we can use `resample_apply()`
# helper function from the library
self.weekly_rsi = resample_apply('W-FRI', RSI, self.data.Close.df, self.w_rsi)
def next(self):
price = self.data.Close[-1]
# If we don't already have a position, and
# if all conditions are satisfied, enter long.
if (not self.position() and
self.daily_rsi[-1] > self.level and
self.weekly_rsi[-1] > self.level and
self.weekly_rsi[-1] > self.daily_rsi[-1] and
self.ma10[-1] > self.ma20[-1] > self.ma50[-1] > self.ma100[-1] and
price > self.ma10[-1]):
# Buy at market price on next open, but do
# set 8% fixed stop loss.
self.buy(sl=.92 * price)
# If the price closes 2% or more below 10-day MA
# close the position, if any.
elif price < .98 * self.ma10[-1]:
self.position().close()
Let's see how our strategy fares replayed on nine years of Google stock data.
from minitrade.backtest.core.test import GOOG
backtest = Backtest(GOOG, System, commission=.002)
backtest.run()
Start 2004-08-19 00:00:00
End 2013-03-01 00:00:00
Duration 3116 days 00:00:00
Exposure Time [%] 2.793296
Equity Final [$] 10017.44422
Equity Peak [$] 10978.3801
Return [%] 0.174442
Buy & Hold Return [%] 313.303599
Return (Ann.) [%] 0.021438
Volatility (Ann.) [%] 5.059486
Sharpe Ratio 0.004237
Sortino Ratio 0.005488
Calmar Ratio 0.002142
Max. Drawdown [%] -10.00745
Avg. Drawdown [%] -9.340092
Max. Drawdown Duration 2653 days 00:00:00
Avg. Drawdown Duration 1410 days 00:00:00
# Trades 4
Win Rate [%] 25.0
Best Trade [%] 9.687579
Worst Trade [%] -4.456159
Avg. Trade [%] 0.081712
Max. Trade Duration 35 days 00:00:00
Avg. Trade Duration 21 days 00:00:00
Profit Factor 1.10514
Expectancy [%] 0.230413
SQN 0.014232
Kelly Criterion 0.004833
_strategy System
_equity_curve Equity Asset Cash D...
_trades EntryBar ExitBar Ticker Size EntryPrice ...
_orders Ticker Side Size
SignalTime ...
_positions {'Asset': 0, 'Cash': 10017}
_trade_start_bar 99
dtype: object
Meager four trades in the span of nine years and with zero return? How about if we optimize the parameters a bit?
%%time
backtest.optimize(d_rsi=range(10, 35, 5),
w_rsi=range(10, 35, 5),
level=range(30, 80, 10))
/Users/ww/workspace/minitrade/minitrade/backtest/core/backtesting.py:2248: UserWarning: For multiprocessing support in `Backtest.optimize()` set multiprocessing start method to 'fork'.
warnings.warn("For multiprocessing support in `Backtest.optimize()` "
0%| | 0/9 [00:00<?, ?it/s]
CPU times: user 5.2 s, sys: 35.6 ms, total: 5.23 s Wall time: 5.3 s
Start 2004-08-19 00:00:00
End 2013-03-01 00:00:00
Duration 3116 days 00:00:00
Exposure Time [%] 22.486034
Equity Final [$] 22822.75224
Equity Peak [$] 23395.59144
Return [%] 128.227522
Buy & Hold Return [%] 313.303599
Return (Ann.) [%] 10.681374
Volatility (Ann.) [%] 13.518924
Sharpe Ratio 0.790105
Sortino Ratio 1.338786
Calmar Ratio 0.564533
Max. Drawdown [%] -18.920719
Avg. Drawdown [%] -3.795058
Max. Drawdown Duration 778 days 00:00:00
Avg. Drawdown Duration 97 days 00:00:00
# Trades 23
Win Rate [%] 65.217391
Best Trade [%] 25.034669
Worst Trade [%] -6.297769
Avg. Trade [%] 3.705482
Max. Trade Duration 63 days 00:00:00
Avg. Trade Duration 29 days 00:00:00
Profit Factor 5.024416
Expectancy [%] 3.971489
SQN 2.63967
Kelly Criterion 0.515784
_strategy System(d_rsi=30,w_rsi=10,level=60)
_equity_curve Equity Asset Cash...
_trades EntryBar ExitBar Ticker Size EntryPrice...
_orders Ticker Side Size
SignalTime ...
_positions {'Asset': 28, 'Cash': 249}
_trade_start_bar 99
dtype: object
backtest.plot()
/Users/ww/workspace/minitrade/minitrade/backtest/core/_plotting.py:737: UserWarning: found multiple competing values for 'toolbar.active_drag' property; using the latest value fig = gridplot( /Users/ww/workspace/minitrade/minitrade/backtest/core/_plotting.py:737: UserWarning: found multiple competing values for 'toolbar.active_scroll' property; using the latest value fig = gridplot(
Better. While the strategy doesn't perform as well as simple buy & hold, it does so with significantly lower exposure (time in market).
In conclusion, to test strategies on multiple time frames, you need to pass in OHLC data in the lowest time frame, then resample it to higher time frames, apply the indicators, then resample back to the lower time frame, filling in the in-betweens.
Which is what the function backtesting.lib.resample_apply() does for you.
Learn more by exploring further examples or find more framework options in the full API reference.