Your biggest investment just took another move higher. It has gotten to the point that you start thinking about taking some profit off the table: it's looking more and more enticing by the day!

Do you do it?

If you're like most investors, you can't resist taking some money today, even if it winds up costing you in the long run.

Or, perhaps your brilliant investment thesis hasn't panned out. Your darling tech company is doing great things, but the market just hasn't noticed and keeps hammering the price lower.

You have conviction so you keep your position on, maybe even putting more money in justified by your belief that it's an even better deal now!

Both of these scenarios are all too common for new investors and lead to serious losses of wealth more often than not.

We're emotional creatures who get in our own way more often than not.

I was there and wanted to look for something new, a way to handle my money without the emotional whipsaw that leads to long-term losses.

How to do this?

Let a cold, calculating computer invest for me!

Rise of the Machines

Turning investing decisions over to a computer goes by a few names: algorithmic, trading, mechanical, or quantitative trading/investing.

Whatever you call it, having an algorithmic system that guides your decision making is a powerful tool for avoiding mistakes and building wealth.

An automated system can eliminate cognitive biases, rash and emotional decisions, and mental errors that most everyone struggles with. It can also be backtested by running it on historical data to get a feel for its performance characteristics so you can get an idea if this is a system you want to trade or not.

Some of the best investment track records have been built on powerful systems like this.

Sounds enticing, but where does one start?

That's where the Starter System comes into play.

This isn't my system. It was developed by the great trader Rob Carver and is laid out in his book, Leveraged Trading.

Carver set this up so that a newbies can get up and running quickly with their first system.

It's complete yet still simple in its rules and it can add some alpha to your trading strategy.

Carver goes into great detail in his book regarding why he makes certain design decisions - which is incredibly important to understand for a new trader - which we'll skip over here (invest in yourself and get a copy if you want more).

What we'll do, is show you how to implement his Starter System in Python. This should get you the basics so that you have a complete system to test.

Later, we'll follow up with improvements that Carver provides as well as how to integrate this code into a broker for live trading, so subscribe to get updates!

The Starter System's Rules

The system can be broken into 4 basic rules:

  1. An entry rule
  2. An exit rule
  3. A set of instruments to trade
  4. A position sizing rule

Every system needs at least these rules to be complete. Many have the first two or three, but leave number four to be implied (which usually means put everything into instrument X and sell it all on your way out).

There should be no ambiguity in your rules!

Computers don't like ambiguity, deep down, they're binary things. Something is true or false, meaning your rule was hit or not. There's no room for "maybe this fits my rule, or maybe not." So they need to be clearly defined.

So what are Carver's Starter System rules?

  1. Entry rules:
    • Go long if the 16-day simple moving average (SMA) crosses the 64-day SMA.
    • Go short if the 16-day SMA crosses below the 64-day SMA.
    • Skip signal if last trade in same direction ended in a stop.

2. Exit rules:

3. Instrument rule:

4. Position sizing rule:

Let's make these precise to ensure that a computer can execute these rules.

Entry Rules

We're only going to buy/short based on a simple moving average crossover.

If we use Carver's values of 16 and 64 days, then we go long if the average closing price for the past 16 days is greater than the average closing price of the past 64 days.

Python and Pandas makes this easy to calculate. If we have a data frame, with the closing price, than the SMA is just:

SMA1 = df['Close'].rolling(16).mean()
SMA2 = df['Close'].rolling(64).mean()</code>

Mathematically, we can write it as:

$$SMA_t^N = \frac{1}{N}\sum_{i=1}^N P_i$$

where N is the number of days and P is our price. We use t for the time and i to index our prices.

We have one exception to our general rule where we skip a trade after a stop. If our last long trade was stopped out, then we wait for SMA1 < SMA2 first, then we open up a new long position if SMA1 > SMA2.

The logic behind this is straightforward. You can (and often) get stopped out before a reversal occurs which is designed to keep you from losing money. So buying in again immediately following a stop may lead to additional losses if the trend is in fact changing.

Then, if we have no current positions and SMA1 > SMA2, open a long position, otherwise open a short position.

Our entry rule is as easy as that.

Exit Rules

To close out a trade, we can first look for a trend reversal. This means that we went long because SMA1 > SMA2, but now, SMA1 < SMA2, indicating a down trend for our system. This is something we want to get out of quickly!

