If you're new to trading, it may be challenging to know how to get started. There are so many new terms, maths, and concepts, it can seem overwhelming!

Now you have to take all that stuff and figure out how to make a profitable system out of it?

Most people give up at this point.

To address this, we're building on a system that's built for newbies, Rob Carver's Starter System as outlined his book, Leveraged Trading.

Improving the Start System

In the last post, we introduced the basics of the system and tested it on a random stock from the S&P 500.

This time, we're going to apply some of the improvements that Carver suggests and get a bit more advanced.

This is still a beginner's system, but it will show you how you can begin to tie entries, exits, and risk management together to have a profitable model!

Adding Entry Rules

The Starter System has one-entry rule, a 16 and 64-day moving average crossover (MAC).

You go long when the faster, 16-day simple moving average (SMA) is above the slower, 64-day SMA, and go short when the situation is reversed.

That's it. That's your entry rule.

Carver argues that we can do better if we add additional entry rules. His research shows that our Sharpe ratio increases with new rules, although it tails off after a certain point.

Here's the table:

carver-sharpe-increase-rules.png

So what rules do we have?

Carver suggests three basic rules with multiple variations: moving average crossovers, mean breakout, and carry.

He's a trend follower, so we get two trend following signals and carry signal to work with. Whether you're advanced or a beginner, you'll see that this works quite well.

Moving Average Crossover

We're not going to dive deeper into MACs because we already discussed them above (you can read more here and here if you're interested).

We already have one in the basic starter system. This new and improved version uses four MACs to determine the entry: 8/32, 16/64, 32/128, and 64/256-day.

Mean Breakout

Carver introduces a custom signal he calls a breakout. He admits that it's not really an appropriate name for the signal, so I'm going to modify it slightly by calling it the mean breakout (MBO) to avoid any confusion with the classic breakout signal.

This signal is calculated by taking the difference of the current price and the SMA over N periods. This value is then divided by the difference between the highest and lowest closing prices from the last N periods.

In pseudocode, we have:

