Advanced Backtesting with BT

Richard L
6 min readApr 15, 2020
Photo by Samson on Unsplash

Introduction

In my first blog and second blog, I have introduced basic concepts and shared python codes for financial backtesting. BT, the flexible backtesting API for Python, can make that task much quicker and easier.

In my third blog today, I’ll start looking at a real-world quant modeling problem, such as the S&P 500 Sector Rotator Index (Ticker: SPXSRT6T).

Bringing Rule-Based Access to Sector-Rotation

To Understand the Sector Rotator Strategy better, let’s first watch a short video on the S&P’s website:

play.vidyard.com/kp29ECQF5FWX5AVRhpKSG8.html

play.vidyard.com/kp29ECQF5FWX5AVRhpKSG8.html

Based on the video described, S&P sectors behaved differently during the different stages of US economic cycles, so there’s a strong need for sector-rotation. The problem is on how-to. The challenge of successful sector-rotation is on the accurate prediction of macroeconomic indicators such as inflation, interest rate, GDP or unemployment, etc.

S&P brings in a different approach by looking at two of the long-lasting investment principals, value and momentum, and uses a rule-based approach to select sectors.

The sector selection rule seems to be straightforward:

  1. Select Top 5 value scored (most fundamentally discounted) sectors based on 10 Years of historical data;
  2. Select Top 3 momentum scored (best 6 months performance) sectors from the Top 5 value sectors, and equal-weight them.

So by doing that, the index captures the sectors with the greatest relative values (across time) and the greatest relative momentum (across sectors).

3. Finally, the index can also provide better risk management by dynamically allocating assets to US T-bills should the index volatility exceeds a target level, i.e. 10%, 20%, etc.

Data Download and Wrangling

The first step for any quantitative research is, of course, investigating your data. Similar to many other quantitative finance blogs, I used iShares’ S&P sector ETF line-ups.

from yahoo_fin import stock_info as sitickers = {‘sp500’:’IVV’, ‘cash’:’SHY’, ‘sectors’:[‘IYC’,’IDU’,’IYZ’,’IYW’,’IYJ’,’IYH’,’IYF’,’IYE’,’IYK’,’IYM’,’IYR’]}# Download Datasymbols = tickers['sectors']+[tickers['sp500'],tickers['cash']]
data = {k:si.get_data(k) for k in symbols}
prices = pd.DataFrame.from_dict({k:v.adjclose for k,v in data.items()}).dropna()
# Plot Performanceprices[ [tickers[‘sp500’]] + tickers[‘sectors’] ].rebase().plot()
plt.title(‘S&P Sector Performance’,{‘fontsize’:16})
plt.legend(ncol=3)
plt.show()
# Print Simple Statsmtest = prices[ [tickers[‘sp500’]] + tickers[‘sectors’] ].asfreq(‘m’,method=’ffill’).pct_change().dropna()
pd.DataFrame.from_dict({
‘annualized monthly return’:mtest.mean()*12*100,
‘annualized monthly volatility’:mtest.std()*np.sqrt(12)*100,
‘annualized monthly sharpe’:mtest.mean() / mtest.std() * np.sqrt(12),
}).T.round(2)

Building the High-Momentum-Value Algo

Now let’s go to the next step to build the sector rotation algorithm.

Step 1: Building the Value Scores

According to S&P’s original methodology document, the overall sector value scores are set to be the simple average of the Z-scores of three fundamental factors:

  1. Book-Value to Price Ratio
  2. Earnings to Price Ratio
  3. Sales to Price Ratio

Because of the difficulties of getting reliable historical financial data and producing sector aggregation (unless you’re willing to pay a decent amount to the financial data vendors), I just use the historical prices to approximate the value score calculation:

Price Based Value Score = Current Price / Historical Highest Price -1

This is essentially the same as a drawdown calculation. So I took the shortcut to directly call bt(ffn)’s embedded drawdown function.

