Rob Carver's Systematic Trading Framework
2. Trading rules and variations

Rob Carver's Systematic Trading Framework 2. Trading rules and variations

·

8 min read

This is probably the most exciting part as a trader but I'm not going to examine complex rules here. I will use one trend-following rule and one mean reversion rule with two variations each to forecast. The forecast is a multiplier to the exposure. A positive / negative forecast means a bullish / bearish view, hence a long / short position. Carver's continuous trading style will also be adopted. Hence, instead of holding only one position in one trade, a higher absolute value of the forecast implies larger opening positions.


Step 2. Trading rules and variations

Trend Following

A simple strategy of Moving Average Crossover will be adopted. The intuition is that when the price is higher than is average, it will be considered an uptrend and vice versa. The more the price deviates from the average, the stronger the trend. This strategy consists of two MAs, one faster and one slower. The slower one represents the average, while the faster one is only for smoothing the prices (reducing noise).

SMA

Simple Moving Average (SMA) assigns equal weights to all prices, regardless of time.

$$SMA_{t,n}=\sum_{i=t-n+1}^{t}\frac{1}{n}\times p_i$$

def SMA(prices,span):
  output = prices.rolling(span).mean()
  return output

One disadvantage is the sudden jump in value. For example, a stock was priced at $10. On the 20th day, a news event caused panic emotions of investors so the price drops to $1. One day after, people realized that was fake news, the price go back to $10. The 10-day SMA will drop and remain at the same level until the SMA doesn't include the data point anymore (the 31st day), as shown in the chart.

EMA

Exponential Moving Average (EMA) weights older data less and newer data more.

$$EMA_{t,n}=\sum_{i=t-n+1}^{n}\alpha^i\times p_i$$

def EMA(prices,span):
  output = prices.ewm(span=span).mean()[span:]
  return output

Alpha is a decay factor smaller than 1. Therefore, older information decays day after day. The price drop will be recovered slowly so that the modeled MA can better describe the reality (less deviation). A smoother line will be generated.

EMA will be used for the strategy.

Forecast

Moving Average Crossover calculates the deviation of fast MA from the slow MA.

$$MAC(8,32) = EMA(8)-EMA(32)$$

def MAC(prices,fast,slow,exp=True):
  if exp: output = EMA(prices,fast)-EMA(prices,slow)
  else: output = SMA(prices,fast)-SMA(prices,slow)
  return output

The graph below shows the MAC of VTI. When it's above zero, it's a long signal, and vice versa. Yet the forecast is in the range of ±1 due to the relatively cheap price of VTI. We should risk adjusting it to make it consistent across instruments. This is done by dividing the standard deviation of the price.

$$RawForecast=\frac{Signal}{\sigma_p}$$

I will use an arbitrary 30-day rolling standard deviation. Again, to normalize the forecast and rescale it to 10 like Carver, divide it by the average absolute value of the raw forecast and times it by 10.

$$Forecast=\frac{RawForecast}{Avg.|RawForecast|}\times10$$

def normalizedForecast(forecast,prices,span=252,binary=False):
  vol = logReturns(prices).rolling(span).std()
  price_vol = vol*prices
  raw_forecast = forecast/price_vol
  nonzero_forecast = raw_forecast.copy()
  nonzero_forecast[nonzero_forecast==0] = np.nan
  avg_forecast = nonzero_forecast.abs().expanding().mean()
  scaled_forecast = raw_forecast/avg_forecast*10
  # Cap the forecast in the range of +-20
  scaled_forecast[scaled_forecast>+20] = +20
  scaled_forecast[scaled_forecast<-20] = -20

  output = scaled_forecast
  return output

Below shows the difference between the original MAC scaled by 10 and the forecast.

Variation

Except for MAC(8,32), I will also try other variations to capture the slower trend.

Forecasts on VTI from variations of MAC

Let the position equal to the value of the forecast divided by 10 (i.e. long 1 share if the forecast is +10) and see the performance of 20 variations on instruments.

fasts = pd.Series([4*i for i in range(1,21)])
slows = fasts*4

