Quant BuffetRelax, Not Over Thinking

Conservative Formula in India

Log in to collect

Academic paper

The Conservative Formula: Evidence from India

AuthorsRajan Raju; Anish Teli

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

Strategy in a nutshell

The strategy targets NSE and BSE-listed firms with positive net worth and large market capitalization. Each quarter, the 1000 largest firms are ranked by 3-year realized volatility, 12-1 month price momentum, and total net payout yield (dividends plus net share changes). The top 100 stocks based on combined rankings are selected for an equally weighted portfolio, which can be hedged using the S&P BSE 100 Index. The portfolio is rebalanced quarterly to capture low-volatility, high-momentum, high-yield opportunities among large-cap, liquid stocks.

Economic rationale

By focusing on low-volatility, high net payout, and strong momentum stocks, the strategy captures robust market factors while minimizing risk. Quarterly rebalancing and large-cap selection enhance liquidity and reduce transaction costs, making the approach efficient and profitable for investors.

Backtest performance

Annualised return16.8%
Volatility22.6%
Beta0.197
Sharpe ratio0.74
Win rate76%

Full Python code

from AlgorithmImports import *
from typing import Dict, Tuple, List
import data_tools
from datetime import datetime
# endregion

class ConservativeFormulaInIndia(QCAlgorithm):

def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(10000000) # INR

self.price_period:int = 12 * 21
self.price_skip_period:int = 21
self.q_fundamental_period:int = 8 # 2 years of quarters

self.data:Dict[Symbol, data_tools.SymbolData] = {}

ticker_file_str:str = self.Download('data.quantpedia.com/backtesting_data/equity/india_stocks/nse_500_tickers.csv')
ticker_lines:List[str] = ticker_file_str.split('\r\n')
tickers:List[str] = [ ticker_line.split(',')[0] for ticker_line in ticker_lines[1:] ]

self.quantile:int = 10
self.leverage:int = 5
self.rebalance_every_n_months:int = 3

for t in tickers:
    # price data subscription
    data:Security = self.AddData(data_tools.IndiaStocks, t, Resolution.Daily)
    data.SetFeeModel(data_tools.CustomFeeModel())
    data.SetLeverage(self.leverage)
    stock_symbol:Symbol = data.Symbol
    
    # fundamental data subscription
    balance_sheet_symbol:Symbol = self.AddData(data_tools.IndiaBalanceSheetStatement, t, Resolution.Daily).Symbol
    cashflow_symbol:Symbol = self.AddData(data_tools.IndiaCashflowStatement, t, Resolution.Daily).Symbol

    self.data[stock_symbol] = data_tools.SymbolData(stock_symbol, balance_sheet_symbol, cashflow_symbol, self.price_period, self.q_fundamental_period)

# BSE index hedge
self.hedge_with_index:bool = False
self.bse_index_data:Security = self.AddData(data_tools.BSEIndex, 'BSE_100', Resolution.Daily)
self.bse_index_data.SetFeeModel(data_tools.CustomFeeModel())
self.bse_index_data.SetLeverage(self.leverage)
self.bse_index:Symbol = self.bse_index_data.Symbol

self.recent_month:int = -1

def OnData(self, data: Slice) -> None:
rebalance_flag:bool = False
metrics_by_symbol:Dict[Symbol, Tuple[float, float]] = {}

price_last_update_date:Dict[Symbol, datetime.date] = data_tools.IndiaStocks.get_last_update_date()
bs_last_update_date:Dict[Symbol, datetime.date] = data_tools.IndiaBalanceSheetStatement.get_last_update_date()
cf_last_update_date:Dict[Symbol, datetime.date] = data_tools.IndiaCashflowStatement.get_last_update_date()