class StatDrawdown(bt.Algo):def __init__(self, lookback=pd.DateOffset(months=3),
lag=pd.DateOffset(days=0)):
super(StatDrawdown, self).__init__()
self.lookback = lookback
self.lag = lag
def __call__(self, target):
selected = target.temp[‘selected’]
t0 = target.now — self.lag
prc = target.universe.loc[(t0 — self.lookback):t0,selected]
target.temp[‘stat’] = prc.to_drawdown_series().iloc[-1]
return True

Step 2: Build the Momentum Scores

In S&P’s original methodology document, the momentum scores are calculated using the six months’ total return and adjusted for the sector index’s daily volatility. The document itself didn’t state precisely whether it applied the square root rule when adjusting for risk. For Simplicity (and soundness of mathematics), I decided to use bt(ffn)’s information ratio function to calculate the Momentum Score.

class StatInfoRatio(bt.Algo):def __init__(self, benchmark, lookback=pd.DateOffset(months=3),
lag=pd.DateOffset(days=0)):
super(StatInfoRatio, self).__init__()
self.benchmark = benchmark
self.lookback = lookback
self.lag = lag
def __call__(self, target):
selected = target.temp[‘selected’]
t0 = target.now — self.lag
prc = target.universe.loc[(t0 — self.lookback):t0,selected].pct_change().dropna()
bmk = target.universe.loc[(t0 — self.lookback):t0,self.benchmark].pct_change().dropna()
target.temp[‘stat’] = pd.Series({p:prc[p].calc_information_ratio(bmk) for p in prc})
return True

Step 3: Build Strategy and Run Backtest

Once I have created the Algo classes for Value and Momentum Scores calculation, I’m ready to wrap up all steps to build the SPHMV strategy and backtest.

First, I created a passive benchmark that is just S&P 500:

sp500 = bt.Strategy(‘SP500’,
algos = [bt.algos.RunQuarterly(),
bt.algos.SelectAll(),
bt.algos.SelectThese([tickers[‘sp500’]]),
bt.algos.WeighEqually(),
bt.algos.Rebalance()],
)

Secondly, I created another benchmark that equally weights eleven S&P sectors:

spesw = bt.Strategy(‘SPESW’,
algos = [bt.algos.RunQuarterly(),
bt.algos.SelectAll(),
bt.algos.SelectThese(tickers[‘sectors’]),
bt.algos.WeighEqually(),
bt.algos.Rebalance()],
)

Now, this is the S&P High Momentum Value Strategy:

sphmv = bt.Strategy(‘SPHMV’,
algos = [bt.algos.RunQuarterly(),
bt.algos.SelectAll(),
bt.algos.SelectThese(tickers[‘sectors’]),
StatDrawdown(lookback=pd.DateOffset(years=10)),
bt.algos.SelectN(5,sort_descending=False),
StatInfoRatio( benchmark=tickers[‘sp500’],
lookback=pd.DateOffset(months=7),
lag=pd.DateOffset(months=1) ),
bt.algos.SelectN(3,sort_descending=True),
bt.algos.WeighEqually(),
bt.algos.Rebalance()],
)

Finally, it’s time to run backtests for all three strategies:

# run backtests
backtest_sp500 = bt.Backtest(sp500,prices)
backtest_spesw = bt.Backtest(spesw,prices)
backtest_sphmv = bt.Backtest(sphmv,prices)
report = bt.run(backtest_sp500, backtest_spesw, backtest_sphmv)
# Plot performance
report.plot()
plt.title('S&P Sector Rotator Strategy')
plt.show()
# Plot Sector Weights
report.backtests['SPHMV'].security_weights.plot.area()
plt.title("Security Weights for S&P Sector Rotator Strategy")
plt.legend(ncol=2,loc='right',bbox_to_anchor=(1.2,0.5))
plt.show()

Add Daily Volatility Control

