Systematically trading a single instrument can be a bit dull.

come-on-do-something.png

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.

Harry Markowitz

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.

Ray Dalio

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.

carver-5-table1.png

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:

$$s = \frac{r_T C_i I}{\sigma_i}$$

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:

carver-5-starter-system-returns.png
carver-5-starter-system-stats-table.png

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.

carver-5-starter-system-exposure.png

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.

carver-5-starter-system-volatility.png

We could do better if we were properly diversified.

carver-5-starter-system-correlations.png

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!