Outperform S&P 500 by 50%: Sector Rotation Strategy Leveraging Risk Indicators (Python code included)

Double L
TradeMind_Analytics
4 min readJan 5, 2024

My proposed strategy flexibly targets defensive sectors in riskier times and transitions to growth sectors when risk reduces. Over 2016–2023, it achieved a 50% outperformance, delivering a 219% return versus the S&P 500’s 166%. With superior risk-adjusted performance and reduced downside compared to the S&P 500, it boasted an Annualized Return of 16% against 13%.

Performance

  • Achieved a 50% outperformance compared to the S&P 500 over the 8 from 2016 to 2023: Strategy 219% versus S&P 500 166%.
  • Demonstrated superior risk-adjusted performance with a Sharpe Ratio of 0.91, surpassing the S&P 500’s 0.78.
  • Experienced a reduced Maximum Drawdown of -29% in contrast to the S&P 500’s -34%.
  • Delivered an Annualized Return of 16%, outperforming the S&P 500’s 13%.

Proposal

In times of market volatility, investors often allocate funds towards safer defensive sectors like Utilities, Consumer Staples, and Healthcare. As market risk diminishes, the investment focus shifts towards growth sectors such as Information Technology, Consumer Discretionary, and Communication. Based on this pattern, I propose crafting a portfolio strategy designed to outperform the market benchmark (S&P 500).

Data

  • VIX: Utilizing VIX as a standard risk assessment tool is a common practice. In this project, I’ve additionally computed the 60-day Moving Average (60 MA). Whenever the VIX surpasses the 60 MA, it signifies a risk-prone setting. Conversely, when the VIX remains below the 60 MA, it indicates a derisked environment.
  • SPY: SPDR S&P 500 ETF to represent the whole market.
  • ETFs: XLU (Utility), XLP (Consumer Staple), XLV (Healthcare), XLK (Technology), XLC (Communication), XLY (Consumer Discretionary)
  • Time Period: 2016–2023 (because all ETFs are available from 2016.)

Python Codes

# Import necessary libraries
import pandas as pd
import yfinance as yf
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
# Downloading data for SPY and ^VIX from Yahoo Finance
refer = yf.download(['SPY','^VIX'], '2016-01-01')['Adj Close']
refer.rename(columns = {'SPY': 'SP500', '^VIX': 'VIX'}, inplace = True)
refer.head()
# Calculating the rolling mean of VIX
refer['MA_VIX'] = refer['VIX'].rolling(60).mean()
refer[['VIX', 'MA_VIX']].plot()
# Calculating the daily returns for SP500
refer['return_sp500'] = refer['SP500'] / refer['SP500'].shift(1) - 1
refer[['SP500', 'return_sp500']].head()
# Creating signals for risk and de-risk based on VIX and MA_VIX
refer['signal_risk'] = np.where((refer.VIX > refer.MA_VIX), 1, 0)
refer['signal_derisk'] = np.where((refer.VIX < refer.MA_VIX), 1, 0)
refer.dropna(inplace = True)
refer.head()
# Mapping sector ETFs and downloading their Adjusted Close prices
sector_etf = dict({
'Consumer Discretionary' : 'XLY',
'Consumer Staples' : 'XLP',
'Energy' : 'XLE',
'Financials' : 'XLF' ,
'Health Care' : 'XLV',
'Industrials' : 'XLI',
'Materials' : 'XLB',
'Real Estate' : 'XLRE',
'Information Technology' : 'XLK',
'Communication Services' : 'XLC',
'Utilities' : 'XLU'
})
df_sector_etf = yf.download(list(sector_etf.values()), '2016-01-01')['Adj Close']
df_sector_etf.head()
# Calculating average returns for risk and de-risk ETFs
df_sector_etf['return_derisk_etf'] = (df_sector_etf[['XLK', 'XLC', 'XLY']] / df_sector_etf[['XLK', 'XLC', 'XLY']].shift(1) - 1).mean(axis = 1)
df_sector_etf['return_risk_etf'] = (df_sector_etf[['XLU', 'XLP', 'XLV']] / df_sector_etf[['XLU', 'XLP', 'XLV']].shift(1) - 1).mean(axis = 1)
df_sector_etf.head()
# Merging dataframes and calculating strategy returns
df_all = pd.concat([refer, df_sector_etf[['return_derisk_etf', 'return_risk_etf']]], axis = 1).dropna()
df_all.head()
# Checking for missing values
df_all.isna().sum()
# Calculating strategy returns and cumulative performance
df_all['return_strategy'] = df_all['signal_risk'] * df_all['return_risk_etf'].shift(-1) + df_all['signal_derisk'] * df_all['return_derisk_etf'].shift(-1)
df_all['rebased_strategy'] = (df_all['return_strategy'] + 1).cumprod()
df_all['rebased_sp500'] = (df_all['return_sp500'] + 1).cumprod()
df_all.dropna(inplace = True)
df_all.tail()
# Plotting cumulative performance with risk and de-risk signals
df_all[['rebased_strategy', 'rebased_sp500']].plot(figsize = (10, 5), color = ['red', 'black'])
plt.fill_between(df_all.index, df_all.signal_risk, color = 'pink', alpha = 0.5, label = 'Signal_Risk')
plt.fill_between(df_all.index, df_all.signal_derisk, color = 'yellow', alpha = 0.5, label = 'Singal_DeRisk')
plt.legend()
plt.grid()
# Calculating Sharpe Ratio for S&P 500 and Strategy
sharpe_sp500 = np.mean(df_all.return_sp500) / np.std(df_all.return_sp500) * (252 ** 0.5)
sharpe_strategy = np.mean(df_all.return_strategy) / np.std(df_all.return_strategy) * (252 ** 0.5)
print(f'Sharpe Ratio: S&P 500 {round(sharpe_sp500,2)}; Strategy {round(sharpe_strategy, 2)}')

