
The strategy trades India’s top 500 stocks, sorting by momentum and volatility, going long on high-momentum, low-volatility stocks, short on low-momentum, high-volatility stocks, rebalancing monthly.
ASSET CLASS: stocks | REGION: India | FREQUENCY: Monthly | MARKET: equities | KEYWORD: India, Momentum Effect
I. STRATEGY IN A NUTSHELL
Double-sort top 500 NSE stocks by momentum and volatility. Go long on high-momentum, low-volatility stocks and short low-momentum, high-volatility stocks. Rebalance monthly for risk-adjusted returns.
II. ECONOMIC RATIONALE
Low-volatility stocks in India outperform, generating high alpha and Sharpe ratios, while high-volatility stocks underperform. The effect is robust across size, value, liquidity, and sector exposures.
III. SOURCE PAPER
Low-Risk Effect: Evidence, Explanations and Approaches to Enhancing the Performance of Low-Risk Investment Strategies [Click to Open PDF]
Mayank Joshipura, School of Business Management, NMIMS University; Nehal Joshipura, Durgadevi Saraf Institute of Management Studies
<Abstract>
The authors offer evidence for low-risk effect from the Indian stock market using the top-500 liquid stocks listed on the National Stock Exchange (NSE) of India for the period from January 2004 to December 2018. Finance theory predicts a positive risk-return relationship. However, empirical studies show that low-risk stocks outperform high-risk stocks on a risk-adjusted basis, and it is called lowrisk anomaly or low-risk effect. Persistence of such an anomaly is one of the biggest mysteries in modern finance. The authors find strong evidence in favor of a low-risk effect with a flat (negative) risk-return relationship based on the simple average (compounded) returns. It is documented that low-risk effect is independent of size, value, and momentum effects, and it is robust after controlling for variables like liquidity and ticket-size of stocks. It is further documented that low-risk effect is a combination of stock and sector level effects, and it cannot be captured fully by concentrated sector exposure. By integrating the momentum effect with the low-volatility effect, the performance of a low-risk investment strategy can be improved both in absolute and risk-adjusted terms. The paper contributed to the body of knowledge by offering evidence for: a) robustness of low-risk effect for liquidity and ticket-size of stocks and sector exposure, b) how one can benefit from combining momentum and low-volatility effects to create a long-only investment strategy that offers higher risk-adjusted and absolute returns than plain vanilla, long-only, low-risk investment strategy.


IV. BACKTEST PERFORMANCE
| Annualised Return | 10.84% |
| Volatility | N/A |
| Beta | -0.173 |
| Sharpe Ratio | -0.287 |
| Sortino Ratio | N/A |
| Maximum Drawdown | N/A |
| Win Rate | 47% |
V. FULL PYTHON CODE
from AlgorithmImports import *
import numpy as np
#endregion
class MomentumAndLowRiskEffectsInIndia(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)
self.data:dict = {}
self.symbols:list[Symbol] = []
self.period:int = 37 * 21 # 37 months of daily closes
self.SetWarmUp(self.period, Resolution.Daily)
self.sma:dict = {}
self.sma_period:int = 200
self.quantile:int = 5
self.perf_period:int = 12
self.symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
csv_string_file = self.Download('data.quantpedia.com/backtesting_data/equity/india_stocks/india_nifty_500_tickers.csv')
line_split = csv_string_file.split(';')
# NOTE: Download method is rate-limited to 100 calls (https://github.com/QuantConnect/Documentation/issues/345)
for ticker in line_split[:99]:
security = self.AddData(QuantpediaIndiaStocks, ticker, Resolution.Daily)
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(5)
symbol:Symbol = security.Symbol
self.symbols.append(symbol)
self.data[symbol] = SymbolData(self.period)
self.sma[symbol] = self.SMA(symbol, self.sma_period, Resolution.Daily)
self.max_missing_days:int = 5
self.recent_month:int = -1
def OnData(self, data):
# store daily prices
for symbol in self.symbols:
if symbol in data and data[symbol]:
price:float = data[symbol].Value
if price != 0 and not np.isnan(price):
self.data[symbol].update(price)
if self.IsWarmingUp: return
if self.Time.month == self.recent_month:
return
self.recent_month = self.Time.month
volatility:[Symbol, float] = {}
performance:[Symbol, float] = {}
# price_over_sma:[Symbol, bool] = {}
for symbol in self.symbols:
# prices are ready and data is still comming in
if self.data[symbol].is_ready() and self.sma[symbol].IsReady:
if self.Securities[symbol].GetLastData() and (self.Time.date() - self.Securities[symbol].GetLastData().Time.date()).days <= self.max_missing_days:
perf:float = self.data[symbol].performance(self.perf_period)
vol:float = self.data[symbol].volatility()
if perf != 0 and vol != 0 and not np.isnan(perf) and not np.isnan(vol):
performance[symbol] = perf
volatility[symbol] = vol
# price_over_sma[symbol] = bool(self.data[symbol].closes[0] > self.sma[symbol].Current.Value)
long:list[Symbol] = []
short:list[Symbol] = []
if len(performance) >= self.quantile and len(volatility) >= self.quantile:
quantile:int = int(len(performance) / self.quantile)
sorted_by_performance = sorted(performance.items(), key=lambda item: item[1], reverse=True)
sorted_by_volatility = sorted(volatility.items(), key=lambda item: item[1], reverse=True)
top_by_perf = [x[0] for x in sorted_by_performance[:quantile]]
bottom_by_perf = [x[0] for x in sorted_by_performance[-quantile:]]
top_by_vol = [x[0] for x in sorted_by_volatility[:quantile]]
bottom_by_vol = [x[0] for x in sorted_by_volatility[-quantile:]]
long = [x for x in top_by_perf if x in bottom_by_vol]
short = [x for x in bottom_by_perf if x in top_by_vol]
# 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)
if long_count:
self.Log(long_count)
if short_count:
self.Log(short_count)
for symbol in long:
if symbol in data and data[symbol]:
self.SetHoldings(symbol, 1 / long_count)
for symbol in short:
if symbol in data and data[symbol]:
self.SetHoldings(symbol, -1 / short_count)
class SymbolData():
def __init__(self, period:int) -> None:
self.closes = RollingWindow[float](period)
def update(self, close:float) -> None:
self.closes.Add(close)
def is_ready(self) -> bool:
return self.closes.IsReady
def performance(self, months:int) -> float:
return self.closes[21] / self.closes[months * 21-1] - 1
def volatility(self) -> float:
closes:list[float] = [x for x in self.closes]
closes:np.ndarray = np.array(closes)
returns:np.ndarray = (closes[:-1] - closes[1:]) / closes[1:]
vol:float = float(np.std(returns))
# monthly_closes:np.array = np.array(closes[0::21])
# monthly_returns:np.array = (monthly_closes[:-1] - monthly_closes[1:]) / monthly_closes[1:]
# vol:float = np.std(monthly_returns)
return vol
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))
# Quantpedia data
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaIndiaStocks(PythonData):
def GetSource(self, config, date, isLiveMode):
source = "data.quantpedia.com/backtesting_data/equity/india_stocks/india_nifty_500/{0}.csv".format(config.Symbol.Value)
return SubscriptionDataSource(source, SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
def Reader(self, config, line, date, isLiveMode):
data = QuantpediaIndiaStocks()
data.Symbol = config.Symbol
if not line[0].isdigit(): return None
split = line.split(',')
data.Time = datetime.strptime(split[0], "%Y-%m-%d") + timedelta(days=1)
data['Price'] = float(split[1])
data.Value = float(split[1])
return data
VI. Backtest Performance