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.