Welcome to part two of this two-part series on how to create a trading strategy using Backtrader and Alpaca. In part one, we discussed how to connect Alpaca and Backtrader. In this post, we'll be building on our previous code and create an RSI stack strategy.
If we haven't met yet, my name is Leo Smigel, and I write about algorithmic trading and investing at Analyzing Alpha. With introductions out of the way and Backtrader receiving data from Alpaca, let's create that RSI stack.
Also, before I forget, all of the code will be on the Analyzing Alpha GitHub Repo.
Create the Strategy
Creating our RSI Stack strategy is relatively easy. We create our RSIStack class by inheriting all of the functionality from backtrader.strategy. We then set the parameters for our strategy in the params dictionary. The parameters dictionary is part of the Backtrader framework and makes our code more readable and maintainable. For our strategy, we set our overbought/oversold thresholds based on the conventional RSI settings, and we set our risk-to-reward ratio to 2. While this will work for our simple strategy, for a production strategy, you'll want to use market structure to determine the optimal risk-to-reward ratio.
class RSIStack(bt.Strategy):
params = dict(
rsi_overbought=70,
rsi_oversold=30,
rrr=2
)
With our derived strategy class created, we can now initialize a few attributes and create our indicators in __init__. __init__ preprocesses our data to prepare it for later use, making backtesting faster. Let's create an attribute to hold our 'alive' orders and our indicators. We create our RSI indicators on every data/timeframe and only create our ATR indicator on the timeframe we're using for position sizing.
def __init__(self):
self.orefs = None
self.inds = {}
for d in self.datas:
self.inds[d] = {}
self.inds[d]['rsi'] = bt.ind.RSI(d)
self.inds[d]['rsiob'] = self.inds[d]['rsi'] >= self.p.rsi_overbought
self.inds[d]['rsios'] = self.inds[d]['rsi'] <= self.p.rsi_oversold
for i in range(len(timeframes)-1, len(self.datas), len(timeframes)):
self.inds[self.datas[i]]['atr'] = bt.ind.ATR(self.datas[i])
With the initialization out of the way, it's time to start the strategy. Start enables us to run code before next
processes each bar. For our strategy, we use it to record the length of the lowest timeframe. Remember, the RSI stack requires all timeframes to be oversold or overbought. We need to reset the stack after each bar passes on the lowest timeframe.
def start(self):
# Timeframes must be entered from the highest to the lowest frequency.
# Getting the length of the lowest frequency timeframe will
# show us how many periods have passed
self.lenlowtframe = len(self.datas[-1])
self.stacks = {}
Now for next
This is where the bulk of the action happens as we process each bar.
First, we determine if the period has changed by saving the length of the lowest timeframe bar and checking it against the current length of the lowest timeframe bar.
def next(self):
# Reset all of the stacks if a bar has passed on our
# lowest frequency timeframe
if not self.lenlowtframe == len(self.datas[-1]):
self.lenlowtframe += 1
self.stacks = {}
Second, we determine if there is an RSI stack by iterating through all of the data feeds (datas) for each ticker and incrementing our stacks dictionary by one for each oversold/overbought condition. For this, we use the modulo operator and the timeframe length to determine when we're on a new ticker.
for i, d in enumerate(self.datas):
# Create a dictionary for each new symbol.
ticker = d.p.dataname
if i % len(timeframes) == 0:
self.stacks[ticker] = {}
self.stacks[ticker]['rsiob'] = 0
self.stacks[ticker]['rsios'] = 0
if i % len(timeframes) == len(timeframes) -1:
self.stacks[ticker]['data'] = d
self.stacks[ticker]['rsiob'] += self.inds[d]['rsiob'][0]
self.stacks[ticker]['rsios'] += self.inds[d]['rsios'][0]
We then delete any dictionary entry where an RSI stack isn't found.
for k,v in list(self.stacks.items()):
if v['rsiob'] < len(timeframes) and v['rsios'] < len(timeframes):
del self.stacks[k]
Now that we have our stacks, we're ready to trade. We only want to enter a position if there are dollars to deploy. In our case, if there aren't any existing positions, open orders and a stack is found. Then it's just a matter of buying/selling depending on if we’re extended to the downside/upside.
# Check if there are any stacks from the previous period
# And buy/sell stocks if there are no existing positions or open orders
positions = [d for d, pos in self.getpositions().items() if pos]
if self.stacks and not positions and not self.orefs:
for k,v in self.stacks.items():
d = v['data']
size = self.broker.get_cash() // d
if v['rsiob'] == len(timeframes) and \
d.close[0] < d.close[-1]:
print(f"{d.p.dataname} overbought")
risk = d + self.inds[d]['atr'][0]
reward = d - self.inds[d]['atr'][0] * self.p.rrr
os = self.sell_bracket(data=d,
price=d.close[0],
size=size,
stopprice=risk,
limitprice=reward)
self.orefs = [o.ref for o in os]
elif v['rsios'] == len(timeframes) and d.close[0] > d.close[-1]:
print(f"{d.p.dataname} oversold")
risk = d - self.inds[d]['atr'][0]
reward = d + self.inds[d]['atr'][0] * self.p.rrr
os = self.buy_bracket(data=d,
price=d.close[0],
size=size,
stopprice=risk,
limitprice=reward)
self.orefs = [o.ref for o in os]
Finally, we use notify_order
to print out our orders and to manage the self.orefs list attribute.
def notify_order(self, order):
self.log(f'Order - {order.getordername()} {order.ordtypename()} {order.getstatusname()} for {order.size} shares @ ${order.price:.2f}')
if not order.alive() and order.ref in self.orefs:
self.orefs.remove(order.ref)
With our strategy class complete, we run cerebro and check out our results. Looking at the console output and the matplotlib figure shows what we expect -- it appears we have a profit for the period.
...
SPY overbought
2020-08-05T15:15:00: Order - Limit Sell Submitted for -306.0 shares @ $331.65
2020-08-05T15:15:00: Order - Stop Buy Submitted for 306.0 shares @ $332.80
2020-08-05T15:15:00: Order - Limit Buy Submitted for 306.0 shares @ $329.37
2020-08-05T15:15:00: Order - Limit Sell Accepted for -306.0 shares @ $331.65
2020-08-05T15:15:00: Order - Stop Buy Accepted for 306.0 shares @ $332.80
2020-08-05T15:15:00: Order - Limit Buy Accepted for 306.0 shares @ $329.37
2020-08-05T15:15:00: Order - Limit Sell Completed for -306.0 shares @ $331.65
2020-08-06T17:00:00: Order - Stop Buy Completed for 306.0 shares @ $332.80
2020-08-06T17:00:00: Order - Limit Buy Canceled for 306.0 shares @ $329.37
Trade PNL: $-286.15
Final Portfolio Value: 101353.63
So what's next?
A lot ;). I do use an RSI stack as part of an indicator. Still, without divulging all of my secrets but pointing aspiring algorithmic traders in the right direction, perhaps you could combine RSI stacks with the order book looking for potential stock trades playing off of longer timeframe support/resistance in the direction of the ETF that the stock is a member of with SPY’s support...
You'll also want to convert this code into a custom indicator, create a sizing algorithm to trade multiple assets, and make sure you've got a statistically significant backtest with a strong rationale behind it.
And with that being said, you’re still just getting started. You have to love the journey if you want to become an algorithmic trader.
That's it for this series, but don't worry, there are at least two series to go.
If you enjoy learning about algorithmic trading, please check out my website where I write about all things quantamental and continue learning about my favorite Python trading tools -- Alpaca and Backtrader.
Again all of the code will be on the Analyzing Alpha GitHub Repo.
*Keep in mind that all investments involve risk and the past performance of a security or financial product does not guarantee future results or returns.
Follow @AlpacaHQ on Twitter!
Brokerage services are provided by Alpaca Securities LLC ("Alpaca"), member FINRA/SIPC, a wholly-owned subsidiary of AlpacaDB, Inc. Technology and services are offered by AlpacaDB, Inc.