I have collected about half a year of trade price data from the Phemex futures exchange, but until now I have not had the means to leverage this data to systematically test strategy performance. With the addition of a tick-by-tick backtester to Serenity with the 0.4.0 release, it is now possible to write a Serenity trading strategy in 100 lines of Python, run it against a year of trade data to see how it performs, and then deploy the same strategy in production with code unchanged. In this article we will use a simple Bollinger Bands-based strategy to illustrate.
Principles
- Strategy code should be clear and easy to understand
- Strategy code should not change between backtest and production environments
- Backtests should run as quickly as possible, but not at the expense of code readability
To achieve this, I reworked the Tau reactive framework to support a historic mode, splitting NetworkScheduler
into RealtimeNetworkScheduler
and HistoricNetworkScheduler
. The mechanism is very simple: events get scheduled at a given time via a priority queue which ensures they are sorted appropriately, and when the scheduler runs with its run() method it just consumes from the priority queue until exhausted. Given a HistoricEvent object which is self-sorting:
@total_ordering
class HistoricalEvent:
def __init__(self, time_millis: int, action: Any):
self.time_millis = time_millis
self.action = action
def get_time_millis(self) -> int:
return self.time_millis
def __eq__(self, other):
return self.get_time_millis() == other.get_time_millis()
def __ne__(self, other):
return self.get_time_millis() != other.get_time_millis()
def __lt__(self, other):
return self.get_time_millis() < other.get_time_millis()
you can run the actions in order with this code:
def run(self):
self.now = self.start_time
while True:
if self.event_queue.empty():
return
event = self.event_queue.get_nowait()
if event.time_millis > self.end_time:
# end of time
return
elif event.time_millis < self.start_time:
# pre-history
continue
self.now = event.time_millis
event.action()
But this should all be hidden to the user, and it is: you write code against the NetworkScheduler
base class and the container (live or backtest) takes care of instantiating the correct scheduler instance for you.
A simple strategy
For our strategy we will compute Bollinger Bands for 5 minute binned trade prices, smoothing prices over 20 periods (100 minutes) and setting the band width to 2 standard deviations. This will give us an idea of what is overall trend (simple moving average) and spread of possible prices (std dev). We will then enter a long position when we see a close price below the lower band and exit it when the price goes above the upper band. We won't consider transaction costs at this time.
Serenity passes you everything you need object-wise in the StrategyContext
object, including YAML-based configuration parameters and the MarketdataService
from which we can get streaming trade prices and order books. It also offers out of the box classes which can create open/high/low/close price bins (ComputeOHLC
) and Bollinger Bands (ComputeBollingerBands
) as well as Tau reactive primitives like Map
and BufferWithTime
.
from datetime import timedelta, datetime
from tau.core import Event, NetworkScheduler
from tau.signal import Map, BufferWithTime
from serenity.algo import Strategy, StrategyContext
from serenity.signal.indicators import ComputeBollingerBands
from serenity.signal.marketdata import ComputeOHLC
class BollingerBandsStrategy1(Strategy):
"""
An example strategy that uses Bollinger Band crossing as a signal to buy or sell.
"""
logger = logging.getLogger(__name__)
def init(self, ctx: StrategyContext):
scheduler = ctx.get_scheduler()
network = scheduler.get_network()
window = int(ctx.getenv('BBANDS_WINDOW'))
num_std = int(ctx.getenv('BBANDS_NUM_STD'))
exchange_code, instrument_code = ctx.getenv('TRADING_INSTRUMENT').split('/')
instrument = ctx.get_instrument_cache().get_exchange_instrument(exchange_code, instrument_code)
trades = ctx.get_marketdata_service().get_trades(instrument)
trades_5m = BufferWithTime(scheduler, trades, timedelta(minutes=5))
prices = ComputeOHLC(network, trades_5m)
close_prices = Map(network, prices, lambda x: x.close_px)
bbands = ComputeBollingerBands(network, close_prices, window, num_std)
We then connect this to a trader Event
class which reacts to the Bollinger Bands moving and implements our position entry and exit conditions:
class BollingerTrader(Event):
# noinspection PyShadowingNames
def __init__(self, scheduler: NetworkScheduler, strategy: BollingerBandsStrategy1):
self.scheduler = scheduler
self.strategy = strategy
self.last_entry = 0
self.last_exit = 0
self.cum_pnl = 0
self.has_position = False
def on_activate(self) -> bool:
if not self.has_position and close_prices.get_value() < bbands.get_value().lower:
self.last_entry = close_prices.get_value()
self.has_position = True
self.strategy.logger.info(f'Close below lower Bollinger band, entering long position at '
f'{close_prices.get_value()} on '
f'{datetime.fromtimestamp(self.scheduler.get_time() / 1000.0)}')
elif self.has_position and close_prices.get_value() > bbands.get_value().upper:
self.cum_pnl = self.cum_pnl + close_prices.get_value() - self.last_entry
self.has_position = False
self.strategy.logger.info(f'Close above upper Bollinger band, exiting long position at '
f'{close_prices.get_value()} on '
f'{datetime.fromtimestamp(self.scheduler.get_time() / 1000.0)}, '
f'cum PnL={self.cum_pnl}')
return False
network.connect(bbands, BollingerTrader(scheduler, self))
For now we'll just print the entry/exit prices and times and cumulative P&L, but StrategyContext
offers an autofill simulator in backtest mode which can make use of order book information to get more accurate transaction costs than the simple mechanism shown above.
Backtesting the strategy
With the 0.4.0 release there are now two strategy containers: serenity.algo.engine.AlgoEngine
for live trading, and serenity.algo.backtester.AlgoBacktester
for backtesting. They take the same format YAML configuration for the strategies, but different command line arguments. The heart of the backtester leverages Tau's historical mode and the new HistoricMarketdataService
which loads marketdata from Azure storage and schedules it:
self.scheduler = HistoricNetworkScheduler(start_time_millis, end_time_millis)
instrument_cache = InstrumentCache(cur, TypeCodeCache(cur))
instruments_to_cache_txt = self.bt_env.getenv('INSTRUMENTS_TO_CACHE')
instruments_to_cache_list = instruments_to_cache_txt.split(',')
instruments_to_cache = []
for instrument in instruments_to_cache_list:
exchange, symbol = instrument.split('/')
instruments_to_cache.append(instrument_cache.get_exchange_instrument(exchange, symbol))
md_service = HistoricMarketdataService(self.scheduler, instruments_to_cache,
self.bt_env.getenv('AZURE_CONNECT_STR'))
The config is similar to live with the exception of specifying autofill simulator for the exchange,providing for loading Azure connection string from the system environment, and a list of instruments to prepare to load into HistoricMarketdataService
:
api-version: v1Beta
environment:
- key: EXCHANGE_ID
value: autofill
- key: EXCHANGE_INSTANCE
value: prod
- key: AZURE_CONNECT_STR
value-source: SYSTEM_ENV
- key: INSTRUMENTS_TO_CACHE
value: Phemex/BTCUSD
strategies:
- name: BollingerBands
module: bbands1
strategy-class: BollingerBandsStrategy1
environment:
- key: TRADING_INSTRUMENT
value: Phemex/BTCUSD
- key: BBANDS_WINDOW
value: 20
- key: BBANDS_NUM_STD
value: 2
You run it by pointing to the config and strategy code directory, and give it start/end times:
--config-path=examples/bbands1_algo_backtester_config.yaml --strategy-dir=examples/ --start_time="2020-01-01T00:00:00" --end_time="2020-09-19T00:00:00"
Strategy performance
This simple intraday strategy appears very profitable (158% annualized returns), but we should take care of not putting too much into the calculated cumulative P&L. The period studied came post-COVID crash in March, and we have not properly accounted for transaction costs, benchmarked it against a simple buy-and-hold strategy or S&P 500 returns, or quantified the risk taken or looked at things like maximum drawdowns. We will turn to the subject of strategy analytics in a coming blog post and we'll see if these initial results hold up.
References
Last weekend I read Ernest P. Chan's Machine Trading and I am currently working through Sebastien Donadio's Learn Algorithmic Trading, both of which provided ideas for the backtester and sample strategies.