Determining the strength of a trend can provide a valuable edge to your trading strategy and help you determine when to go long and let it ride, or not. This is what the Trend Intensity Indicator (TII) was designed to do.

This indicator is as simple to interpret as more familiar values like the RSI. It’s scaled from 0–100 where higher numbers indicate a stronger upward trend, lower values a stronger downward trend, and values around the centerline (50) are neutral.

Calculating the TII takes place in 3 quick steps:

  1. Calculate the Simple Moving Average (SMA) of your closing prices (60-day SMA is typical, i.e. P=60).
  2. Determine how many time periods close above the SMA over P/2 periods.
  3. Divide the number of positive periods by P/2 and multiply by 100 to scale from 0–100.

Or in pseudo-code:

SMA[t] = mean(Price[t-P:t])
PositivePeriods[t] = count(SMA[t-P/2:t]>Price[t-P/t:t]
TII[t] = PositivePeriods[t] / (P/2) * 100

TL;DR

We provide backtests and code for four distinct trading strategies using the TII. These tests are designed to give you a feel for the indicator and running backtests. If you want something faster and more complete, check out our free, no-code platform here.

Trend Intensity Indicator

Let’s get to coding this in Python:

import pandas as pd

def calcTrendIntensityIndex(data, P=60):
  data[f'SMA_{P}'] = data['Close'].rolling(P).mean()
  diff = data['Close'] - data[f'SMA_{P}']
  pos_count = diff.map(
    lambda x: 1 if x > 0 else 0).rolling(int(P/2)).sum()
  data['TII'] = 200 * (pos_count) / P
  return data

The function is quick and easy.

Let’s grab some data and look a bit closer before trading it.

import numpy as np
import matplotlib.pyplot as plt
import yfinance as yf

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

P = [30, 60, 90]

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

fig, ax = plt.subplots(2, figsize=(12, 8), sharex=True)
ax[0].plot(data['Close'], label='Close')

for i, p in enumerate(P):
  df = calcTrendIntensityIndex(data.copy(), p)
  ax[0].plot(df[f'SMA_{p}'], label=f'SMA({p})')
  ax[1].plot(df['TII'], label=f'TII({p})', c=colors[i+1])

ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and SMA Values for {ticker}')
ax[0].legend()

ax[1].set_xlabel('Date')
ax[1].set_ylabel('TII')
ax[1].set_title('Trend Intensity Index')
ax[1].legend()
plt.tight_layout()
plt.show()
tii-plot1.png

Just like the RSI and other oscillators, the TII ranges from 0–100. It does so in a steadier fashion than many of these other indicators because it’s simply counting the number of days where the price is greater than the SMA over the past P/2 periods (30 days in this case). It only moves in discrete steps up and down over time.

Even though the TII is derived from the SMA crossover, it differs significantly from the standard indicator as shown below.

P = 60
df = calcTrendIntensityIndex(data.copy(), P)
vals = np.where(df[f'SMA_{P}'] > df['Close'], 1, 0)
df['SMA_cross'] = np.hstack([np.nan, vals[:-1] - vals[1:]])

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

ax[0].plot(df['Close'], label='Close')
ax[0].plot(df[f'SMA_{P}'], label=f'SMA({P})')
ax[0].scatter(df.loc[df['SMA_cross']==1].index, 
              df.loc[df['SMA_cross']==1][f'SMA_{P}'], 
              marker='^', c=colors[3],
              label='Cross Above', zorder=100, s=100)
ax[0].scatter(df.loc[df['SMA_cross']==-1].index, 
              df.loc[df['SMA_cross']==-1][f'SMA_{P}'], 
              marker='v', c=colors[4],
              label='Cross Below', zorder=100, s=100)
ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and {P}-Day SMA for {ticker}')
ax[0].legend()

ax[1].plot(df['TII'], label='TII')
ax[1].scatter(df.loc[df['SMA_cross']==1].index,
              df.loc[df['SMA_cross']==1]['TII'], 
              marker='^', c=colors[3],
              label='Cross Above', zorder=100, s=100)
ax[1].scatter(df.loc[df['SMA_cross']==-1].index, 
              df.loc[df['SMA_cross']==-1]['TII'], 
              marker='v', c=colors[4],
              label='Cross Below', zorder=100, s=100)
ax[1].set_xlabel('Date')
ax[1].set_ylabel('TII')
ax[1].set_title('Trend Intensity Index')
ax[1].legend()

plt.tight_layout()
plt.show()
tii-plot2.png

In this plot, we have the 60-day SMA marked with orange triangles when the price moves above the SMA and blue triangles showing when it drops below the SMA. As can be seen in the lower plot, these occur all over the TII. So you can’t necessarily tell when a cross over occurs by looking at the TII. While it is a straightforward derivative of the SMA crossover, you’re going to have very different signals when trading it.

Let’s turn to a few examples of how we can use it.

Buy Low and Sell High

The first model we’ll run approaches the TII in a similar fashion as the RSI; we buy when the TII crosses below 20 and sell/short when it breaks above 80. We hold the positions until they reach the exit value.

The basic idea is that the price should be split roughly between days above and below the SMA. When we have a series that has deviated from the long-run average, we put a position on and wait for it to revert back to the mean.

def TIIMeanReversion(data, P=60, enter_long=20, exit_long=50, 
                     enter_short=80, exit_short=50, shorts=True):
  df = calcTrendIntensityIndex(data, P=P)
  df['position'] = np.nan
  df['position'] = np.where(df['TII']<=enter_long, 1, np.nan)
  _exit_long = df['TII'] - exit_long
  exit = _exit_long.shift(1) / _exit_long
  df['position'] = np.where(exit<0, 0, df['position'])

  if shorts:
    df['position'] = np.where(df['TII']>=enter_short, -1, 
                              df['position'])
    _exit_short = df['TII'] - exit_short
    df['position'] = np.where(exit<0, 0, df['position'])

  df['position'] = df['position'].ffill().fillna(0)

  return calcReturns(df)


# A few helper functions
def calcReturns(df):
  # Helper function to avoid repeating too much code
  df['returns'] = df['Close'] / df['Close'].shift(1)
  df['log_returns'] = np.log(df['returns'])
  df['strat_returns'] = df['position'].shift(1) * df['returns']
  df['strat_log_returns'] = df['position'].shift(1) * \ 
    df['log_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()
  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 also added a few helper functions to get our returns and statistics for easy plotting and comparison.

Let’s get some extra data for our ticker to see if we can hit it big more than our gold mining ETF.

ticker = 'GDX'
start = '2000-01-01'
end = '2020-12-31'
yfObj = yf.Ticker(ticker)
data = yfObj.history(start=start, end=end)
# Drop unused columns
data.drop(
    ['Open', 'High', 'Low', 'Volume', 'Dividends', 'Stock Splits'], 
    axis=1, inplace=True)

P = 60
enter_long = 20
enter_short = 80
df_mr = TIIMeanReversion(data.copy(), P=P, enter_long=enter_long, 
                         enter_short=enter_short)

fig, ax = plt.subplots(3, figsize=(12, 8), sharex=True)
ax[0].plot(df_mr['Close'], label='Close')
ax[0].plot(df_mr[f'SMA_{P}'], label=f'SMA({P})')
ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and {P}-Day SMA for {ticker}')
ax[0].legend()

ax[1].plot(df_mr['TII'], label='TII')
ax[1].axhline(enter_short, c=colors[1], 
              label='Short Signal Line', linestyle=':')
ax[1].axhline(enter_long, c=colors[2], 
              label='Long Signal Line', linestyle=':')
ax[1].axhline(50, c=colors[3],
              label='Exit Line', linestyle=':')
ax[1].set_ylabel('TII')
ax[1].set_title('Trend Intensity Index')
ax[1].legend(bbox_to_anchor=[1, 0.65])

ax[2].plot(df_mr['strat_cum_returns']*100, label='Mean Reversion')
ax[2].plot(df_mr['cum_returns']*100, label='Buy and Hold')
ax[2].set_title('Cumulative Returns')
ax[2].set_ylabel('Returns (%)')
ax[2].set_xlabel('Date')
ax[2].legend()
plt.tight_layout()
plt.show()

df_stats = pd.DataFrame(getStratStats(df_mr['log_returns']),
                        index=['Buy and Hold'])
df_stats = pd.concat([df_stats,
                      pd.DataFrame(getStratStats(
                        df_mr['strat_log_returns']),
                        index=['Mean Reversion'])])
df_stats
tii-plot3.png
tii-table1.png

The mean reversion model yields some reasonable returns above and beyond the buy and hold approach. The risk adjusted metrics aren’t great, but it is a good boost against the underlying.

Momentum Trading

Like RSI and other oscillators, we can interpret the TII as a momentum indicator, buying when it breaks above the centerline and selling/shorting when it crosses below it. For this, we’ll add a bit of a wrinkle such that we’ll hold while the TII goes above an upper level and then sell if it proceeds to fall below that level. We treat the short side the same way. This will get us out of positions sooner, hopefully with a larger profit than if we held until it reversed all the way back to the centerline again.

def TIIMomentum(data, P=60, centerline=50, upper=80,
                lower=20, shorts=True):
  df = calcTrendIntensityIndex(data, P=P)
  position = np.zeros(df.shape[0])
  for i, (idx, row) in enumerate(df.iterrows()):
    if np.isnan(row['TII']):
      last_row = row.copy()
      continue
    
    if row['TII'] >= centerline and last_row['TII'] < centerline:
      # Go long if no position
      if position[i-1] != 1:
        position[i] = 1
      
    elif row['TII'] > centerline and position[i-1] == 1:
      # Check if broke below upper line
      if last_row['TII'] > upper and row['TII'] <= upper:
        position[i] = 0
      else:
        position[i] = 1
    
    elif position[i-1] == 1 and row['TII'] <= centerline:
      # Sell/short if broke below centerline
      if shorts:
        position[i] = -1
      else:
        position[i] = 0
    
    elif shorts:
      if row['TII'] <= centerline and last_row['TII'] > centerline:
        # Go short if no position
        if position[i-1] != -1:
          position[i] = -1
        elif row['TII'] <= centerline and position[i-1] == -1:
          # Check if broke above lower line
          if last_row['TII'] < lower and row['TII'] > lower:
            position[i] = 0
          else:
            position[i] = -1
        elif position[i-1] == -1 and row['TII'] > centerline:
          # Exit short and go long if it crosses
          position[i] = 1
    last_row = row.copy()
    
  df['position'] = position  
  
  return calcReturns(df)

The easiest way to get the logic right is just by looping over the data and buying/selling in each of the different cases. The code is a bit longer, but hopefully easier to understand.

Let’s move on to testing it with our standard settings.

P = 60
df_mom = TIIMomentum(data.copy(), P=P)

fig, ax = plt.subplots(3, figsize=(12, 8), sharex=True)

ax[0].plot(df_mom['Close'], label='Close')
ax[0].plot(df_mom[f'SMA_{P}'], label=f'SMA({P})')
ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and {P}-Day SMA for {ticker}')
ax[0].legend()

ax[1].plot(df_mom['TII'], label='TII')
ax[1].axhline(20, c=colors[1], 
              label='Exit Short Signal Line', linestyle=':')
ax[1].axhline(80, c=colors[2], 
              label='Exit Long Signal Line', linestyle=':')
ax[1].axhline(50, c=colors[3],
              label='Center Line', linestyle=':')
ax[1].set_ylabel('TII')
ax[1].set_title('Trend Intensity Index')
ax[1].legend(bbox_to_anchor=[1, 0.65])

ax[2].plot(df_mom['strat_cum_returns']*100, label='Momentum')
ax[2].plot(df_mom['cum_returns']*100, label='Buy and Hold')
ax[2].set_title('Cumulative Returns')
ax[2].set_ylabel('Returns (%)')
ax[2].set_xlabel('Date')
ax[2].legend()

plt.tight_layout()
plt.show()

df_stats = pd.concat([df_stats,
                      pd.DataFrame(getStratStats(
                        df_mom['strat_log_returns']),
                        index=['Momentum'])])
df_stats
tii-plot4.png
tii-table2.png

This strategy lost over 60% of its initial capital — clearly not what we were hoping for. The underlying is fairly volatile, so it may perform better if we reduce P to try to take advantage of some of these shorter moves. I leave that as an exercise to the reader.

Adding a Signal Line

Like the MACD, the TII is often traded with a signal line. This signal line is just the EMA of the TII (i.e. EMA(TII(P))).
To calculate this, we need to grab a couple of functions for the EMA calculation.

def _calcEMA(P, last_ema, N):
  return (P - last_ema) * (2 / (N + 1)) + last_ema

def calcEMA(series, N):
  series_mean = series.rolling(N).mean()
  ema = np.zeros(len(series))
  for i, (_, val) in enumerate(series_mean.iteritems()):
    if np.isnan(ema[i-1]):
      ema[i] += val
    else:
      ema[i] += _calcEMA(val, ema[i-1], N)
  return ema

We’ll use these functions to calculate the signal line. We’ll buy when the TII crosses above the signal line, and sell/short when the signal line crosses below.

We’ll code this up below:

def TIISignal(data, P=60, N=9, shorts=True):
  df = calcTrendIntensityIndex(data, P=P)
  df['SignalLine'] = calcEMA(df['TII'], N=N)
  df['position'] = np.nan
  df['position'] = np.where(df['TII']>=df['SignalLine'], 1, 
                            df['position'])
  if shorts:
    df['position'] = np.where(df['TII']<df['SignalLine'], -1,
                              df['position'])
  else:
    df['position'] = np.where(df['TII']<df['SignalLine'], 0,
                              df['position'])
    
  df['position'] = df['position'].ffill().fillna(0)
  return calcReturns(df)

This strategy is short and sweet. Just buy on those signal line cross-overs.

P = 60
N = 9
df_sig = TIISignal(data.copy(), P=P, N=N)

fig, ax = plt.subplots(3, figsize=(12, 8), sharex=True)

ax[0].plot(df_sig['Close'], label='Close')
ax[0].plot(df_sig[f'SMA_{P}'], label=f'SMA({P})')
ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and {P}-Day SMA for {ticker}')
ax[0].legend()

ax[1].plot(df_sig['TII'], label='TII')
ax[1].plot(df_sig['SignalLine'], label='Signal Line')
ax[1].set_ylabel('TII')
ax[1].set_title('Trend Intensity Index and Signal Line')
ax[1].legend(bbox_to_anchor=[1, 0.65])

ax[2].plot(df_sig['strat_cum_returns']*100, label='Signal Line')
ax[2].plot(df_sig['cum_returns']*100, label='Buy and Hold')
ax[2].set_title('Cumulative Returns')
ax[2].set_ylabel('Returns (%)')
ax[2].set_xlabel('Date')
ax[2].legend()

plt.tight_layout()
plt.show()

df_stats = pd.concat([df_stats,
                      pd.DataFrame(getStratStats(
                        df_sig['strat_log_returns']),
                        index=['Signal'])])
df_stats
tii-plot5.png
tii-table3.png

This model missed out on the big gold bull market from 2008–2011, enduring a long and protracted drawdown. It did have some great returns as the gold price fell into 2016. After running flat for a few years, closely following the GDX, it started to pick up with the underlying and managed to get on the right side of the COVID crash — but then gave it all back as the GDX rebounded quickly.

Lots of volatility here and not much upside leads to some poor risk adjusted metrics. You can always adjust the standard P and N values used to see if you can find some better parameters for this one.

Trading with Signal Momentum

I decided to throw in a bonus model now that we have the signal line. We can use that as a classic momentum indicator buying when the TII signal crosses the centerline. In this case, I didn’t bother with trying to hit the tops/bottoms when momentum starts to wane like we did above, just buy and sell based on the centerline, which drastically simplifies the code.

def TIISignalMomentum(data, P=60, N=9, centerline=50,
                      shorts=True):
  df = calcTrendIntensityIndex(data, P=P)
  df['SignalLine'] = calcEMA(df['TII'], N=N)
  df['position'] = np.nan
  df['position'] = np.where(df['SignalLine']>centerline, 1, 
                            df['position'])
  if shorts:
    df['position'] = np.where(df['SignalLine']<centerline, -1,
                              df['position'])
  else:
    df['position'] = np.where(df['SignalLine']<centerline, 0,
                              df['position'])
    
  df['position'] = df['position'].ffill().fillna(0)
  return calcReturns(df)

And to test it on our data:

P = 60
N = 9
df_sigM = TIISignalMomentum(data.copy(), P=P, N=N)

fig, ax = plt.subplots(3, figsize=(12, 8), sharex=True)

ax[0].plot(df_sigM['Close'], label='Close')
ax[0].plot(df_sigM[f'SMA_{P}'], label=f'SMA({P})')
ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and {P}-Day SMA for {ticker}')
ax[0].legend()

ax[1].plot(df_sigM['TII'], label='TII')
ax[1].plot(df_sigM['SignalLine'], label='Signal Line')
ax[1].axhline(50, label='Centerline', c='k', linestyle=':')
ax[1].set_ylabel('TII')
ax[1].set_title('Trend Intensity Index and Signal Line')
ax[1].legend(bbox_to_anchor=[1, 0.65])

ax[2].plot(df_sigM['strat_cum_returns']*100, label='Signal Line')
ax[2].plot(df_sigM['cum_returns']*100, label='Buy and Hold')
ax[2].set_title('Cumulative Returns')
ax[2].set_ylabel('Returns (%)')
ax[2].set_xlabel('Date')
ax[2].legend()

plt.tight_layout()
plt.show()

df_stats = pd.concat([df_stats,
                      pd.DataFrame(getStratStats(
                        df_sigM['strat_log_returns']),
                        index=['Signal Momentum'])])
df_stats
tii-plot6.png
tii-table4.png

This wound up being the worst performer of them all! This strategy lost over 70% of its initial capital and spent 17 years in a drawdown. I don’t know anyone who would have the patience to stick with a strategy to endure those kinds of losses.

Of course, you’ve got the standard knobs to play with to try to pull some better returns out of this one. Plus you could try adding the complexity from the previous momentum strategy to see how that could boost returns.

Improving Your Trading

The TII is designed to measure the strength of a trend. We walked through four different trading strategies, but none really shined. That could be due to a variety of factors — poor selection of the underlying security, bad parameterization, it ought to be combined with other indicators, or maybe this indicator is just terrible.

The only way to know for sure is by testing it.

Proper testing and trading is difficult. The code here should not be relied upon for running your account — it makes too many simplifying assumptions for a real system. It’s just here to teach you about various indicators, running a backtest, and give you a feel for some of the key metrics used to evaluate a strategy.

If you want a fuller backtest experience, check out our free demo for professional backtests and data in a no-code framework so you can test ideas and find valuable trading ideas quickly.