To add volatility control, we need to implement the Pooled Portfolio concept as I introduced in my second blog.

First, to calculate the target equity weights based on volatility. I’m tracking the rolling 20 days volatility of the S&P 500 with a maximum annualized target of 20%.

target_vol = 20
real_vol = prices[tickers[‘sp500’]].pct_change()\
.rolling(20).std().multiply(np.sqrt(252)*100)
w = real_vol.map(lambda x: min(1,target_vol/x) )
target_weight = pd.DataFrame({‘SPHMV’:w,’SHY’:1-w})
target_weight.plot()
plt.title(“Allocation Weights for Daily Volatility Control”)
plt.legend(loc=’right’,bbox_to_anchor=(1.1,0.5))
plt.show()

Now to apply the target weight on a pooled portfolio with SPHMV and SHY (cash) using the bt.algos.WeighTarget().

# daily volatility controlspdvc = bt.Strategy(‘SPDVC’,
algos = [bt.algos.RunDaily(),
bt.algos.SelectAll(),
bt.algos.WeighTarget(target_weight),
bt.algos.Rebalance()],
children = [sphmv,tickers[‘cash’]]
)
backtest_spdvc = bt.Backtest(spdvc,prices)
report2 = bt.run(backtest_sp500, backtest_sphmv,backtest_spdvc)
report2.set_date_range(end=’2020–1–31')
report2.set_riskfree_rate(0.01)
report2.display()
Stat SP500 SPHMV SPDVC
------------------- ---------- ---------- ----------
Start 2002-07-29 2002-07-29 2002-07-29
End 2020-01-31 2020-01-31 2020-01-31
Risk-free rate 1.00% 1.00% 1.00%

Total Return 405.13% 474.86% 572.18%
Daily Sharpe 0.55 0.52 0.67
Daily Sortino 0.86 0.82 1.09
CAGR 9.69% 10.51% 11.50%
Max Drawdown -55.25% -61.36% -41.35%
Calmar Ratio 0.18 0.17 0.28
MTD -0.00% 1.28% 1.28%
3m 6.67% 5.30% 5.30%
6m 9.26% 8.94% 8.61%
YTD -0.00% 1.28% 1.28%
1Y 21.61% 22.42% 22.05%
3Y (ann.) 14.48% 9.97% 8.62%
5Y (ann.) 12.04% 10.43% 9.68%
10Y (ann.) 13.75% 11.03% 10.51%
Since Incep. (ann.) 9.69% 10.51% 11.50%

(omitted several lines here)

Avg. Drawdown -1.71% -2.53% -2.32%
Avg. Drawdown Days 24.00 30.91 28.27
Avg. Up Month 2.92% 3.62% 3.35%
Avg. Down Month -3.46% -3.93% -3.33%
Win Year % 83.33% 72.22% 72.22%
Win 12m % 86.50% 76.50% 76.50%

Adding Daily Volatility Control seems to be beneficial to the strategy performance - our new portfolio SPDVC is outperforming both the SP500 and SPHMV. However, just keep in mind that timely execution is the key to this type of strategy as this is a daily tracked strategy and very sensitive to the price fluctuations. In another word, good numbers from backtesting are very difficult to be fully materialized in real-life trade books.

Conclusion:

In today’s blog, I have demonstrated how to use BT to tackle the real-world quantitative investing questions — starting with a relatively easy task to replicate the S&P Sector Rotator Strategy.

By building customized algorithms and using BT’s advanced functions such as SelectN or WeighTarget, I was able to implement the High-Momentum-Value and Daily-Volatility-Control Algos and successfully run the backtests.

The backtest result seems to outperform the benchmark S&P 500 and a sector-equal-weighted S&P. But, keep in mind that backtest numbers are more like science fiction if they remain untested. For my next blog, I’m going to share some thoughts on how to validate the backtest results.

Thanks for reading and stay tuned!

--

--

Richard L

to make data tell the story; to make code do the work