Maybe the prices drop much more quickly so if we were to wait for a down trend, we'd wind up losing a lot of money.

In this case, we use a stop loss which sells a long position if the price closes below the stop price (or if it closes above the stop for a short position).

So that raises the question, how do you calculate the stop price?

While there are many different ways to do this in practice, Carver recommends setting the stop at 50% of the standard deviation of the price for the past year.

Roughly speaking, if the price moves by 10% per year, then we're going to set our stops to close a position if it moves against us by at least half that amount (i.e. 5%).

I realize that's not easy to picture, so hopefully some code helps:

def calcStopPrice(price, std, stop_loss_gap, trend_dir):
  # trend_dir == 1 if SMA1 > SMA2
  if trend_dir == 1:
    return price * (1 - std * stop_loss_gap)
  return price * (1 + std * stop_loss_gap)

We could run into some issues if we keep our stops pegged at a given level whereby we start to give back a lot of open profit as a trend begins to turn.

To deal with this, we'll also update our stop as the price moves (called a trailing stop).

Take our stop at 5% from above. Say we're long a position and entered at $10. Then our initial stop is going to be set at $9.50. If the price then rises to $12, we'll move our stop up to $11.40.

This will help us exit positions more quickly so that we can keep more of our profits in adverse situations.

Instrument Rule

To start, we're just going to trade a single, liquid instrument with this system.

Different instruments have different costs, liquidity, capital requirements, and so forth. There's a lot that can be said about choosing your instruments, particularly when you scale up to a portfolio!

For now, we'll stick with a stock that's in the S&P 500.

That should provide plenty of liquidity for a small account like this, but it should also be relatively cheap to trade given the rise of low-cost retail brokers.

Position Sizing Rule

Sizing your positions and controlling your risk is critical to trading succesfully. Risk management isn't sexy, but it keeps you in the game.

This is also the biggest mistake I see among retail traders.

Good position sizing can be complicated (and, admittedly, this is the most complex portion of our system as well) but that doesn't mean it should be ignored.

For Carver, we're trying to hit a given amount of risk - as measured by the volatility - in our portfolio (this is often referred to as vol targeting). To do this, we need to size our positions to keep the risk constant.

This is done by setting a target risk level, calculating our instrument risk (same way we did with the stops above), and seeing how much of our capital to allocate to this instrument.

The code for this is:

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

Or, mathematically:

$$s = \frac{r_T C}{\sigma}$$

where s is our position size in dollars, r_T is our target risk in volatility, C is our capital, and sigma is our instrument risk.

To convert this into the number of shares to purchase, we divide the exposure by the price and round down (equation below). If we don't have enough cash on hand, we just max out our position (Carver's book is called Leveraged Trading so in these cases he'd take on some leverage to hit his target, which we could do, but we'll save that for improvements to the model).

$$S = \bigg\lfloor \frac{s}{P_t} \bigg\rfloor$$

You might be asking, "how we choose our target risk?"

This is a critical parameter after all.

Carver lays out four possible methods, but suggests we take half of the optimal risk (half-Kelly). He does this by estimating the Sharpe Ratio of the Starter Strategy to be 0.24. If we use the Sharpe Ratio as our risk, then we'd set our target risk to 0.12 (12%).

We'll use that as our value going forward.

You'll see below that our implementation of the Starter System actually comes pretty close to these numbers.

Coding the Starter System

Enough with the preliminaries - let's get to the code!

Start with importing some packages:

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

Next, we'll put our system together.

This is going to start by calculating our moving averages and our instrument risk. From there, we'll loop over our data and update our position accordingly (it's a bit slower to run a loop like this, but I think it makes the code more readable, so we'll opt for understanding over efficiency).

We have a few vectors that we'll initialize (e.g. position) which we'll update as we go through the loop, then combine them all into the data frame at the end.

