Quant BuffetRelax, Not Over Thinking

Betting Against Beta in India

Log in to collect

Academic paper

'Long' Factors, not 'Short' Change : Long Only Factor Portfolios in India

AuthorsRajan Raju; Anish Teli

Institute
  • ?Invespar Pte Ltd
  • Healthcentric Advisors
  • ?QED Capital Advisors

Strategy in a nutshell

This strategy targets S&P BSE 200 stocks, ranks them by adjusted beta scores, and builds long positions in low-beta equities. Portfolios are rebalanced monthly to capture the low-risk anomaly.

Economic rationale

Empirical evidence shows high-beta stocks underperform while low-beta stocks deliver superior risk-adjusted returns. Constraints on leverage explain this anomaly, making “Betting Against Beta” a profitable and robust factor strategy.

Backtest performance

Annualised return18.88%
Volatility16.02%
Beta0.249
Sharpe ratio1.18
Win rate51%

Full Python code

from AlgorithmImports import *
import numpy as np
from scipy import stats
import data_tools
#endregion

class BettingAgainstBetainIndia(QCAlgorithm):

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

self.data:dict = {}
self.long_term_period:int = 5 * 12 * 21
self.short_term_period:int = 1 * 12 * 21
self.SetWarmUp(self.short_term_period, Resolution.Daily)

self.quantile:int = 5
self.max_missing_days:int = 5
self.market = self.AddData(data_tools.BSE_200, 'BSE_200', Resolution.Daily).Symbol
self.data[self.market] = data_tools.SymbolData(self.short_term_period)

csv_string_file = self.Download('data.quantpedia.com/backtesting_data/equity/india_stocks/india_nifty_100_tickers.csv')
line_split = csv_string_file.split(';')

# NOTE: Download method is rate-limited to 100 calls (https://github.com/QuantConnect/Documentation/issues/345)
self.ticker:list[str] = line_split[:99]

for ticker in self.ticker:
    security = self.AddData(data_tools.QuantpediaIndiaStocks, ticker, Resolution.Daily)
    security.SetFeeModel(data_tools.CustomFeeModel())
    security.SetLeverage(5)

    self.data[ticker] = data_tools.SymbolData(self.short_term_period)

self.recent_month:int = -1

def OnData(self, data):
rebalance_flag:bool = False

# rebalance once a month
if not self.IsWarmingUp and self.Time.month != self.recent_month:
    rebalance_flag = True
    self.recent_month = self.Time.month

beta:dict[str, float] = {}

# store daily price data
if self.market in data and data[self.market]:
    # market price data
    self.data[self.market].update(data[self.market].Value)
    
    # stock price data
    for ticker in self.ticker:
        if ticker in data and data[ticker]:
            self.data[ticker].update(data[ticker].Value)
        else:
            if self.data[ticker].closes.Count != 0:
                self.data[ticker].update(self.data[ticker].closes[0])

        if self.IsWarmingUp: continue
        if rebalance_flag:
            if self.data[ticker].is_ready() and self.data[self.market].is_ready():
                # stock price data is still comming in
                if self.Securities[ticker].GetLastData() and (self.Time.date() - self.Securities[ticker].GetLastData().Time.date()).days < self.max_missing_days:
                    # market and stock returns
                    market_prices:np.ndarray = np.array([x for x in self.data[self.market].closes])
                    market_returns:np.ndarray = market_prices[:-1] / market_prices[1:] - 1
                    market_returns = np.nan_to_num(market_returns, nan=0.)

                    stock_prices:np.ndarray = np.array([x for x in self.data[ticker].closes])
                    stock_returns:np.ndarray = stock_prices[:-1] / stock_prices[1:] - 1
                    stock_returns = np.nan_to_num(stock_returns, nan=0.)

                    # NOTE source paper version of beta estimate
                    slope, intercept, r_value, p_value, std_err = stats.linregress(market_returns, stock_returns)
                    beta[ticker] = slope

if rebalance_flag:
    # z-score normalization
    beta_values:list[float] = [beta for ticker, beta in beta.items()]
    beta_std:float = np.std(beta_values)
    beta_mean:float = np.mean(beta_values)
    z_score:dict = { ticker: (beta - beta_mean) / beta_std for ticker, beta in beta.items()}
    z_transform:dict = { ticker : (1+z) if z>= 0 else (1/(1-z)) for ticker, z in z_score.items()}

    long:list[Symbol] = []
    short:list[Symbol] = []

    if len(z_transform) >= self.quantile:
        quantile:int = int(len(z_transform) / self.quantile)
        sorted_by_zscore = sorted(z_transform.items(), key=lambda item: item[1], reverse=True)

        long = [x[0] for x in sorted_by_zscore[:quantile]]
        short = [x[0] for x in sorted_by_zscore[-quantile:]]
        
    # trade execution
    long_count:int = len(long)
    short_count:int = len(short)

    stocks_invested = [x.Key for x in self.Portfolio if x.Value.Invested]
    for symbol in stocks_invested:
        if symbol not in long + short:
            self.Liquidate(symbol)

    for symbol in long:
        self.SetHoldings(symbol, 1 / long_count)
    for symbol in short:
        self.SetHoldings(symbol, -1 / short_count)