for price_symbol, symbol_data in self.data.items():
    # store price data
    if price_symbol in data and data[price_symbol] and data[price_symbol].Value != 0:
        price:float = data[price_symbol].Value
        self.data[price_symbol].update_price(price)
    
    bs_symbol:Symbol = symbol_data._balance_sheet_symbol
    cf_symbol:Symbol = symbol_data._cashflow_symbol

    # both CF and BS statement data is present at the same time
    if bs_symbol in data and data[bs_symbol] and cf_symbol in data and data[cf_symbol]:
        bs_statement:Dict = data[bs_symbol].Statement
        cf_statement:Dict = data[cf_symbol].Statement

        shares_field:str = 'commonStockSharesOutstanding'
        dividends_field:str = 'dividendsPaid'
        if shares_field in bs_statement and bs_statement[shares_field] is not None and \
            dividends_field in cf_statement and cf_statement[dividends_field] is not None:
            shares_outstanding:float = float(bs_statement[shares_field])
            dividend:float = float(cf_statement[dividends_field])

            # store fundamentals
            symbol_data.update_fundamentals(shares_outstanding, dividend)

    if self.IsWarmingUp: continue

    if (self.recent_month != self.Time.month and self.Time.month % self.rebalance_every_n_months == 0) or rebalance_flag:
        self.recent_month = self.Time.month
        rebalance_flag = True

        if self.Securities[price_symbol].GetLastData() and price_symbol in price_last_update_date and self.Time.date() <= price_last_update_date[price_symbol]:
            if self.Securities[bs_symbol].GetLastData() and bs_symbol in bs_last_update_date and self.Time.date() <= bs_last_update_date[bs_symbol] and \
                self.Securities[cf_symbol].GetLastData() and cf_symbol in cf_last_update_date and self.Time.date() <= cf_last_update_date[cf_symbol]:
                # momentum and fundamental data are ready and still arriving
                if symbol_data.momentum_ready() and symbol_data.fundamentals_ready():
                    shares_outstanding, dividend = symbol_data.get_recent_fundamentals()
                    dps:float = dividend / shares_outstanding
                    price:float = symbol_data.get_recent_price()
                    
                    if price != 0.:
                        dividend_yield:float = dps / price
                        buyback_yield:float = shares_outstanding / symbol_data.get_avg_so()

                        # net payout yield
                        NPY:float = dividend_yield + buyback_yield
                        momentum:float = symbol_data.get_momentum(self.price_skip_period)
                        metrics_by_symbol[price_symbol] = (NPY, momentum)

# rebalance once a quarter
if rebalance_flag:
    long:List[Symbol] = []

    # sorting
    if len(metrics_by_symbol) >= self.quantile:
        # calculate aggregate rank from the momentum and NPY ranks
        sorted_by_npy:List = sorted(metrics_by_symbol.items(), key=lambda x: x[1][0])
        sorted_by_momentum:List = sorted(metrics_by_symbol.items(), key=lambda x: x[1][1])
        rank:Dict[Symbol, float] = { data[0] : np.mean([sorted_by_npy.index(data), sorted_by_momentum.index(data)]) for data in sorted_by_npy}

        # portfolio consists of the top ranked stocks
        sorted_by_rank:List = sorted(rank.items(), key=lambda x: x[1], reverse=True)
        quantile:int = int(len(sorted_by_rank) / self.quantile)
        long = [x[0] for x in sorted_by_rank[:quantile]]

    # liquidate and rebalance
    invested:List[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested]
    for price_symbol in invested:
        if price_symbol not in long:# + [self.bse_index] if self.hedge_with_index else []:
            self.Liquidate(price_symbol)

    long_count:float = float(len(long))
    for price_symbol in long:
        if price_symbol in data and data[price_symbol]:
            self.SetHoldings(price_symbol, 1. / long_count)
    
    # hedge
    if self.hedge_with_index:
        if long_count != 0:
            self.SetHoldings(self.bse_index, -1)
        else:
            if self.Portfolio[self.bse_index].Invested:
                self.Liquidate(self.bse_index)
else:
    if self.Portfolio.Invested:
        # BSE index ended
        bse_last_udpate_date:datetime.date = data_tools.BSEIndex.get_last_update_date()
        if self.Securities[self.bse_index].GetLastData() and self.Time.date() > bse_last_udpate_date:
            self.Liquidate()