performance_list = []
for fast,slow in zip(fasts,slows):
  # Shift the forecast to avoid look-ahead bias
  position = normalizedForecast(forecast=MAC(prices,fast,slow),
                                prices=prices,
                                span=slow).shift(1)

  returns = logReturns(prices)*position
  train_returns = returns.loc[train_index,:]

  performance = pd.DataFrame([train_returns.apply(annualizedReturn,args=[252]),
                              train_returns.apply(annualizedVolatility,args=[252]),
                              train_returns.apply(sharpeRatio,args=[252,0]),
                              train_returns.apply(periodSkew,args=[252,12]),
                              train_returns.apply(lowerTailRatio),
                              train_returns.apply(upperTailRatio)])

  trial_name = 'MAC({:03d},{:03d})'.format(fast,slow)
  performance.index = pd.MultiIndex.from_tuples([(trial_name,'Return'),
                                                 (trial_name,'Volatility'),
                                                 (trial_name,'Sharpe'),
                                                 (trial_name,'Skew'),
                                                 (trial_name,'Lower-tail'),
                                                 (trial_name,'Upper-tail')])                     
  performance_list += [performance]
all_performance = pd.concat(performance_list)

X-AXIS: Fast to slow

  • VTI: Fast to medium trends clearly outperform the slower trends. Firstly, the Sharpe doesn't show any improvement in slower trends. Secondly, the skew starts to decrease when the trend isn't fast enough, meaning a lower probability of big winners. Thirdly, lower-tail increases with the speed.

  • TLT: Trend following is obviously not a good strategy for long-term treasury, so I will leave it for now.

  • GLD: Medium to slow trends are best for the gold. It yields the highest Sharpe. Although the high skew only shows in fast trends, the lower-tail is the highest too. I'm willing to sacrifice some skew for less extreme losses.

The following variations will be used:

  • VTI: (16,64) and (32,128)

  • TLT: just hold

  • GLD: (40,160) and (68,272)

Combine variations

Since there are only two variations, forecasts will be weighted equally.

trend_forecasts = pd.DataFrame({'VTI':.5*normalizedForecast(MAC(prices['VTI'],16,64),prices=prices['VTI'])+.5*normalizedForecast(MAC(prices['VTI'],32,128),prices=prices['VTI']),
                                'GLD':.5*normalizedForecast(MAC(prices['GLD'],40,186),prices=prices['GLD'])+.5*normalizedForecast(MAC(prices['GLD'],68,272),prices=prices['GLD'])})
GLDTLTVTI
Return1.236291NaN0.602893
Volatility2.319065NaN2.918427
Sharpe0.533099NaN0.206582
Skew0.867705NaN2.477726
Lower-tail2.487542NaN2.976850
Upper-tail1.776325NaN3.016062

Didn't yield a very high Sharpe. Yet the skew is performing well. The higher the skew means more small losers but the bigger few winners.

Such crazy winners are created by tail events in the market. For example, the 2008 crisis caused a surge in gold prices first and sent shockwaves through the market at the end of 2008. We may conclude that volatility favors the trend-following strategy. That may also explain the low Sharpe ratio.


Mean Reversion

I will use a Bollinger Band strategy here. The conventional Bollinger Bands are simply the ±2 standard deviations bounds of the price. When the price goes above the upper band, the price deviates from its mean. It is predicted to go down to mean hence a short entry. The opposite also holds for the long entry.

BB

EMA will again be used as the mean price.

$$Avg.p_{t,n}=EMA_{t,n}$$

$$Upper_{t,n}=Avg.p_{t,n}+ z\times\sigma_{price,t,n}$$

$$Lower_{t,n}=Avg.p_{t,n}-z\times\sigma_{price,t,n}$$

def BBU(prices,z,span):
  avg = EMA(prices,span)
  vol = logReturns(prices).rolling(span).std()
  price_vol = vol*prices

  output = avg+z*price_vol
  return output

def BBL(prices,z,span):
  avg = EMA(prices,span)
  vol = logReturns(prices).rolling(span).std()
  price_vol = vol*prices

  output = avg-z*price_vol
  return output

Forecast

Bollinger Band Crossover calculates the deviation of price from the bands.