def StarterSystem(data, SMA1=16, SMA2=64, target_risk=0.12, stop_loss_gap=0.5,
                  starting_capital=1000, shorts=True):
  data['SMA1'] = data['Close'].rolling(SMA1).mean()
  data['SMA2'] = data['Close'].rolling(SMA2).mean()
  data['STD'] = data['Close'].pct_change().rolling(252).std() * /
    np.sqrt(252)

  position = np.zeros(data.shape[0])
  cash = position.copy()
  stops = position.copy()
  stops[:] = np.nan
  stop_triggered = stops.copy()
  exit_on_stop_dir = 0
  for i, (ts, row) in enumerate(data.iterrows()):
    if any(np.isnan(row[['SMA1', 'SMA2', 'STD']])):
      cash[i] += cash[i-1] if i > 0 else starting_capital
      continue
    
    trend_dir = 1 if row['SMA1'] > row['SMA2'] else -1
    new_stop = calcStopPrice(row['Close'], row['STD'],
                             stop_loss_gap, trend_dir)
    # Propagate values forward
    cash[i] = cash[i-1]
    position[i] = position[i-1]
    stops[i] = stops[i-1]

    if trend_dir == 1:
      # Reset stop direction if applicable
      if exit_on_stop_dir == -1:
        exit_on_stop_dir = 0
      if position[i] > 0:
        # Update stop
        if new_stop > stops[i-1]:
          stops[i] = new_stop
        
        # Check if stop was hit
        if row['Close'] < stops[i]:
          cash[i] += position[i] * row['Close']
          position[i] = 0
          stop_triggered[i] = 1
          exit_on_stop_dir = 1
      
      else:
        if position[i] < 0:
          # Trend reversal -> exit position
          cash[i] += position[i] * row['Close']
          
        # Open new position, pass if last position was long and stopped
        if exit_on_stop_dir != 1:
          position[i] = sizePosition(target_risk, cash[i],
                              row['STD'], row['Close'])
          stops[i] = new_stop
          cash[i] -= position[i] * row['Close']

    elif trend_dir == -1:
      # Reset stop direction if applicable
      if exit_on_stop_dir == 1:
        exit_on_stop_dir = 0
      if position[i] < 0:
        # Update stop
        if new_stop < stops[i-1]:
          stops[i] = new_stop

        if row['Close'] > stops[i]:
          # Check if stop was hit
          cash[i] += position[i] * row['Close']
          position[i] = 0
          stop_triggered[i] = 1
          exit_on_stop_dir = -1

      else:
        if position[i] > 0:
          # Trend reversal -> exit position
          cash[i] += position[i] * row['Close']
          position[i] = 0
          
        # Open new position
        if shorts and exit_on_stop_dir != -1:
          position[i] = -sizePosition(target_risk, cash[i],
                                     row['STD'], row['Close'])
          stops[i] = new_stop
          cash[i] -= position[i] * row['Close']
  
  data['position'] = position
  data['cash'] = cash
  data['stops'] = stops
  data['stop_triggered'] = stop_triggered
  data['portfolio'] = data['position'] * data['Close'] + data['cash']
  return calcReturns(data)


def sizePosition(target_risk, cash, instrument_risk, price):
  exposure = (target_risk * cash) / instrument_risk
  shares = np.floor(exposure / price)
  if shares * price > cash:
    return np.floor(cash / price)
  return shares


def calcStopPrice(price, std, stop_loss_gap, trend_dir):
  if trend_dir == 1:
    return price * (1 - std * stop_loss_gap)
  return price * (1 + std * stop_loss_gap)

I have also included the code for a pair of helper functions to some stats based on our model's performance.

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()}

I'm going to grab a ticker from the S&P 500 and see what it would look like if we had run this since 2000. Keep in mind, this is a basic strategy. Nothing is guaranteed to work on every ticker over every time frame and past performance is not indicative of future returns.

Ok, now we're ready to run this!

Backtest the Starter System

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 = 'AES' # Selected from sample
print(f'Ticker = {ticker}')

start = '2000-01-01'
end = '2020-12-31'

yfObj = yf.Ticker(ticker)
df = yfObj.history(start=start, end=end)
# Drop unused columns
df.drop(['Open', 'High', 'Low', 'Volume', 'Dividends', 'Stock Splits'], 
        axis=1, inplace=True)