# Calculating Cumulative Return for S&P 500 and Strategy
print(f'Cumulative Return: S&P 500 {round(df_all.rebased_sp500[-1] - 1, 2) * 100}%; Strategy {round(df_all.rebased_strategy[-1] -1, 2)*100}%')

# Calculating Annualized Return for S&P 500 and Strategy
diff_day = df_all.index[-1] - df_all.index[0]
holding_period = diff_day.days
cum_return_sp500 = df_all.rebased_sp500[-1] - 1
annual_sp500 = (cum_return_sp500 + 1) ** (365/ holding_period) - 1
cum_return_strategy = df_all.rebased_strategy[-1] - 1
annual_strategy = (cum_return_strategy + 1) ** (365/ holding_period) - 1
print(f'Annualized Return: S&P 500 {round(annual_sp500, 2)}; Strategy {round(annual_strategy, 2)}')

# Calculating Maximum Drawdown for S&P 500 and Strategy
running_max_sp500 = np.maximum.accumulate(df_all.rebased_sp500)
running_max_sp500[running_max_sp500 < 1] = 1
DD_sp500 = df_all.rebased_sp500 / running_max_sp500 - 1
MDD_sp500 = DD_sp500.min()
running_max_strategy = np.maximum.accumulate(df_all.rebased_strategy)
running_max_strategy[running_max_strategy < 1] = 1
DD_strategy = df_all.rebased_strategy / running_max_strategy - 1
MDD_strategy = DD_strategy.min()
print(f'Maximum Drawdown: S&P 500: {round(MDD_sp500,2)}; Strategy: {round(MDD_strategy, 2)}')

Improvement

While the performance appears superior to the S&P 500, the actual outcome might be less impressive when factoring in transaction fees and associated costs resulting from numerous ETF trades. To improve operational efficiency and minimize transaction expenses, frequently changing the portfolio isn’t advisable. Therefore, a potential enhancement could involve making the trading signals more consistent or persistent.

--

--

Double L
TradeMind_Analytics

Macro Economics, Algo. Trading, Quantitative Portfolio Management