MBO[t] = (P[t] - SMA[t]) / (max(P[-N:t]) - min(P[-N:t))

Or, mathematically, we can write the mean breakout as:

$$MBO_t^N = \frac{P_t - SMA_t^N}{\textrm{max}(P^N) - \textrm{min}(P^N)}$$

where P_t is our price at time t, and P^N is our last N prices. Our MBO rule then is just:

$$MBO_t^N > 0 \Rightarrow \text{go long}$$ $$MBO_t^N < 0 \Rightarrow \text{short}$$

Because \textrm{max}(P^N) - \textrm{min}(P^N) > 0 you can get the exact same signals if you just compare price to the SMA.

So what's the advantage of scaling the price by the range of closing prices?

While Carver likes to trade when the value is greater than or less than 0, scaling it like this allows you more flexibility and allows you to trade multiple instruments in the same way (coming in a future post).

For example, because everything is scaled, you could be a bit more conservative with your MBO and wait until it hits 0.1 instead of 0. This same scaling would work for any instrument regardless of price.

If you're just looking at the price and the SMA, you'd have to choose a specific price difference (e.g. when price is $0.50 above or below the SMA) before you get a signal. This means your signal could become more or less sensitive over time as the price of your underlying security changes or volatility changes.

If you want that behaviour, then fine. But you probably want to be a bit more clever about your adjustments!

Carver gives us multiple values of N for our MBO calculation to add to our system: 20, 40, 80, 160, and 320 days.

Carry

Our last signal is a trade that would pay us if nothing happened: the Carry Rule. The carry of a an asset, if positive, is the return obtained by holding it or, if negative, the cost incurred by holding it.

Imagine you're holding onto a dividend paying stock that currently has a 5% yield. This means, buying the stock today should get you a 5% return in one year if the price remains the same, so its carry is positive.

If you have a stock without a dividend, then your expected return is 0 on the same trade.

If we define the carry return as the expected annualized return on a long position if the underlying price remains unchanged, then it makes sense that we should go long if we get a positive carry on a stock, and go short if we get a negative carry.

How do we figure that out?

Glad you asked.

First, we estimate the dividend yield based on the previous 12-months of dividends and the current price. If we have a stock that paid out $5 in dividends and is trading for $100, then we have a 5% yield.

The next few values are broker dependent, namely, how much we pay to borrow a stock, to buy on margin, and the interest we get on our cash. Everyone is different, so check your broker for these values if you're interested in including this signal.

We need to look at the net return on our long and short positions, and compare those to determine our signal.
For net long return, we'll take our dividend (d) and subtract the interest we pay in margin i_m. If we pay 4% to buy on margin and have a 5% dividend-paying stock, then our net long return is 1%.

$$N_l = d - i_m = 5\% - 4\% = 1\%$$

Easy enough.

Now, we look at our net short return.

For this, we take the annual interest we get paid on our cash balance (which has been basically 0% for the past 10+ years at this point) and subtract our borrowing cost to short a stock and the dividend.

We do this because when we short a stock, we pay a fee to borrow it, but then we sell it immediately. So we get the cash from the sale which we can drop in our account and earn interest on (if we're so lucky), but we need to also pay the dividend to the owner when we return the stock. Thus if the price doesn't change, we get the interest income from the cash minus our borrowing fee and minus the dividends.

If we get 0% in interest and pay 0.1% to short, then we have:

$$N_s = i - C_b - d = 0\% - 0.1\% - 5\% = -5.1\%$$

Finally, we stick these together by averaging the two to get our expected annualized net return R:

$$R = \frac{N_l - N_s}{2} = \frac{1\% - -5.1\%}{2} = 3.05\%$$

The Carry Rule then is:

$$R > 0 \Rightarrow \text{go long}$$ $$R < 0 \Rightarrow \text{short}$$

Essentially, this equation tells us to go long when the expected return by going long is greater than the expected return of going short (so the numerator in R is positive), and to go short when it's more profitable to do so (i.e. the numerator is negative).

We get 3.05% from this little calculation meaning this hypothetical stock and brokerage account would yield a long carry signal.

We can get the historical dividend rates from the Yahoo! Finance API, but the interest rates and borrowing costs aren't going to be easy to backtest - I have no clue what my broker was charging 20 years ago.

So, we're going to run a model without the carry signal, and another with it included, but using the rates and fees above. Try your own and see how it goes!

Combining Multiple Signals

We still have one stock with one exit rule (stop loss) and one position sizing rule (see last post for the details). How do we now trade it with all of these different signals?

With a weighted voting system.

We have 10 different signals (4 MACs, 5 MBOs, 1 carry). The MACs and MBOs are similar types of signals, namely trend following signals. So, we may want to adjust our weights accordingly.

Carver recommends a top-down approach: weighting begins at the highest level with signal type (e.g. trend following vs carry) then we split weights by the particular rule (e.g. MAC vs MBO), finally, we divide weights by the variation of the rule.

In this 10-signal case we wind up with 50% of our voting power assigned to our carry rule, 6.25% assigned to each MAC variation, and 5% for each MBO variation.

starter-system-weights-with-carry-1024x415.png

Each long signal is assigned a value of 1, and each short signal has a value of -1. Finally, to determine if we should buy or sell a stock at a given time period, we weight all of the signals from the variations by the weights above.

Here's a quick example:

starter-system-weights-table.png

Summing the values in the right-hand column, we get 0.675. Since this is greater than 0, we go long. If it was less than 0, we would have short.

Because we only have two types of signals, this is dominated by the carry signal which comprises 50% of our weights. If we were to drop it, we'd have the following weights.

starter-system-weights-trend-following-1024x431.png

Beefing up our Starter System

Let's give a quick overview of our updated system's rules:

  1. Entry rules:
    • Go long if the sum of the weighted signals is greater than 0.
    • Go short if the sum of the weighted signals is less than 0.
  1. Exit rules:
    • Exit long positions if the instrument closes below the stop price.
    • Exit short positions if the instrument closes above the stop price.
  1. Instrument rule:
    • Trade only one low-cost instrument.
  1. Position sizing rule:
    • A position is sized according to the target risk times your trading capital divided by the instrument risk.

All we did is take our original starter system and update the entry rules.

Finally, let's turn to getting this implemented in Python.

Coding our Starter System

We have the algorithm in hand, let's get the code in place so we can build a trading bot.

We're going to build a class we'll call StarterSystem. This class will take the ticker we want to trade as an input, a dictionary with our signals, and a number of personalized values discussed previously (e.g. target risk, starting capital, shorting costs, etc.).

We need a few standard packages to get started.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf
from datetime import timedelta
from copy import copy

Here's the code to initialize our class:

class StarterSystem:
  '''
  Upgraded Start System using multiple entry rules. Adapted from Rob Carver's
  Leveraged Trading: https://amzn.to/3C1owYn
  '''
  def __init__(self, ticker: str, signals: dict,
    target_risk: float = 0.12, stop_loss_gap: float = 0.5, 
    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 = []):
    
    self.ticker = ticker
    self.signals = signals
    self.target_risk = target_risk
    self.stop_loss_gap = stop_loss_gap
    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.signal_names = []

    self._getData()
    self._calcSignals()
    self._setWeights(weights)

There are a lot of parameters here, but most should make sense if you've followed along this far. All of these parameters are assigned to an attribute in the class, then we call three methods:

  • _getData()
  • _calcSignals()
  • _setWeights()

_getData() is very straightforward, especially if you've read other blog posts of ours in the past. It uses the yfinance package to call the Yahoo Finance API and download our data. It then drops some of the unused columns and saves the data frame as an attribute.

def _getData(self):
    yfObj = yf.Ticker(self.ticker)
    df = yfObj.history(start=self.start, end=self.end)
    df.drop(['Open', 'High', 'Low', 'Stock Splits', 'Volume'],
            inplace=True, axis=1)
    self.data = df

_calcSignals() is probably the most involved of our initialization methods. Before diving into this, let's look at how we structure our dictionary of signals.

To provide some flexibility so you can easily change these values as you see fit, we'll write our signal dictionary as follows:

sig_dict_no_carry = {
    'MAC' : {
        0: {'fast': 8,
            'slow': 32},
        1: {'fast': 16,
            'slow': 64},
        2: {'fast': 32,
            'slow': 128},
        3: {'fast': 64,
            'slow': 256}
    },
    'MBO': {
        0: 20,
        1: 40,
        2: 80,
        3: 160,
        4: 320
    }
}

This dictionary has the MAC and MBO with the parameters for each in a nested dictionary.

If we want to add our carry signal, we'll follow the same pattern and just set the value to True.

sig_dict_carry = copy(sig_dict_no_carry)
sig_dict_carry['CAR'] = {0: True}
sig_dict_carry
{'CAR': {0: True},
 'MAC': {0: {'fast': 8, 'slow': 32},
  1: {'fast': 16, 'slow': 64},
  2: {'fast': 32, 'slow': 128},
  3: {'fast': 64, 'slow': 256}},
 'MBO': {0: 20, 1: 40, 2: 80, 3: 160, 4: 320}}

Setting CAR to False is going to give us the same behaviour as not including it at all (also, I just used CAR to keep everything to a concise TLA).

Now that we have our signal dictionary, we can look at _calcSignals().

This function loops over the items in signals and calculates our moving averages, dividend yields, and the like for each signal based on the parameters provided in the dictionary.

def _calcSignals(self):
    self.data['STD'] = self.data['Close'].pct_change().rolling(252).std()
    self.n_sigs = 0
    for k, v in self.signals.items():
      if k == 'MAC':
        for v1 in v.values():
          self._calcMAC(v1['fast'], v1['slow'])
          self.n_sigs += 1
          
      elif k == 'MBO':
        for v1 in v.values():
          self._calcMBO(v1)
          self.n_sigs += 1

      elif k == 'CAR':
        for v1 in v.values():
          if v1:
            self._calcCarry()
            self.n_sigs += 1

If we have a MAC signal, then we call _calcMAC and assign the resulting long/short signal from our moving average crossover to a unique column in the data frame (I also saved the MAC values themselves for later in case you want to plot the results or dig deeper).

We also append the unique signal name to a list called signal_names so we can easily subset our data frame.

def _calcMAC(self, fast, slow):
    name = f'MAC{self.n_sigs}'
    if f'SMA{fast}' not in self.data.columns:
      self.data[f'SMA{fast}'] = self.data['Close'].rolling(fast).mean()
    if f'SMA{slow}' not in self.data.columns:
      self.data[f'SMA{slow}'] = self.data['Close'].rolling(slow).mean()
    self.data[name] = np.where(
        self.data[f'SMA{fast}']>self.data[f'SMA{slow}'], 1, np.nan)
    self.data[name] = np.where(
        self.data[f'SMA{fast}']<self.data[f'SMA{slow}'], -1,
        self.data[name]
    )
    self.data[name] = self.data[name].ffill().fillna(0)
    self.signal_names.append(name)

The other methods, _calcMBO() and _calcCarry() do the same thing, just with their unique signals, so I'm not going to go into the details - just scroll down for all of the code below!

The last method here is _setWeights(). This will default to the top-down weighting approach showed above using _topDownWeighting(), but we can also pass custom weights.

If you set custom weights, be sure that they add up to 1 and match the number of signals you pass, otherwise, you'll get an error!

def _setWeights(self, weights):
    l_weights = len(weights)
    if l_weights == 0:
      # Default to Carver's top-down approach
      self.signal_weights = self._topDownWeighting()
    elif l_weights == self.n_sigs:
      assert sum(weights) == 1, "Sum of weights must equal 1."
      self.signal_weights = np.array(weights)
    else:
      raise ValueError(
          f"Length of weights must match length of signals" +
          f"\nSignals = {self.n_sigs}" +
          f"\nWeights = {l_weights}")

With that, we've downloaded the data and prepped it for testing!

The next step is to build the run() method which will test our strategy on our historical data.

We've taken advantage of the fact that we have a carry strategy to add some additional nuance to our backtest, namely shorting costs and dividends (if any).

Dividends are paid out to directly to our cash balance, no re-investing! When short, dividends will be subtracted from our cash balance.

Shorting costs calculated daily based on the borrowing rate. This can vary by the stock and brokerage, but is calculated using 360 days per year (weird industry convention*) based on the share price.

*Maybe this isn't so weird because it allows your broker on Wall St. to charge you just a bit more every day than using the actual number of days in a year. Makes sense now, doesn't it?

$$C_B = \frac{P_t c_b}{360}$$

where C_B is the daily cost to borrow (in USD, or whatever your currency is), P_t is the closing price, and c_b is the annualized borrowing cost we referred to above.

If any of these additional costs causes our cash balance to go negative, we'll pay a daily rate based on our margin costs. While we're at it, I'll also add our interest rate to our cash balance that will be calculated daily (by default this is set to 0, but if you're broker pays something, then update it to reflect your values).

Most retail traders are able to access commission free trading for stocks, so I'm going to continue to leave those out.

This will be a more accurate backtest by taking into account these costs. Your costs may be very different than the defaults, so update accordingly!

Starter System 2.0

I can't keep you waiting any longer, here's the complete StarterSystem:

class StarterSystem:
  '''
  Upgraded Start System using multiple entry rules. Adapted from Rob Carver's
  Leveraged Trading: https://amzn.to/3C1owYn
  '''
  def __init__(self, ticker: str, signals: dict,
    target_risk: float = 0.12, stop_loss_gap: float = 0.5, 
    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 = []):
    
    self.ticker = ticker
    self.signals = signals
    self.target_risk = target_risk
    self.stop_loss_gap = stop_loss_gap
    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.signal_names = []

    self._getData()
    self._calcSignals()
    self._setWeights(weights)

  def _getData(self):
    yfObj = yf.Ticker(self.ticker)
    df = yfObj.history(start=self.start, end=self.end)
    df.drop(['Open', 'High', 'Low', 'Stock Splits', 'Volume'],
            inplace=True, axis=1)
    self.data = df

  def _calcSignals(self):
    self.data['STD'] = self.data['Close'].pct_change().rolling(252).std() * /
      np.sqrt(252)
    self.n_sigs = 0
    for k, v in self.signals.items():
      if k == 'MAC':
        for v1 in v.values():
          self._calcMAC(v1['fast'], v1['slow'])
          self.n_sigs += 1
          
      elif k == 'MBO':
        for v1 in v.values():
          self._calcMBO(v1)
          self.n_sigs += 1

      elif k == 'CAR':
        for v1 in v.values():
          if v1:
            self._calcCarry()
            self.n_sigs += 1

  def _calcMAC(self, fast, slow):
    name = f'MAC{self.n_sigs}'
    if f'SMA{fast}' not in self.data.columns:
      self.data[f'SMA{fast}'] = self.data['Close'].rolling(fast).mean()
    if f'SMA{slow}' not in self.data.columns:
      self.data[f'SMA{slow}'] = self.data['Close'].rolling(slow).mean()
    self.data[name] = np.where(
        self.data[f'SMA{fast}']>self.data[f'SMA{slow}'], 1, np.nan)
    self.data[name] = np.where(
        self.data[f'SMA{fast}']<self.data[f'SMA{slow}'], -1,
        self.data[name]
    )
    self.data[name] = self.data[name].ffill().fillna(0)
    self.signal_names.append(name)

  def _calcMBO(self, periods):
    name = f'MBO{self.n_sigs}'
    ul = self.data['Close'].rolling(periods).max()
    ll = self.data['Close'].rolling(periods).min()
    mean = self.data['Close'].rolling(periods).mean()
    self.data[f'SPrice{periods}'] = (self.data['Close'] - mean) / (ul - ll)
    
    self.data[name] = np.where(
        self.data[f'SPrice{periods}']>0, 1, np.nan)
    self.data[name] = np.where(
        self.data[f'SPrice{periods}']<0, -1,
        self.data[name])
    self.data[name] = self.data[name].ffill().fillna(0)
    self.signal_names.append(name)

  def _calcCarry(self, *args):
    name = f'Carry{self.n_sigs}'
    ttm_div = self.data['Dividends'].rolling(252).sum()
    div_yield = ttm_div / self.data['Close']
    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
    self.data[name] = np.nan
    self.data[name] = np.where(net_return > 0, 1, self.data[name])
    self.data[name] = np.where(net_return < 0, -1, self.data[name])
    self.data['net_return'] = net_return
    self.signal_names.append(name)

  def _topDownWeighting(self):
    mac_rules = 0
    mbo_rules = 0
    carry_rules = 0
    for k, v in self.signals.items():
      if k == 'MAC':
        mac_rules += len(v)
      elif k == 'MBO':
        mbo_rules += len(v)
      elif k == 'CAR':
        carry_rules += len(v)

    if carry_rules == 0:
      # No carry rules, divide weights between trend following rules
      weights = np.ones(mac_rules + mbo_rules)
      weights[:mac_rules] = 1 / mac_rules / 2
      weights[-mbo_rules:] = 1 / mbo_rules / 2
    elif mac_rules + mbo_rules == 0:
      weights = np.ones(carry_rules) / carry_rules
    else:
      weights = np.ones(mac_rules + mbo_rules + carry_rules)
      weights[:mac_rules] = 1 / mac_rules / 4
      weights[mac_rules:mac_rules + mbo_rules] = 1 / mbo_rules / 4
      weights[-carry_rules:] = 1 / carry_rules / 2

    return weights

  def _setWeights(self, weights):
    l_weights = len(weights)
    if l_weights == 0:
      # Default to Carver's top-down approach
      self.signal_weights = self._topDownWeighting()
    elif l_weights == self.n_sigs:
      assert sum(weights) == 1, "Sum of weights must equal 1."
      self.signal_weights = np.array(weights)
    else:
      raise ValueError(
          f"Length of weights must match length of signals" +
          f"\nSignals = {self.n_sigs}" +
          f"\nWeights = {l_weights}")
  
  def _getSignal(self, signals):
    return np.dot(self.signal_weights, signals)

  def _calcStopPrice(self, price, std, position, signal):
    if position != 0:
      return price * (1 - std * self.stop_loss_gap * np.sign(position))
    else:
      return price * (1 - std * self.stop_loss_gap * np.sign(signal))

  def _sizePosition(self, capital, price, instrument_risk):
    exposure = (self.target_risk * capital) / instrument_risk
    shares = np.floor(exposure / price)
    if shares * price > capital:
      return np.floor(capital / price)
    return shares

  def _calcCash(self, cash_balance, position, price, dividend):
    cash = cash_balance * self.daily_iob if cash_balance > 0 else \
      cash_balance * self.daily_margin_cost
    if position == 1:
      return cash + position * dividend
    elif position == -1:
      return cash - position * dividend - position * price * self.daily_short_cost
    return cash

  def run(self):
    position = np.zeros(self.data.shape[0])
    cash = position.copy()
    stops = position.copy()
    stops[:] = np.nan
    stop_triggered = stops.copy()
    for i, (ts, row) in enumerate(self.data.iterrows()):
      if any(np.isnan(row.values)):
        cash[i] += self._calcCash(cash[i-1], position[i], 
                                  row['Close'], row['Dividends']) if i > 0 \
          else self.starting_capital
        continue

      # Propagate values forward
      position[i] = position[i-1]
      cash[i] += self._calcCash(cash[i-1], position[i], 
                                row['Close'], row['Dividends'])
      stops[i] = stops[i-1]
      signal = self._getSignal(row[self.signal_names].values)
      new_stop = self._calcStopPrice(row['Close'], row['STD'],
                                     position[i], signal)
      if position[i] > 0:
        # Check for exit on stop
        if row['Close'] < stops[i]:
          cash[i] += position[i] * row['Close']
          position[i] = 0
          stop_triggered[i] = 1
        
        # Update stop
        elif new_stop > stops[i-1]:
          stops[i] = new_stop

      elif position[i] < 0:
        # Check for exit on stop
        if row['Close'] > stops[i]:
          cash[i] += position[i] * row['Close']
          position[i] = 0
          stop_triggered[i] = 1
        
        # Update stop
        elif new_stop < stops[i-1]:
          stops[i] = new_stop

      else:
        # Open new position
        if signal > 0:
          # Go long
          position[i] = self._sizePosition(cash[i], row['Close'], row['STD'])
          stops[i] = new_stop
          cash[i] -= position[i] * row['Close']

        elif signal < 0:
          # Go short
          position[i] = -self._sizePosition(cash[i], row['Close'], row['STD'])
          stops[i] = new_stop
          cash[i] -= position[i] * row['Close']
        else:
          continue
    
    self.data['position'] = position
    self.data['cash'] = cash
    self.data['stops'] = stops
    self.data['stop_triggered'] = stop_triggered
    self.data['portfolio'] = self.data['position'] * self.data['Close'] \
      + self.data['cash']
    self.data = calcReturns(self.data)

# Helper functions to calculate stats
def calcReturns(df):
  df['returns'] = df['Close'] / df['Close'].shift(1)
  df['log_returns'] = np.log(df['returns'])
  df['strat_returns'] = df['portfolio'] / df['portfolio'].shift(1)
  df['strat_log_returns'] = np.log(df['strat_returns'])
  df['cum_returns'] = np.exp(df['log_returns'].cumsum()) - 1
  df['strat_cum_returns'] = np.exp(df['strat_log_returns'].cumsum()) - 1
  df['peak'] = df['cum_returns'].cummax()
  df['strat_peak'] = df['strat_cum_returns'].cummax()
  
  # Get number of trades
  df['trade_num'] = np.nan
  trades = df['position'].diff()
  trade_start = df.index[np.where((trades!=0) & (df['position']!=0))]
  trade_end = df.index[np.where((trades!=0) & (df['position']==0))]
  df['trade_num'].loc[df.index.isin(trade_start)] = np.arange(
      trade_start.shape[0])
  df['trade_num'] = df['trade_num'].ffill()
  df['trade_num'].loc[(df.index.isin(trade_end+timedelta(1))) & 
                      (df['position']==0)] = np.nan
  return df

def getStratStats(log_returns: pd.Series,
  risk_free_rate: float = 0.02):
  stats = {}  # Total Returns
  stats['tot_returns'] = np.exp(log_returns.sum()) - 1 
   
  # Mean Annual Returns
  stats['annual_returns'] = np.exp(log_returns.mean() * 252) - 1 
   
  # Annual Volatility
  stats['annual_volatility'] = log_returns.std() * np.sqrt(252)
   
  # Sortino Ratio
  annualized_downside = log_returns.loc[log_returns<0].std() * \
    np.sqrt(252)
  stats['sortino_ratio'] = (stats['annual_returns'] - \
    risk_free_rate) / annualized_downside  
   
  # Sharpe Ratio
  stats['sharpe_ratio'] = (stats['annual_returns'] - \
    risk_free_rate) / stats['annual_volatility']  
   
  # Max Drawdown
  cum_returns = log_returns.cumsum() - 1
  peak = cum_returns.cummax()
  drawdown = peak - cum_returns
  max_idx = drawdown.argmax()
  stats['max_drawdown'] = 1 - np.exp(cum_returns[max_idx]) \
    / np.exp(peak[max_idx])
   
  # Max Drawdown Duration
  strat_dd = drawdown[drawdown==0]
  strat_dd_diff = strat_dd.index[1:] - strat_dd.index[:-1]
  strat_dd_days = strat_dd_diff.map(lambda x: x.days).values
  strat_dd_days = np.hstack([strat_dd_days,
    (drawdown.index[-1] - strat_dd.index[-1]).days])
  stats['max_drawdown_duration'] = strat_dd_days.max()
  return {k: np.round(v, 4) if type(v) == np.float_ else v
          for k, v in stats.items()}

Now, we simply choose a stock to apply this to and we're off!

I always like to just grab something at random from the S&P 500 to take a look at the model.

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']
# Sample symbols
# ticker = np.random.choice(syms.values)
ticker = 'HAL' # Sampled during our run
print(f'Ticker:\t{ticker}')
sys_carry = StarterSystem(ticker, sig_dict_carry)
sys_carry.run()
sys = StarterSystem(ticker, sig_dict)
sys.run()

We just ran two versions of our improved starter system, one with the carry term and one without.

Let's see how the results turned out and compare with a baseline buy and hold for the selected ticker and the SPY ETF.

# Get SPY for a benchmark
df_spy = yf.Ticker('SPY').history(start=sys.start, end=sys.end)
df_spy['log_returns'] = np.log(df_spy['Close'] / df_spy['Close'].shift(1))
df_spy['cum_returns']  = np.exp(df_spy['log_returns'].cumsum()) - 1

strat_stats = pd.DataFrame(
    getStratStats(sys_carry.data['strat_log_returns']),
    index=['Strategy'])
strat_no_carry_stats = pd.DataFrame(
    getStratStats(sys.data['strat_log_returns']),
    index=['Strategy (no Carry)'])
buy_hold_stats = pd.DataFrame(
    getStratStats(sys.data['log_returns']),
    index=['Buy and Hold'])
spy_stats = pd.DataFrame(
    getStratStats(df_spy['log_returns']),
    index=['S&P 500'])

stats = pd.concat([strat_stats, strat_no_carry_stats,
                   buy_hold_stats, spy_stats])
stats
starter-system-2-performance-stats-1024x154.png

Both of our strategies blew the baselines away in terms of total returns.

Annual volatility was fairly high for these models, which significantly reduced our Sharpe ratio, moving them closer to the S&P 500.

Looking at the charts below, we do see that the distribution of returns was very similar for both strategies. They exhibit that fat right tail characteristic of trend following strategies whereby a few, large outliers make up for a large number of small losses.

sys_carry_trade_rets = sys_carry.data.groupby(
    'trade_num')['strat_log_returns'].sum() * 100
sys_trade_rets = sys.data.groupby(
    'trade_num')['strat_log_returns'].sum() * 100

# Plot results
colors = plt.rcParams['axes.prop_cycle'].by_key()['color']

fig, ax = plt.subplots(3, figsize=(15, 10))
ax[0].plot(sys.data['Close'], label='Close')
ax[0].set_ylabel('Price ($)')
ax[0].set_xlabel('Date')
ax[0].set_title(f'Price for {ticker}')
ax[0].legend(loc=1)

ax[1].plot(sys_carry.data['strat_cum_returns'] * 100, 
           label='Strategy')
ax[1].plot(sys.data['strat_cum_returns'] * 100, 
           label='Strategy (no Carry)')
ax[1].plot(sys.data['cum_returns'] * 100, 
           label='Buy and Hold')
ax[1].plot(df_spy['cum_returns'] * 100, 
           label='SPY')
ax[1].set_ylabel('Returns (%)')
ax[1].set_xlabel('Date')
ax[1].set_title(f'Cumulative Returns for Starter Strategies vs Baselines')
ax[1].legend(loc=2)

ax[2].hist(sys_carry_trade_rets, bins=50, alpha=0.3, label='Strategy')
ax[2].hist(sys_trade_rets, bins=50, alpha=0.3, label='Strategy (no Carry)')
ax[2].axvline(sys_carry_trade_rets.mean(), 
          label=f'Mean Return = {sys_carry_trade_rets.mean():.2f}', 
          c=colors[0])
ax[2].axvline(sys_trade_rets.mean(), 
          label=f'Mean Return = {sys_trade_rets.mean():.2f}', 
          c=colors[1])
ax[2].set_ylabel('Trade Count')
ax[2].set_xlabel('Return (%)')
ax[2].set_title('Profile of Trade Returns')
ax[2].legend(loc=2)
plt.tight_layout()
plt.show()
starter-system2-results-plot-1024x678.png

The system with carry has much higher volatility, frequently mirroring the movements of the underlying. This is because the carry term gives the model a long bias.

This stock is a dividend paying stock. Although the dividend is small, it provides some positive expectation for going long the stock, enough so that the carry expectation is always positive. Given that the carry term has 50% of the weighting among the signals, all 9 other signals would have to go negative simultaneously to equal the single carry term, at which point it would simply be neutral and stay out of the market if no position was on.

We can see this in the plot below showing the returns for shorts and longs for each model - note there are no shorts in the strategy with carry.*

*For those following along with the code, I got tired of descriptive variable names. You can deal with A, B, X and Y for a bit, OK?

# Long/Short Breakdown
X = sys.data.groupby('trade_num')['position'].unique().map(
    lambda x: x[np.where(x!=0)].take(0))
A = pd.concat([sys_trade_rets, X], axis=1)
A['long'] = np.sign(A['position'])

Y = sys_carry.data.groupby('trade_num')['position'].unique().map(
    lambda x: x[np.where(x!=0)].take(0))
B = pd.concat([sys_carry_trade_rets, Y], axis=1)
B['long'] = np.sign(B['position'])

fig, ax = plt.subplots(1, 2, figsize=(12, 8))

ax[0].hist(B.loc[B['long']==1]['strat_log_returns'], bins=50,
         label='Long', alpha=0.3)
ax[0].hist(B.loc[B['long']==-1]['strat_log_returns'], bins=50,
         label='Short', alpha=0.3)
ax[0].set_title('Starter System Trade Returns')
ax[0].set_ylabel('Count')
ax[0].set_xlabel('Return (%)')

ax[1].hist(A.loc[A['long']==1]['strat_log_returns'], bins=50,
         label='Long', alpha=0.3)
ax[1].hist(A.loc[A['long']==-1]['strat_log_returns'], bins=50,
         label='Short', alpha=0.3)
ax[1].set_title('Starter System (no Carry) Trade Returns')
ax[1].set_ylabel('Count')
ax[1].set_xlabel('Return (%)')

plt.legend()
plt.show()
starter-system2-trade-distribution-plot.png

This result reminds me of a discussion with trader Tom Basso who tested multiple markets with random entries but good risk management via stop losses and position sizes and made money with it.

Basso wasn't the only one to do this. Others have replicated these results.

You can even find guys on YouTube doing this:

It comes back to a point we hammer home repeatedly: risk management is your number one job as a trader.

After that digression, let's take a quick look at the annual returns for our strategies.*

*Still tired of coming up with descriptive names.

sys_carry.data['year'] = sys_carry.data.index.map(lambda x: x.year)
sys.data['year'] = sys.data.index.map(lambda x: x.year)
df_spy['year'] = df_spy.index.map(lambda x: x.year)

a = sys_carry.data.groupby('year')['strat_log_returns'].sum() * 100
b = sys.data.groupby('year')['strat_log_returns'].sum() * 100
c = df_spy.groupby('year')['log_returns'].sum() * 100

ann_rets = pd.concat([a, b, c], axis=1)
ann_rets.round(2)
starter-system-2-annual-returns-table.png

These strategies provided some serious annual volatility! This table illustrates better than the plot above (surprising, but it comes down to scaling issues) why those Sharpe ratios were as low as they were.

This kind of volatility is going to be tough to stomach, but if you're looking at 7-15x returns, you may be able to deal with it.

Of course, this is just a backtest. There's no guarantee that these strategies would perform this well in the future, or on all instruments.

Better to be Lucky than Good?

Maybe we just got lucky picking HAL out of the current S&P 500 and applying this to TSCO would fail miserably.

So how do we improve our odds of getting lucky? There's got to be some kind of instrument dependence going on here?

Well, that will be the subject of our next post - diversification.

In fact, according to Carver and his data, diversification is going to give you even better improvement over the starter system than adding multiple entries.

Subscribe to get emails so you don't miss our next post when we boost this system even further. We're building this out into a trading bot that you can run yourself to trade your own account!

If that interests you, be sure to check us out where we have a free demo so you can build your own, customized trading bot with no-code and deploy it to trade in the markets!

You can skip all of this work and be up and running in a few minutes!