Systematically trading a single instrument can be a bit dull.
There are times when your chosen stock isn’t trending or doing much. So your system just sits there and waits…and waits..and waits.
Obviously we don’t want to trade just to trade — that’s a good way to start losing money. But if you’re bored, you’re probably not going to stick to your system — especially if you’re feeling the FOMO watching some other asset really take off!
The good news is we can add other instruments to our algorithmic trading system and we can get better results at the same time!
We Value Diversity
We have been constructing a trend following system step-by-step over this series of articles. Trend following hunts for high-performing outliers, but we know we can’t predict them, so we don’t try.
If you’re running a system on a single instrument, you’re essentially predicting that you’ll get some big moves in this instrument. Given the hundreds of thousands of instruments available out there (stocks, bonds, crypto, commodities, currencies, and everything else you can trade), odds are, you’re going to be wrong.
This is where diversification can help, a lot.
Diversification is the only free lunch in finance.
By adding other instruments to your system, you’re increasing your odds of finding one of those outlier moves so your system can grab it and make a nice profit.
In fact, Rob Carver (the guy who developed the system we’re implementing in this series) argues that diversifying is the single biggest boost to this system’s performance. Even adding one random instrument to your single-instrument system can yield a 20% increase in your Sharpe Ratio!
Of course, there’s a law of diminishing returns to contend with. Once you get beyond a certain number (20–30) then the benefits of diversification slow down.
Also, to be well-diversified typically means you need more money to trade.
If you’ve got a $10,000 account and one instrument, then your un-levered max position is $10,000. Two instruments is going to drop that to $5,000 because we always want to keep some money available to open a position if our system gives us a signal (remember, we don’t know what will trend or when).
If you get up to 30 instruments, then you’ve got $333.33 per instrument. There are a lot of hot, trending stocks (e.g. Tesla) that you can’t even buy one share of for that amount!
That simple chart struck me with the same force I imagine Einstein must have felt when he discovered E=mc2: I saw that with fifteen to twenty good, uncorrelated return streams, I could dramatically reduce my risks without reducing my expected returns… I called it the “Holy Grail of Investing” because it showed the path to making a fortune.
There is a caveat to diversification. It only counts as diversification if you’re trading uncorrelated assets.
If everything you’re trading is in the energy sector and they all move up and down together, then you don’t have the kind of diversification you actually need.
Thankfully, through ETFs and other products, diversification is easier today for retail traders than it ever has been.
So be sure to check the correlations of your instruments and always test your strategy before diving in!
Diversification Multiplier
Let’s get to the specifics of this strategy. We’re building on our last article where we introduced a forecast to our system.
Because diversification reduces our risks, Carver introduces an instrument diversification multiplier (IDM) that we can use to boost our target risk. This is designed to ensure that we maximize the benefits of diversification.
There are IDM tables for multiple and single asset classes. If you’re finding uncorrelated assets to trade, then use the multiple asset class values. If you’re just adding more US stocks, then use the single asset class values.
The IDM values range from 1 to 2.5 for the multiple asset class category, and 1 to 1.4 for the single asset class group. Both reach their max at the 30+ instrument level where you really start to see fewer benefits from additional instruments.
To add this to our system, we only need to add the IDM value to our exposure equation (see the Position Sizing Rule here for details). It becomes:
where s is our position size in dollars, r_T is the risk target, C_i is the capital for this instrument, σ_i is our instrument risk, and I is our IDM.
To add this to the code, we can change our equation for the exposure like so:
exposure = (self.target_risk * self.idm * capital * signal) / \
instrument_risk
Building our Multi-Instrument System
Adding the IDM is simple given our existing starter system. It is a bit more complex to add the new instruments in an efficient manner.
The easy way to do it is to just add some for loops to the backtest code and call it a day. The problem is, as you add instruments, backtests can take a lot of time. We’re talking 30+ min per test (remember, we’re doing all of this from scratch using standard Python packages, not an optimized backtest engine).
To speed this up a little bit, we leveraged the vectorization capabilities in Pandas. We could speed this further by parallelizing our calculations using packages like Ray and Modin. This does make the code a bit harder to read (in my opinion) and because this is more for educational purposes, I opted for perspicuity and left optimization as an exercise for the reader.
This seems plenty fast for our purposes anyway. This code runs in 60–70 seconds for 15 stocks on Colab, with most of that devoted to downloading and prepping the data.
class DiversifiedStarterSystem(MultiSignalStarterSystem):
'''
Carver's Starter System without stop losses, multiple entry rules,
a forecast for position sizing and rebalancing, and multiple instruments.
Adapted from Rob Carver's Leveraged Trading: https://amzn.to/3C1owYn
Code for MultiSignalStarterSystem available here:
https://gist.github.com/raposatech/2d9f309e2a54fc9545d44eda821e29ad
'''
def __init__(self, tickers: list, signals: dict, target_risk: float = 0.12,
starting_capital: float = 1000, margin_cost: float = 0.04,
short_cost: float = 0.001, interest_on_balance: float = 0.0,
start: str = '2000-01-01', end: str = '2020-12-31',
shorts: bool = True, weights: list = [],
max_forecast: float = 2, min_forecast: float = -2,
exposure_drift: float = 0.1,
*args, **kwargs):
self.tickers = tickers
self.n_instruments = len(tickers)
self.signals = signals
self.target_risk = target_risk
self.starting_capital = starting_capital
self.shorts = shorts
self.start = start
self.end = end
self.margin_cost = margin_cost
self.short_cost = short_cost
self.interest_on_balance = interest_on_balance
self.daily_iob = (1 + self.interest_on_balance) ** (1 / 252)
self.daily_margin_cost = (1 + self.margin_cost) ** (1 / 252)
self.daily_short_cost = self.short_cost / 360
self.max_forecast = max_forecast
self.min_forecast = min_forecast
self.max_leverage = 3
self.exposure_drift = exposure_drift
self.signal_names = []
self.weights = weights
self.idm_dict = {
1: 1,
2: 1.15,
3: 1.22,
4: 1.27,
5: 1.29,
6: 1.31,
7: 1.32,
8: 1.34,
15: 1.36,
25: 1.38,
30: 1.4
}
self._getData()
self._calcSignals()
self._setWeights()
self._calcTotalSignal()
self._setIDM()
def _getData(self):
yfObj = yf.Tickers(self.tickers)
df = yfObj.history(start=self.start, end=self.end)
df.drop(['High', 'Open', 'Stock Splits', 'Volume', 'Low'],
axis=1, inplace=True)
# Drop rows where all closing prices are NaN
df = df.iloc[df['Close'].apply(
lambda x: all(~np.isnan(x)), axis=1).values]
df.columns = df.columns.swaplevel()
df = df.fillna(0)
self.data = df
def _setIDM(self):
keys = np.array(list(self.idm_dict.keys()))
idm_idx = keys[np.where(keys<=self.n_instruments)].max()
self.idm = self.idm_dict[idm_idx]
def _clipForecast(self, signal):
return signal.clip(upper=self.max_forecast, lower=self.min_forecast)
def _calcMAC(self, fast, slow, scale):
name = f'MAC{self.n_sigs}'
close = self.data.loc[:, (slice(None), 'Close')]
sma_f = close.rolling(fast).mean()
sma_s = close.rolling(slow).mean()
risk_units = close * self.data.loc[:, (slice(None), 'STD')].values
sig = sma_f - sma_s
sig = sig.ffill().fillna(0) / risk_units * scale
self.signal_names.append(name)
return self._clipForecast(sig).rename(columns={'Close': name})
def _calcMBO(self, periods, scale):
name = f'MBO{self.n_sigs}'
close = self.data.loc[:, (slice(None), 'Close')]
ul = close.rolling(periods).max().values
ll = close.rolling(periods).min().values
mean = close.rolling(periods).mean()
sprice = (close - mean) / (ul - ll)
sig = sprice.ffill().fillna(0) * scale
self.signal_names.append(name)
return self._clipForecast(sig).rename(columns={'Close': name})
def _calcCarry(self, scale):
name = f'Carry{self.n_sigs}'
ttm_div = self.data.loc[:, (slice(None), 'Dividends')].rolling(252).sum()
div_yield = ttm_div / self.data.loc[:, (slice(None), 'Close')].values
net_long = div_yield - self.margin_cost
net_short = self.interest_on_balance - self.short_cost - div_yield
net_return = (net_long - net_short) / 2
sig = net_return / self.data.loc[:, (slice(None), 'STD')].values * scale
self.signal_names.append(name)
return self._clipForecast(sig).rename(columns={'Dividends': name})
def _calcSignals(self):
std = self.data.loc[:, (slice(None), 'Close')].pct_change().rolling(252).std() \
* np.sqrt(252)
self.data = pd.concat([self.data,
std.rename(columns={'Close': 'STD'})], axis=1)
self.n_sigs = 0
for k, v in self.signals.items():
if k == 'MAC':
for v1 in v.values():
sig = self._calcMAC(v1['fast'], v1['slow'], v1['scale'])
self.data = pd.concat([self.data, sig], axis=1)
self.n_sigs += 1
elif k == 'MBO':
for v1 in v.values():
sig = self._calcMBO(v1['N'], v1['scale'])
self.data = pd.concat([self.data, sig], axis=1)
self.n_sigs += 1
elif k == 'CAR':
for v1 in v.values():
if v1['status']:
sig = self._calcCarry(v1['scale'])
self.data = pd.concat([self.data, sig], axis=1)
self.n_sigs += 1
def _calcTotalSignal(self):
sigs = self.data.groupby(level=0, axis=1).apply(
lambda x: x[x.name].apply(
lambda x: np.dot(x[self.signal_names].values,
self.signal_weights), axis=1))
sigs = sigs.fillna(0)
midx = pd.MultiIndex.from_arrays([self.tickers, len(self.tickers)*['signal']])
sigs.columns = midx
self.data = pd.concat([self.data, sigs], axis=1)
def _sizePositions(self, cash, price, instrument_risk, signal, positions, index):
shares = np.zeros(self.n_instruments)
if cash <= 0:
return shares
sig_sub = signal[index]
ir_sub = instrument_risk[index]
capital = (cash + np.dot(price, positions)) / self.n_instruments
exposure = self.target_risk * self.idm * capital * sig_sub / ir_sub
shares[index] += np.floor(exposure / price[index])
insuff_cash = np.where(shares * price >
(cash * self.max_leverage) / self.n_instruments)[0]
if len(insuff_cash) > 0:
shares[insuff_cash] = np.floor(
(cash * self.max_leverage / self.n_instruments) / price[insuff_cash])
return shares
def _getExposureDrift(self, cash, position, price, signal, instrument_risk):
if position.sum() == 0:
return np.zeros(self.n_instruments), np.zeros(self.n_instruments)
capital = (cash + price * position) / self.n_instruments
exposure = self.target_risk * self.idm * capital * signal / instrument_risk
cur_exposure = price * position
avg_exposure = self.target_risk * self.idm * capital / instrument_risk * np.sign(signal)
# Cap exposure leverage
avg_exposure = np.minimum(avg_exposure, self.max_leverage * capital)
return (exposure - cur_exposure) / avg_exposure, avg_exposure
def _calcCash(self, cash_balance, positions, dividends):
cash = cash_balance * self.daily_iob if cash_balance > 0 else \
cash_balance * self.daily_margin_cost
long_idx = np.where(positions>0)[0]
short_idx = np.where(positions<0)[0]
if len(long_idx) > 0:
cash += np.dot(positions[long_idx], dividends[long_idx])
if len(short_idx) > 0:
cash += np.dot(positions[short_idx], dividends[short_idx])
return cash
def _logData(self, positions, cash, rebalance, exp_delta):
# Log data - probably a better way to go about this
self.data['cash'] = cash
df0 = pd.DataFrame(positions,
columns=self.tickers, index=self.data.index)
midx0 = pd.MultiIndex.from_arrays(
[self.tickers, len(self.tickers)*['position']])
df0.columns = midx0
df1 = pd.DataFrame(rebalance,
columns=self.tickers, index=self.data.index)
midx1 = pd.MultiIndex.from_arrays(
[self.tickers, len(self.tickers)*['rebalance']])
df1.columns = midx1
df2 = pd.DataFrame(exp_delta,
columns=self.tickers, index=self.data.index)
midx2 = pd.MultiIndex.from_arrays(
[self.tickers, len(self.tickers)*['exposure_drift']])
df2.columns = midx2
self.data = pd.concat([self.data, df0, df1, df2], axis=1)
portfolio = np.sum(
self.data.loc[:, (slice(None), 'Close')].values * df0.values,
axis=1) + cash
self.data['portfolio'] = portfolio
def _processBar(self, prices, sigs, stds, pos, cash):
open_long = np.where((pos<=0) & (sigs>0))[0]
if len(open_long) > 0:
# Short positions turned to long
lprices = prices[open_long]
cash += np.dot(pos[open_long], lprices)
pos[open_long] = 0
pos += self._sizePositions(cash,
prices, stds, sigs,
pos, open_long)
cash -= np.dot(pos[open_long], lprices)
open_short = np.where((pos>=0) & (sigs<0))[0]
if len(open_short) > 0:
# Close long position and open short
sprices = prices[open_short]
cash += np.dot(pos[open_short], sprices)
pos[open_short] = 0
if self.shorts:
pos -= self._sizePositions(cash,
prices, stds, sigs,
pos, open_short)
cash -= np.dot(pos[open_short], sprices)
neutral = np.where((pos!=0) & (sigs==0))[0]
if len(neutral) > 0:
cash += np.dot(pos[neutral],
prices[neutral])
pos[neutral] = 0
# Rebalance existing positions
delta_exposure, avg_exposure = self._getExposureDrift(
cash, pos, prices, sigs, stds)
drift_idx = np.where(np.abs(delta_exposure) >= self.exposure_drift)[0]
reb_shares = np.zeros(self.n_instruments)
if len(drift_idx) > 0:
reb_shares[drift_idx] = np.round(
delta_exposure * avg_exposure / prices)[drift_idx]
cash -= np.dot(reb_shares, prices)
pos += reb_shares
return pos, cash, reb_shares, delta_exposure
def run(self):
positions = np.zeros((self.data.shape[0], len(self.tickers)))
exp_delta = positions.copy()
rebalance = positions.copy()
cash = np.zeros(self.data.shape[0])
for i, (ts, row) in enumerate(self.data.iterrows()):
prices = row.loc[(slice(None), 'Close')].values
divs = row.loc[(slice(None), 'Dividends')].values
sigs = row.loc[(slice(None), 'signal')].values
stds = row.loc[(slice(None), 'STD')].values
pos = positions[i-1].copy()
cash_t = self._calcCash(cash[i-1], positions[i], divs) \
if i > 0 else self.starting_capital
pos, cash_t, shares, delta_exp = self._processBar(
prices, sigs, stds, pos, cash_t)
positions[i] = pos
cash[i] = cash_t
rebalance[i] = shares
exp_delta[i] = delta_exp
self._logData(positions, cash, rebalance, exp_delta)
self._calcReturns()
def _calcReturns(self):
self.data['strat_log_returns'] = np.log(
self.data['portfolio'] / self.data['portfolio'].shift(1))
self.data['strat_cum_returns'] = np.exp(
self.data['strat_log_returns'].cumsum()) - 1
self.data['strat_peak'] = self.data['strat_cum_returns'].cummax()
Testing our Diversified Trading System
We’re going to keep things simple and just sample a few stocks from the S&P 500 to show how this works. This is going to be somewhat diversified — at least we have multiple instruments — but not nearly as diversified as we need it to be to get really great results.
We’ll let you play with the model yourself to see what you can come up with.
Here’s the code for randomly sampling the S&P 500 and running our system with the default values.
url = 'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies'
table = pd.read_html(url)
df_sym = table[0]
syms = df_sym['Symbol']
# tickers = list(np.random.choice(syms.values, size=5, replace=False))
# We got the following from our sample:
tickers = ['CINF', 'DE', 'INFO', 'MO', 'STX']
sys = DiversifiedStarterSystem(tickers, signals=sig_dict)
sys.run()
And we get the following equity curve:
To be honest, the returns here aren’t spectacular pulling in only 2.2% per year. The volatility is very low, however, indicating that we may be able to increase our returns with a bump to our target risk, which is what Carver recommends for the full system, and something we didn’t do.
We can see this by looking at our exposures.
We have a very conservative system — it never goes beyond 50% invested at any given time. This keeps returns and volatility low, but also helps reduce the negative impact of drawdowns.
We can see that as volatility increases, the system tends to retreat into cash. The exception being the 2020 COVID Crash, which saw the system pick up huge returns on its shorts, but then give it all back because of the speed of the rebound off the bottoms.
This up-tick in vol was so great and happened so quickly, that it took much of 2020 for the system to begin to allocate more to the market, so it missed out on the biggest bounce off the lows.
We could do better if we were properly diversified.
All of our assets are highly correlated with one another, which I said would be an issue, but this is just an example — you’ve got to do your own research here!
Making the System Your Own
There’s a lot of good stuff in this system. Even in this small and random portfolio we see that it handles risk well and manages to avoid a lot of the big sell offs.
That’s absolutely critical for compounding wealth and being able to stick with a system.
It’s not perfect — but no system is — and could be improved.
First and foremost by looking at other markets and getting some proper diversification. Even if all you have is a US-based brokerage account, you could do a lot by taking advantage of the wide variety of ETFs currently on offer.
Second, update the default settings with your brokerage’s details on margin, interest, costs, and everything else you need to take into account. Maybe you have a place to store your cash to get some higher returns.
Finally, test it and see what happens!
Did you find a good system that works well? Let us know!
In our final post in this series, we’ll show you how to tie this into a broker so you can trade this system live.
Be sure to subscribe below to get updates so you don’t miss it!