$$BBC_{upper}(1.5,5)=Upper(1.5,5)-p$$

$$BBC_{lower}(1.5,5)=Lower(1.5,5)-p$$

def BBC(prices,z,span):
  dev_u = BBU(prices,z,span)-prices
  dev_l = BBL(prices,z,span)-prices
  dev_u[dev_u>0] = 0
  dev_l[dev_l<0] = 0

  output = dev_u+dev_l
  return output

In the book Algorithmic Trading (2013) written by Ernest Chan, he explains that the idea of scaling-in may not work in mean reversion style strategies. (Chapter 3. Does scaling-in work?) Therefore, I will use a binary forecast here.

def normalizedForecast(forecast,prices,span=252,binary=False):
  if binary:
    scaled_forecast = np.sign(forecast)*20
  else:
    vol = logReturns(prices).rolling(span).std()
    price_vol = vol*prices
    raw_forecast = forecast/price_vol
    nonzero_forecast = raw_forecast.copy()
    nonzero_forecast[nonzero_forecast==0] = np.nan
    avg_forecast = nonzero_forecast.abs().expanding().mean()
    scaled_forecast = raw_forecast/avg_forecast*10
    scaled_forecast[scaled_forecast>+20] = +20
    scaled_forecast[scaled_forecast<-20] = -20

  output = scaled_forecast
  return output

The forecast only generates a buy or sell signal without the strength.

Variations

Again, look at the performance of different variations.

zs = [1,1.5,2,2.5]
spans = [4*i for i in range(1,65)]

performance_list = []
for z,span in itertools.product(zs,spans):
  position = normalizedForecast(forecast=BBC(prices,z,span),
                                prices=prices,
                                span=span,
                                binary=True).shift(1)

  returns = logReturns(prices)*position
  train_returns = returns.loc[train_index,:]

  performance = pd.DataFrame([train_returns.apply(annualizedReturn,args=[252]),
                              train_returns.apply(annualizedVolatility,args=[252]),
                              train_returns.apply(sharpeRatio,args=[252,0]),
                              train_returns.apply(periodSkew,args=[252,12]),
                              train_returns.apply(lowerTailRatio),
                              train_returns.apply(upperTailRatio)])

  trial_name = 'BBC({},{:03d})'.format(z,span)
  performance.index = pd.MultiIndex.from_tuples([(z,span,'Return'),
                                                 (z,span,'Volatility'),
                                                 (z,span,'Sharpe'),
                                                 (z,span,'Skew'),
                                                 (z,span,'Lower-tail'),
                                                 (z,span,'Upper-tail')])                     
  performance_list += [performance]

all_performance = pd.concat(performance_list)

X-AXIS: Fast to slow

The chart shows the mean values across different values of z. Clearly, the shorter the lookback, the better the Sharpe (and the skew). This makes sense for a mean reversion strategy that captures the abnormalities of price (non-equilibrium state).

The following variations will be used:

  • VTI: (1.5,5) and (2.25,5)

  • TLT: (0.75,5) and (2.5,5)

  • GLD: (1.75,5) and (1.5,5)

Combine variations

Again, forecasts will be weighted equally.

reversion_forecasts = pd.DataFrame({'VTI':.5*normalizedForecast(BBC(prices['VTI'],1.5,5),prices=prices['VTI'],span=5,binary=True)+.5*normalizedForecast(BBC(prices['VTI'],2.25,5),prices=prices['VTI'],span=5,binary=True),
                                    'TLT':.5*normalizedForecast(BBC(prices['TLT'],0.75,5),prices=prices['TLT'],span=5,binary=True)+.5*normalizedForecast(BBC(prices['TLT'],2.5,5),prices=prices['TLT'],span=5,binary=True),
                                    'GLD':.5*normalizedForecast(BBC(prices['GLD'],1.75,5),prices=prices['GLD'],span=5,binary=True)+.5*normalizedForecast(BBC(prices['GLD'],1.5,5),prices=prices['GLD'],span=5,binary=True)})
