Trade like a Turtle

In 1982, a group of inexperienced traders were recruited to be a part of an experiment that would make many of them multi-millionaires.

Richard Dennis bet his partner William Eckhardt that anyone could be a successful trader given they had training and a system to follow. It was a re-hash of the nature vs nurture debate, but now with millions of dollars on the line.

Dennis and Eckhardt trained their turtles and furnished each $1,000,000 to trade. Over the next 4 years, the traders returned over $175 million following Dennis and Eckhardt's system.

Below, we break down the rules of this system and implement it in Python.

The Complete Turtle Trader Rules

There are some different versions of the Turtle rules online, so I'm going to choose one set and stick with it. I'm going with the rules laid out in Chapter 5 of Michael Covel's book The Complete Turtle Trader, because they are some of the most detailed and complete. I'll explain where I may deviate from Covel's rules or feel the need to provide some additional details and examples - because there are some rules which are prone to cause confusion!

The Turtles are trend followers, meaning they're looking for price breakouts (closing highs or lows over a given lookback period) to buy an instrument (in the case of highs) or short (in the case of lows) it. They actually traded two separate but closely related systems called System 1 (S1) and System 2 (S2).

S2 is slower than S1. S2 watches for a 55-day breakout for an entry and a 20-day breakout in the opposite direction to sell (which I'll refer to as a breakdown). For example, if the system goes long after a breakout (i.e., when the closing price of the stock exceeds the previous 55-day high), then it will close the position when the price hits the lowest close over the past 20 trading days.

S1 looks for a 20-day breakout for an entry and 10-day breakdown for an exit. The other slight wrinkle for S1 is a filter that causes it to trade every other successful breakout. This means if some signal turns into a profitable trade, then the next time that signal comes up, it skips it.

Turtles were given the freedom to allocate whatever percentage of their capital to either system.

All of these are variables in the code below so you can play with them as you see fit.

Position Sizing

The Turtles used a volatility-based position sizing method to normalize their risk across contracts and instruments. The position size was calculated using the 20-day simple moving average (SMA-20) of the True Range (TR) of the price. TR is just the largest absolute value of the difference between the high, low, and close:

$$TR_t = \textrm{max}\big(\mid High_t - Low_t \mid, \mid High_t - Close_{t-1} \mid, \mid Close_{t-1} - Low_t \mid \big)$$

The Average True Range (ATR) is just the moving average of this value, which we calculate for the past 20-days.

In Turtle jargon, this 20-day ATR is called N, which is used as a proxy for risk and to calculate position size. Higher ATR means a higher volatility and a higher value of N and increased risk. Unfortunately, this is where things often get confusing.

Covel writes that the Turtles would bet 2% of their equity on each trade, which was, again in Turtle jargon, called a unit. If you have $10,000, then you have 50 units to trade, each of $200. To determine how much you actually invest, you weight the dollar volatility of the instrument by N and a constant value to adjust your risk (e.g. 2).

What's dollar volatility? This is the amount of value a $1 change in the contract would impact your portfolio. For example, if you're trading the Bitcoin futures contract on the CME, you have a contract size of 5 BTC. So a $1 change in the contract actually yields a $5 change to your portfolio meaning your dollar volatility is 5. Likewise, a gold contract is quoted in price per troy ounce, however the contract consists of 100 troy ounces so you have a dollar volatility of 100.

Turtles use these values to get their position size in the number of contracts as follows:

$$U = \textrm{floor}\bigg( \frac{fC}{r_t N D} \bigg)$$

where U is the size of 1 unit of risk. C is the total capital you have to trade, f is the fraction of capital you use to scale your unit, rt is a risk coefficient, and D is your dollar volatility. The floor function just rounds down to the nearest whole number.

Unit has a habit of causing some confusion, so let's address that quickly. The Turtles used the concept of a unit to scale different instruments by the risk they wanted, as measured by N. So if you are trading Bitcoin (which is very volatile) and German 30-year Bunds (which are much less volatile by comparison), but want to have equal risk in each, you assign 1 unit to each, and then buy the number of contracts or shares required to achieve that unit.

To show a quick example, if you have $100,000 of capital and got a signal on that Bitcoin contract. You're devoting 2% to the trade and set your risk adjustment to 2, and N over this period is 120 (which would be crazy low for Bitcoin, but this is just an example). Then your system would dictate that you should buy 1 contract:

$$U = \textrm{floor}\bigg(\frac{0.02 \times 100,000}{2 \times 120 \times 5}\bigg) = \textrm{floor}(1.667) = 1\textrm{ contract}$$

This means that a single unit of risk can be purchased for a single Bitcoin contract. The higher the N, the fewer contracts you need to purchase to get the same risk exposure.

To apply this to trading stocks, we use the same formula but replace D with our share price P.

$$U = \textrm{floor}\bigg( \frac{fC}{r_t N P} \bigg)$$

Imagine we're trading Apple at P = $150/share with N = 2.5, then we'd buy:

$$U = \textrm{floor}\bigg(\frac{0.02 \times 100,000}{2 \times 2.5 \times 150}\bigg) = \textrm{floor}(2.667) = 2 \textrm{ shares}$$

You read that right, just two shares of Apple for a $100,000 account.

This system is designed to be diversified - you never know where the trend is going to come from - so you hold small positions in lots of different instruments, then you pyramid into winners over time (discussed below). The idea of all of this volatility unit weighting is to adjust your position such that one unit of instrument A is equivalent in terms of risk to one unit of instrument B. That way a diversified portfolio of offsetting risks can be easily managed.

One last thing about position sizing. The Turtles had rules to reduce their position size as they lose capital. For every 10% they lose, they reduce their unit size by 20%, then they add that back as they recover their capital and trade full units again.

Pyramiding

Trends can take off and run for quite some time, so Turtles use the concept of pyramiding - adding to winning positions - to grow their exposure and winnings with the trend.

Again, we're going to rely on N to make our moves here. If the price moves up 1N above the original price, then we add 1 unit to our position. Additionally, we'll use a trailing stop at this point and move all of our stops to the current price minus 2N. We can continue to pyramid on top of a trend until we have 5 units, then we let it run until we exit.

Exiting

Our exits were touched on briefly, but to make them explicit, Turtles exit a trade when the price hits their stop or they get a breakout in the opposite direction of the trade they opened.

Markets

When they started out, the Turtles had almost two-dozen instruments to trade including US bonds of various durations, cotton, sugar, gold, coffee, crude oil, heating oil, gasoline, S&P 500 futures, silver and a handful of currencies like the Swiss Franc, French Franc, Deutschmark, British Pound, Eurodollar, and Japanese Yen. Some of these don't exist anymore, namely the French Franc and Deutschmark which have been replaced by the Euro, so a modern basket would be a bit different. The bigger issue with this set up is the amount of capital that would be required to trade many of these.

Futures do allow significant margins, however prices may still be out of the range for a typical retail investor. Take gold for example. Currently a 100 oz contract would cost roughly $180,000 without margin. If you could leverage that 20:1, you'd still be required to put up $9,000. If we're using the conservative position sizing of the Turtles, it would probably take a few years of great returns or a huge crash in the gold price to be able to squeeze a single contract into your system.

There are other complexities with commodities such as rolling contracts to avoid expiration, getting the data, margin calculations, and so forth. To try to keep the code simple, we'll just trade a random group of equities for this example. This is certainly NOT recommended because of correlation issues, but will allow us to demonstrate the principles of the system.

Position Limits and Correlations

If you haven't noticed by now, the most important thing in this system is controlling your risk. Risk is primarily controlled via position sizing and is what enables trend followers like the Turtles to rack up great returns, when they lose (which they do often) they don't lose very much.

In that vein, we have position limits that the system respects. Any entry is going to be limited to just a few units (4 or 5) and pyramiding is capped at 5. Moreover, correlated positions impose limits on trades.

Because some of the contracts the Turtles traded were highly correlated (e.g. silver and gold or different durations of bonds, types of oil, etc.) they were considered to be more or less the same market. One long unit of 10-year US Treasuries is highly correlated with another long unit of 30-year US Treasuries, so the Turtles would consider this to be net 2 long units. So the total portfolio risk would be twice as risky as being long one and short the other, which would be more or less neutral because of the correlations.

To do Turtle trading properly, we'd be including commodities and checking for uncorrelated markets. Like I mentioned above, we're taking a little shortcut here and just grabbing some stocks - which will likely be very correlated because they're all coming from the S&P 500. It would be interesting to see, however, if an equity portfolio could be constructed with stocks and ETFs that mirror commodity markets to mimic a Turtle (or general CTA) strategy with equities and potentially less capital and simpler rules.

Trading Like a Turtle

While the system doesn't rely on any complex mathematics, it has a few moving parts and so may be helpful to look at step-by-step before getting into the details of the code.

For trading or backtesting, we're going to be running a big loop where the system gets new daily tick data, and everything executes from there. We can describe it as follows:

  1. Get new data.
  2. Calculate N.
  3. For each system (S1 and S2) get breakouts/breakdowns (if any).
  4. For each system-instrument combination do the following:
  • If a breakout occurs and no current position is open, size the position according to the rules and enter a position (long or short depending on breakout direction). For S1 only: ignore the breakout if the last breakout led to a winning trade.
  • If a position is open and the price has moved 2N above the entry point, add one unit - sized according to the rules - to the position.
  • If a position is open and a breakdown occurs, liquidate the position.
  • If a position is open and the price hits the stop loss, liquidate the position. - Otherwise, do nothing.

At a high level, the procedure is pretty simple. The complexity comes in the execution of the model and handling all of the little details.

So let's get to coding this system!

Turtle Trading in Python

To start, import a few packages.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf
from copy import deepcopy, copy

We're going to need a few helper functions to make calculations and then to analyze our data afterwards.

Let's start with the a function for calculating the True Range (TR).

def calcTR(high, low, close):
  '''Calculate True Range'''
  return np.max(np.abs([high-low, close-low, low-close]))

Next, we'll write some code to look at our returns so we can get our risk metrics.

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

Finally, we'll jump into the TurtleSystem itself. I'll give the model below and some explanation of a few of the key methods. Hopefully the code is relatively self-explanatory.

class TurtleSystem:

  def __init__(self, tickers, init_account_size=10000, risk_level=2, r_max=0.02,
               sys1_entry=20, sys1_exit=10, sys2_entry=55, sys2_exit=20,
               atr_periods=20, sys1_allocation=0.5, risk_reduction_rate=0.1,
               risk_reduction_level=0.2, unit_limit=5, pyramid_units=1, 
               start='2000-01-01', end='2020-12-31', shorts=True):
    '''
    :tickers: list of security symbols to trade.
    :init_account_size: int that sets initial trading capital
    :risk_level: float used to determine the stop loss distance by multiplying
      this value with N.
    :r_max: float max percentage of account that a trade can risk.
    :sys1_entry: int determines number of breakout days for System 1 to generate
      a buy signal.
    :sys1_exit: int determines number of breakout days for System 1 to generate
      a sell signal.
    :sys2_entry: int determines number of breakout days for System 2 to generate
      a buy signal.
    :sys2_exit: int determines number of breakout days for System 2 to generate
      a sell signal.
    :sys1_allocation: float to balance capital allocation 
      between System 1 and 2.
    :start: str first date for getting data.
    :end: str end date for getting data.
    :shorts: bool to allow short positions if True.
    :atr_periods: int number of days used to calculate SMA of N.
    :risk_reduction_rate: float < 1 represents the amount of loss the system
      sees before it reduces its trading size.
    :risk_reduction_level: float < 1 represents each increment in risk the
      the system reduces as it loses capital below its initial size.
    '''
    self.tickers = tickers
    self.init_account_size = init_account_size
    self.cash = init_account_size
    self.portfolio_value = init_account_size
    self.risk_level = risk_level
    self.r_max = r_max
    self.sys1_entry = sys1_entry
    self.sys1_exit = sys1_exit
    self.sys2_entry = sys2_entry
    self.sys2_exit = sys2_exit
    self.sys1_allocation = sys1_allocation
    self.sys2_allocation = 1 - sys1_allocation
    self.start = start
    self.end = end
    self.atr_periods = atr_periods
    self.shorts = shorts
    self.last_s1_win = {t: False for t in self.tickers}
    self.unit_limit = unit_limit
    self.risk_reduction_level = risk_reduction_level
    self.risk_reduction_rate = risk_reduction_rate
    self.pyramid_units = pyramid_units
    self.sys_list = ['S1', 'S2']

    self._prep_data()

  def _prep_data(self):
    self.data = self._get_data()
    self._calc_breakouts()
    self._calc_N()

  def _get_data(self):
    # Gets data for all tickers from YFinance
    yfObj = yf.Tickers(self.tickers)
    df = yfObj.history(start=self.start, end=self.end)
    df.drop(['Open', 'Dividends', 'Stock Splits', 'Volume'], 
            inplace=True, axis=1)
    df.ffill(inplace=True)
    return df.swaplevel(axis=1)

  def _calc_breakouts(self):
    # Gets breakouts for all tickers
    for t in self.tickers:
      # Breakouts for enter long position (EL), exit long (ExL)
      # enter short (ES), exit short (ExS)
      self.data[t, 'S1_EL'] = self.data[t]['Close'].rolling(
          self.sys1_entry).max()
      self.data[t, 'S1_ExL'] = self.data[t]['Close'].rolling(
          self.sys1_exit).min()
      self.data[t, 'S2_EL'] = self.data[t]['Close'].rolling(
          self.sys2_entry).max()
      self.data[t, 'S2_ExL'] = self.data[t]['Close'].rolling(
          self.sys2_exit).min()

      if self.shorts:
        self.data[t, 'S1_ES'] = self.data[t]['Close'].rolling(
            self.sys1_entry).min()
        self.data[t, 'S1_ExS'] = self.data[t]['Close'].rolling(
            self.sys1_exit).max()
        self.data[t, 'S2_ES'] = self.data[t]['Close'].rolling(
            self.sys2_entry).min()
        self.data[t, 'S2_ExS'] = self.data[t]['Close'].rolling(
            self.sys2_exit).max()

  def _calc_N(self):
    # Calculates N for all tickers
    for t in self.tickers:
      tr = self.data[t].apply(
          lambda x: calcTR(x['High'], x['Low'], x['Close']), axis=1)
      self.data[t, 'N'] = tr.rolling(self.atr_periods).mean()

  def _check_cash_balance(self, shares, price):
    # Checks to see if we have enough cash to make purchase.
    # If not, resizes position to lowest feasible level
    if self.cash <= shares * price:
      shares = np.floor(self.cash / price)
    return shares

  def _adjust_risk_units(self, units):
    # Scales units down by 20% for every 10% of capital that has been lost
    # under default settings.
    cap_loss = 1 - self.portfolio_value / self.init_account_size
    if cap_loss > self.risk_reduction_level:
      scale = np.floor(cap_loss / self.risk_reduction_level)
      units *= (1 - scale * self.risk_reduction_rate)
    return units

  def _calc_portfolio_value(self, portfolio):
    pv = sum([v1['value'] for v0 in portfolio.values() if type(v0) is dict 
              for k1, v1 in v0.items() if v1 is not None])
    pv += self.cash
    if np.isnan(pv):
      raise ValueError(f"PV = {pv}\n{portfolio}")
    return pv

  def _get_units(self, system):
    sys_all = self.sys1_allocation if system == 1 else self.sys2_allocation
    dollar_units = self.r_max * self.portfolio_value * sys_all
    dollar_units = self._adjust_risk_units(dollar_units)
    return dollar_units

  def _size_position(self, data, dollar_units):
    shares = np.floor(dollar_units / (
        self.risk_level * data['N'] * data['Close']))
    return shares

  def _run_system(self, ticker, data, position, system=1):
    S = system # System number
    price = data['Close']
    if np.isnan(price):
      # Return current position in case of missing data
      return position
    N = data['N']
    dollar_units = self._get_units(S)
    shares =  0
    if position is None:
      if price == data[f'S{S}_EL']: # Buy on breakout
        if S == 1 and self.last_s1_win[ticker]:
          self.last_s1_win[ticker] = False
          return None
        shares = self._size_position(data, dollar_units)
        stop_price = price - self.risk_level * N
        long = True
      elif self.shorts:
        if price == data[f'S{S}_ES']: # Sell short
          if S == 1 and self.last_s1_win[ticker]:
            self.last_s1_win[ticker] = False
            return None
          shares = self._size_position(data, dollar_units)
          stop_price = price + self.risk_level * N
          long = False
      else:
        return None
      if shares == 0:
        return None
      # Ensure we have enough cash to trade
      shares = self._check_cash_balance(shares, price)
      value = price * shares

      self.cash -= value
      position = {'units': 1,
                  'shares': shares,
                  'entry_price': price,
                  'stop_price': stop_price,
                  'entry_N': N,
                  'value': value,
                  'long': long}
      if np.isnan(self.cash) or self.cash < 0:
        raise ValueError(f"Cash Error\n{S}-{ticker}\n{data}\n{position}")

    else:
      if position['long']:
        # Check to exit existing long position
        if price == data[f'S{S}_ExL'] or price <= position['stop_price']:
          self.cash += position['shares'] * price
          if price >= position['entry_price']:
            self.last_s1_win[ticker] = True
          else:
            self.last_s1_win[ticker] = False
          position = None
        # Check to pyramid existing position
        elif position['units'] < self.unit_limit:
          if price >= position['entry_price'] + position['entry_N']:
            shares = self._size_position(data, dollar_units)
            shares = self._check_cash_balance(shares, price)
            self.cash -= shares * price
            stop_price = price - self.risk_level * N
            avg_price = (position['entry_price'] * position['shares'] +
                         shares * price) / (position['shares'] + shares)
            position['entry_price'] = avg_price
            position['shares'] += shares
            position['stop_price'] = stop_price
            position['units'] += 1
      else:
        # Check to exit existing short position
        if price == data[f'S{S}_ExS'] or price >= position['stop_price']:
          self.cash += position['shares'] * price
          if S == 1:
            if price <= position['entry_price']:
              self.last_s1_win[ticker] = True
            else:
              self.last_s1_win[ticker] = False
          position = None
        # Check to pyramid existing position
        elif position['units'] < self.unit_limit:
          if price <= position['entry_price'] - position['entry_N']:
            shares = self._size_position(data, dollar_units)
            shares = self._check_cash_balance(shares, price)
            self.cash -= shares * price
            stop_price = price + self.risk_level * N
            avg_price = (position['entry_price'] * position['shares'] +
                         shares * price) / (position['shares'] + shares)
            position['entry_price'] = avg_price
            position['shares'] += shares
            position['stop_price'] = stop_price
            position['units'] += 1

      if position is not None:
        # Update value at each time step
        position['value'] = position['shares'] * price
        
    return position

  def run(self):
    # Runs backtest on the turtle strategy
    self.portfolio = {}
    position = {s: 
                  {t: None for t in self.tickers}
                for s in self.sys_list}

    for i, (ts, row) in enumerate(self.data.iterrows()):
      for t in self.tickers:
        for s, system in enumerate(self.sys_list):
          position[system][t] = self._run_system(t, row[t], position[system][t])
      self.portfolio[i] = deepcopy(position)
      self.portfolio[i]['date'] = ts
      self.portfolio[i]['cash'] = copy(self.cash)
      self.portfolio_value = self._calc_portfolio_value(self.portfolio[i])

  def get_portfolio_values(self):
    vals = []
    for v in self.portfolio.values():
      pv = sum([v1['value'] for v0 in v.values() if type(v0) is dict 
              for k1, v1 in v0.items() if v1 is not None])
      pv += v['cash']
      vals.append(pv)
    return pd.Series(vals, index=self.data.index)

  def get_system_data_dict(self):
    sys_dict = {}
    cols = ['units', 'shares', 'entry_price', 'stop_price',
      'entry_N', 'value', 'long']
    X = np.empty(shape=(len(cols))) 
    X[:] = np.nan
    index = [v['date'] for v in self.portfolio.values()]
    for s in self.sys_list:
      for t in self.tickers:
        df = pd.DataFrame()
        for i, v in enumerate(self.portfolio.values()):
          d = v[s][t]
          if d is None:
            if i == 0:
              _array = X.copy()
            else:
              _array = np.vstack([_array, X])
          else:
            vals = np.array([float(d[i]) for i in cols])
            if i == 0:
              _array = vals.copy()
            else:
              _array = np.vstack([_array, vals])
        df = pd.DataFrame(_array, columns=cols, index=index)
        sys_dict[(s, t)] = df.copy()
    return sys_dict

  def get_transactions(self):
    ddict = self.get_system_data_dict()
    transactions = pd.DataFrame()
    for k, v in ddict.items():
      df = pd.concat([v, self.data[k[1]].copy()], axis=1)
      df.fillna(0, inplace=True)
      rets = df['Close'] / df['entry_price'].shift(1) -1
      trans = pd.DataFrame(rets[df['shares'].diff()<0], 
        columns=['Returns'])
      trans['System'] = k[0]
      trans['Ticker'] = k[1]
      trans['Long'] = df['long'].shift(1).loc[df['shares'].diff()<0]
      trans['Units'] = df['units'].shift(1).loc[df['shares'].diff()<0]
      trans['Entry_Price'] = df['entry_price'].shift(1).loc[
        df['shares'].diff()<0]
      trans['Sell_Price'] = df['Close'].loc[df['shares'].diff()<0]
      trans['Shares'] = df['shares'].shift(1).loc[df['shares'].diff()<0]
      trans.reset_index(inplace=True)
      trans.rename(columns={'index': 'Date'}, inplace=True)
      transactions = pd.concat([transactions, trans.copy()])

    transactions.reset_index(inplace=True)
    transactions.drop('index', axis=1, inplace=True)
    return transactions

We're using YFinance to get our data, which is easy because you can pass a list of tickers and it will pull all of the relevant data and concat them into a dataframe for easy manipulation. We do need to do some reorganization of the multi-level index and calculate the breakouts for each system, long and short, as well as N for every ticker. This is all handled in the _prep_data() method which calls a series of methods to get the data, organize it, and calculate our values.

Another important method is _get_dollar_units(), which returns the available units we have to trade in dollars. Note that we have an extra term in the numerator which adjusts the available capital based on our system allocation. So if we have a 50:50 split between S1 and S2, then our maximum unit is actually 1% of our capital with the default settings. We use this value to check how many units we have devoted to certain positions, to get our pyramiding, and so forth.

The longest method is _run_system() which will run both S1 and S2. This handles the step-by-step details of the Turtle model as laid out above.

run() loops through all of our data to backtest the strategy. There are a few logging functions that take place here as well such as a daily caching of our portfolio for later analysis.

# Sample 10 tickers from S&P 500
url = 'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies'
table = pd.read_html(url)
df = table[0]
syms = df['Symbol']
# Sample symbols
tickers = list(np.random.choice(syms.values, size=10))
print("Ticker Symbols:")
_ = [print(f"\t{i}") for i in tickers]
sys = TurtleSystem(tickers, init_account_size=1E4, start='2000-01-01')
sys.run()
Ticker Symbols:
	UAA
	HST
	UA
	CBOE
	ATVI
	MMM
	CTXS
	GPC
	UHS
	PBCT
[*********************100%***********************]  10 of 10 completed

We can plot the returns below against the S&P 500 as a benchmark.

port_values = sys.get_portfolio_values()
returns = port_values / port_values.shift(1)
log_returns = np.log(returns)
cum_rets = log_returns.cumsum()

# Compare to SPY baseline
sp500 = yf.Ticker('SPY').history(start=sys.start, end=sys.end)
sp500['returns'] = sp500['Close'] / sp500['Close'].shift(1)
sp500['log_returns'] = np.log(sp500['returns'])
sp500['cum_rets'] = sp500['log_returns'].cumsum()

plt.figure(figsize=(12, 8))
plt.plot((np.exp(cum_rets) -1 )* 100, label='Turtle Strategy')
plt.plot((np.exp(sp500['cum_rets']) - 1) * 100, label='SPY')
plt.xlabel('Date')
plt.ylabel('Returns (%)')
plt.title('Cumulative Portfolio Returns')
plt.legend()
plt.tight_layout()
plt.show()

stats = getStratStats(log_returns)
spy_stats = getStratStats(sp500['log_returns'])
df_stats = pd.DataFrame(stats, index=['Turtle'])
df_stats = pd.concat([df_stats, pd.DataFrame(spy_stats, index=['SPY'])])
df_stats
turtle-returns1.png
turtle-stats1.png

The Turtle model outperforms the buy-and-hold approach on the S&P in every category. Surprisingly, the volatility of the Turtle method is lower than the SPY, because the typical knock on trend following is that the returns tend to be "lumpy" as they give back a lot of profit before exiting and hitting large runs again. You can see that pattern at play here as well. Most important is the healthy Sortino ratio which shows good risk-adjusted returns.

Before you get too excited about the results here, note that this backtest doesn't account for dividends (which would boost both models) and is rife with survivorship bias. We took 10 random stocks from today's S&P 500, which is comprised of the today's largest 500 companies. So by sampling from this group of successful companies, we're likely to do well. You'd need a larger sample size - including de-listed stocks - to develop confidence in this approach.

Regardless, it does look promising and we can dig a little deeper to understand it further.

Let's look at the transactions.

Transaction Stats

transactions = sys.get_transactions()
mean = transactions['Returns'].mean() * 100
median = np.median(transactions['Returns']) * 100

colors = plt.rcParams['axes.prop_cycle'].by_key()['color']
plt.figure(figsize=(12, 8))
plt.hist(transactions['Returns']*100, bins=50)
plt.axvline(mean, c=colors[1], label=f'Mean={mean:.1f}%')
plt.axvline(median, c=colors[2], label=f'Median={median:.1f}%')
plt.ylabel('Frequency')
plt.xlabel('Returns (%)')
plt.title(f'Histogram of Trade Results N={len(transactions)}')
plt.legend()
plt.show()
turtle-hist-plot1.png

The expectation of this strategy is slightly positive with an average return of 0.6%. The mean is pulled above the median by a heavier right tail. Notice those outlier returns on the bottom right? That's what trend following is banking on - a few big wins to offset many small losses.

So overall, we have a positive expectation, but we can dig a bit deeper. Recall that we have two systems running simultaneously here, longs and shorts, as well as 10 different stocks.

We can look at the histograms and stats for each below.

category = ['System', 'Long']

fig, ax = plt.subplots(1, len(category), figsize=(15, 10), sharey=True)

for i, c in enumerate(category):
  vals = transactions[c].unique()
  for v in vals:
    data = transactions.loc[transactions[c]==v]
    ax[i].hist(data['Returns'] * 100, label=v, alpha=0.5, bins=100)
    ax[i].set_ylabel('Frequency')
    
  ax[i].set_xlabel('Returns (%)')
  ax[i].legend()

ax[0].set_title('Returns from System')
ax[1].set_title('Returns from Long/Short')

plt.tight_layout()
plt.show()
turtle-hist-plot2-1024x680.png

Looking at these histograms, we see that both systems had very similar performance. Long positions, however, are very skewed to the right with all of the big wins coming from that side. Most of the small losses also came from going long on a breakout.

Going short, had the opposite effect. It was a frequent winner, but provided all of the big losses.

Finally, let's look at some of the stats from the tickers. Even with 10, we have too many to cleanly plot, so we'll get some summary stats in a table. Feel free to break this down further by system and ticker or system, ticker, and long/short. Just know that you're going to have a harder time drawing a lot of conclusions as you disaggregate your data further and further due to the diminishing sample size.

Anyway, this is all easy to do:

from scipy.stats import skew

transactions.groupby(['Ticker'])['Returns'].agg(
    {'count', 'mean', 'median', 'std', 'skew'})
turtle-stats2.png

We see that positive, right-tailed skew from all of these except for UHS, and almost all the tickers provided a positive average return, except UA.

Turtle Trend Following Forever?

Some of the original Turtles, such as Jerry Parker, have made long and successful careers out of continually and consistently applying trend following rules to markets for decades.

The challenge with trend following - or any quantitative approach is remaining disciplined. It's easy to get caught up during a drawdown or in the midst of a cold streak and begin to make discretionary decisions. In those times, it's important to remind yourself of the stats and what you expect your model to do in the long run.

At Raposa, we want to give you the tools to build quantitative models you can rely on. We're making it easy for traders and investors to build and test rigorous trading models with a no-code solution running professional code with high-quality data.

Check out our free demo here to learn more.