Quant BuffetRelax, Not Over Thinking

Intraday VIX Betas Predict Stocks Returns

Log in to collect

Academic paper

Does the Volatility-Hedging Portfolio Underreact to Volatility Innovations?

AuthorsTong Wang

Institute
  • University of Oklahoma
  • ?University of Oklahoma, Price College of Business

Strategy in a nutshell

The strategy invests in NYSE-listed stocks with bid-ask spreads under 10%, ranked by their intraday VIX beta over the past month. Stocks with the highest intraday VIX beta are shorted, while those with the lowest are longed. The portfolio is equally weighted and rebalanced monthly, exploiting short-term volatility sensitivity.

Economic rationale

Intraday VIX betas capture investor behavioral biases, particularly underreaction to market volatility. Stocks highly sensitive to intraday VIX movements tend to underperform, allowing the strategy to generate returns by exploiting these predictable patterns. Overnight betas lack predictive power.

Backtest performance

Annualised return9.77%
Volatility12.3%
Beta0.554
Sharpe ratio0.79
Sortino ratio-0.106
Win rate49%

Full Python code

from AlgorithmImports import *
from scipy import stats
# endregion

class IntradayVIXBetasPredictStocksReturns(QCAlgorithm):

def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)

self.daily_return_data:dict[Symbol, List[float]] = {}

self.vix = self.AddData(CBOE, 'VIX', Resolution.Daily).Symbol
self.daily_return_data[self.vix] = []

self.active_universe:List[Symbol] = []

self.market_symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

self.min_return_period:int = 15
self.quantile:int = 10
self.leverage:int = 5
self.min_share_price:float = 5.

self.SetWarmup(self.min_return_period, Resolution.Daily)

self.fundamental_count:int = 3000
self.fundamental_sorting_key = lambda x: x.MarketCap

self.selection_flag:bool = False
self.rebalance_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0
self.Schedule.On(self.DateRules.MonthStart(self.market_symbol), self.TimeRules.BeforeMarketClose(self.market_symbol), self.Selection)

def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())
    security.SetLeverage(self.leverage)

    self.daily_return_data[security.Symbol] = []

def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
if not self.selection_flag:
    return Universe.Unchanged
self.selection_flag = False

selected:List[CoarseFundamental] = sorted([x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and \
    x.AdjustedPrice >= self.min_share_price and x.SecurityReference.ExchangeId == "NAS"],
    key=lambda x: x.DollarVolume, reverse=True)

if len(selected) > self.fundamental_count:
    selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

self.active_universe = list(map(lambda stock: stock.Symbol, selected))

return self.active_universe

def OnData(self, data: Slice) -> None:
beta:dict[Symbol, float] = {}

# store daily returns
if self.vix in data and data[self.vix]:
    vix_return:float = data[self.vix].Close / data[self.vix].Open - 1
    self.daily_return_data[self.vix].append(vix_return)
    
    for symbol in self.active_universe:
        if symbol in data and data[symbol]:
            daily_return:float = data[symbol].Close / data[symbol].Open - 1
            self.daily_return_data[symbol].append(daily_return)
        
        # calculate VIX beta if rebalance flag is set
        if not self.IsWarmingUp and self.rebalance_flag:
            vix_hist_len:int = int(len(self.daily_return_data[self.vix]))
            if vix_hist_len >= self.min_return_period:
                # both return series have equall lenghts
                if len(self.daily_return_data[symbol]) == vix_hist_len:
                    # calculate VIX beta
                    slope, _, _, _, _ = stats.linregress(self.daily_return_data[self.vix], self.daily_return_data[symbol])
                    beta[symbol] = slope
        
            # reset daily prices for a given month
            self.daily_return_data[symbol].clear()

if self.IsWarmingUp:
    return

if not self.rebalance_flag:
    return
self.rebalance_flag = False

self.daily_return_data[self.vix].clear()

long:List[Symbol] = []
short:List[Symbol] = []

if len(beta) >= self.quantile:
    # sort by intraday beta
    sorted_by_beta:List = sorted(beta.items(), key=lambda x: x[1], reverse=True)
    quantile:int = int(len(beta) / self.quantile)
    long = [x[0] for x in sorted_by_beta[-quantile:]]
    short = [x[0] for x in sorted_by_beta[:quantile]]

# order execution
targets:List[PortfolioTarget] = []
for i, portfolio in enumerate([long, short]):
    for symbol in portfolio:
        if symbol in data and data[symbol]:
            targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))

self.SetHoldings(targets, True)

def Selection(self) -> None:
# quarterly universe selection
if self.Time.month % 3 == 0:
    self.selection_flag = True

# monthly rebalance
self.rebalance_flag = True

class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))