GLDTLTVTI
Return0.3959790.3556390.546358
Volatility0.7469020.5051620.679320
Sharpe0.5301620.7040100.804272
Skew0.506691-1.7231783.920673
Lower-tail2.2427421.5843152.059077
Upper-tail1.6631291.3151935.336017

This time, the Sharpe is much higher. Yet the skew behaves quite differently. VTI shows a very high skew of 3.92, while GLD's skew drops by 0.36 and is even negative for TLT.

The high skew of VTI is probably again due to the 2008 crisis. Capturing the small fluctuations during the quiet days is more of the style of mean reversion strategies.


Combine Forecasts

Including the buy and hold, there are 3 strategies in total. To combine them, I'm going to weigh them equally.

TrendReversionHoldTotal
VTI0.330.330.331.00
TLTNaN0.500.501.00
GLD0.330.330.331.00
trend_forecasts = pd.DataFrame({'VTI':.5*normalizedForecast(MAC(prices['VTI'],16,64),prices=prices['VTI'])+.5*normalizedForecast(MAC(prices['VTI'],32,128),prices=prices['VTI']),
                                'GLD':.5*normalizedForecast(MAC(prices['GLD'],40,186),prices=prices['GLD'])+.5*normalizedForecast(MAC(prices['GLD'],68,272),prices=prices['GLD'])})

reversion_forecasts = pd.DataFrame({'VTI':.5*normalizedForecast(BBC(prices['VTI'],1.5,5),prices=prices['VTI'],span=5,binary=True)+.5*normalizedForecast(BBC(prices['VTI'],2.25,5),prices=prices['VTI'],span=5,binary=True),
                                    'TLT':.5*normalizedForecast(BBC(prices['TLT'],0.75,5),prices=prices['TLT'],span=5,binary=True)+.5*normalizedForecast(BBC(prices['TLT'],2.5,5),prices=prices['TLT'],span=5,binary=True),
                                    'GLD':.5*normalizedForecast(BBC(prices['GLD'],1.75,5),prices=prices['GLD'],span=5,binary=True)+.5*normalizedForecast(BBC(prices['GLD'],1.5,5),prices=prices['GLD'],span=5,binary=True)})

hold_forecasts = pd.DataFrame({'VTI':normalizedForecast(HOLD(prices['VTI']),prices=prices['VTI'],span=5),
                               'TLT':normalizedForecast(HOLD(prices['TLT']),prices=prices['TLT'],span=5),
                               'GLD':normalizedForecast(HOLD(prices['GLD']),prices=prices['GLD'],span=5)})

combine_forecasts = pd.DataFrame({'VTI':.33*trend_forecasts['VTI']+.33*reversion_forecasts['VTI']+.33*hold_forecasts['VTI'],
                                  'TLT':.5*reversion_forecasts['TLT']+.5*hold_forecasts['TLT'],
                                  'GLD':.33*trend_forecasts['GLD']+.33*reversion_forecasts['GLD']+.33*hold_forecasts['GLD']})

position = combine_forecasts.shift(1)
returns = logReturns(prices)*position
train_returns = returns.loc[train_index,:]

performance = pd.DataFrame([train_returns.apply(annualizedReturn,args=[252]),
                            train_returns.apply(annualizedVolatility,args=[252]),
                            train_returns.apply(sharpeRatio,args=[252,0]),
                            train_returns.apply(periodSkew,args=[252,12]),
                            train_returns.apply(lowerTailRatio),
                            train_returns.apply(upperTailRatio)])

performance.index = ['Return',
                     'Volatility',
                     'Sharpe',
                     'Skew',
                     'Lower-tail',
                     'Upper-tail']
performance
GLDTLTVTI
Return1.0154800.6581650.810180
Volatility1.0952890.6549560.988408
Sharpe0.9271341.0049000.819683
Skew-0.269709-0.7465302.838962
Lower-tail2.0166321.7710771.942587
Upper-tail1.6028731.9528671.665772

The combined forecast yields excellent results. The Sharpe goes up to 0.8-1.0. The relatively high skew of VTI is retained but GLD has a negative skew instead. Tails also reduce a bit. Overall, the improvement is extraordinary, especially the Sharpe.

The next step is volatility targeting and position sizing.