# Get SPY for a benchmark
df_spy = yf.Ticker('SPY').history(start=start, end=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

# Run system
data = StarterSystem(df.copy())

trade_returns = 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(data['Close'], label='Close')
ax[0].plot(data['SMA1'], label='SMA-16')
ax[0].plot(data['SMA2'], label='SMA-64')
ax[0].set_ylabel('Price ($)')
ax[0].set_xlabel('Date')
ax[0].set_title(f'Price and SMA Indicators for {ticker}')
ax[0].legend(loc=1)

ax[1].plot(data['strat_cum_returns'] * 100, label='Strategy')
ax[1].plot(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 Simple Strategy and Buy and Hold')
ax[1].legend(loc=2)


ax[2].hist(trade_returns, bins=50)
ax[2].axvline(trade_returns.mean(), 
          label=f'Mean Return = {trade_returns.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-system-eq-curve1-1024x680.png

And our starting strategy got us some good returns!

It took a year to get started because it needed at least one year of data to initialize the instrument risk measurements before it got to trading. After that, we see a steady rise in equity punctuated by a number of flat periods where the model was out of the market.

The model was flat during some significant periods because it got stopped out early in a trend and never re-entered until the trend reversed. This is exactly what it is supposed to do.

However, it may be the case that the stops are too tight, or we should introduce a time limit or some other aspect so that it won't miss long trends simply because it has yet to see a reversal.

Another way to get more out of the model is to add additional instruments.

While the model might be sitting on the sideline waiting for AES to reverse, it could be riding a bull market in some other stock. Again, we'll look at improvements to this basic system in a follow up article.

Turning to the histogram, we have the returns for each individual trade the system made over this period.

The mean is strongly positive despite relatively few trades (4.5/year), but we also see the typical trait of trend following strategies like this, heavy, right tailed skew.

This right-tailed skew indicates that the model takes many small losses, but these are outweighed by a few large wins.

We can calculate the skew too, and we see our eyes don't deceive us (a value > 0 indicates that fat right tail).

from scipy import stats

skew = stats.skew(trade_returns)
print(f"Skew = {skew:.3f}")
Skew = 2.153

Digging a bit deeper, we can use our other helper function to take a look at the stats below:

stats = pd.DataFrame(getStratStats(data['strat_log_returns']), 
                     index=['Starter System'])
stats = pd.concat([stats,
                   pd.DataFrame(getStratStats(data['log_returns']), 
                                index=['Buy and Hold'])
                   ])

stats = pd.concat([stats,
                   pd.DataFrame(getStratStats(df_spy['log_returns']),
                                index=['S&P 500'])])

stats
start-system-stats-table1-1024x125.png

The model grabbed 6% annual returns with less volatility than the underlying stock, and less than the SPY ETF that tracks the S&P 500. According to the Sharpe Ratio, the Starter System had better risk-adjusted returns than the S&P 500, despite slightly under-performing the index on the whole.

We can also look at the annual returns to get a feel for this model's performance.

data['year'] = data.index.map(lambda x: x.year)
df_spy['year'] = df_spy.index.map(lambda x: x.year)
ann_rets = data.groupby('year')['strat_log_returns'].sum() * 100
spy_ann_rets = df_spy.groupby('year')['log_returns'].sum() * 100
ann_rets = pd.concat([ann_rets, spy_ann_rets], axis=1)
ann_rets.columns = ['Starter Strategy', 'S&P 500']
ann_rets.plot(kind='bar', figsize=(12, 8), xlabel='Year', ylabel='Returns (%)',
              title='Annual Returns for Simple Strategy')
plt.show()
starter-system-annual-returns.png

Here we've compared the annual returns for the Starter Strategy with the S&P 500. For the first four trading years, you would feel really good with the returns you're seeing, but then things turn down from 2005-2007.

Those three years would be tough on their own, but the S&P 500 was not only positive but nearly hit 15% in 2006! Would you be able to stick to your strategy throughout those down periods?

If you're one of the few people to say "yes," then you'd be rewarded with a strong 2008 while the S&P 500 nearly got cut in half.

Sticking to it is one of the hardest things about any trading system. Even the best system is going to run into tough times.

Having a system based on solid principles and supported by a reliable backtest helps to weather these storms so that you can persevere in the tough times.

Following 2012, the system under-performed the S&P 500 every year but two.

ann_rets.round(2)
starter-system-annual-returns-table.png

Again, this is a Starter System. Something to get your feet wet with.

We walk through these comparisons to give an idea of what to look for and to think about whether or not you could let the system run your money, especially during those down years.

Profitable Trading is Hard

There's a lot of code and a lot of nuance here just to get a backtest in place.

We'll walk you through how to improve and trade this system in a future article, but if you're eager to get started, check out our free demo here.

We're building a no-code backtest and trading solution to give you the freedom to design a strategy in seconds, test it, and start trading automatically.

Stay tuned to learn more!