该策略投资于S&P BSE 200指数成分股。每月末,计算每只股票t-3月份的账面价值与t-1月份市场价格的比率,并使用z分数进行标准化处理。对于负值,按照公式进行转换。然后,将所有股票按z分数排序为十分位组,买入z分数最低的前十分位组(账面市值比最低的股票),并每月重新平衡。

策略概述

投资范围包括S&P BSE 200指数成分股。首先,在每个月底,计算每只成分股t-3月份的账面价值与t-1月份的市场价格的比率。其次,使用z分数对计算得到的账面市值比进行标准化。为了处理负值,按照公式2进一步对z分数进行转换(如果z分数大于或等于0,则取1加上z分数;如果z分数为负,则取1除以1减去z)。第三,根据z分数将所有股票排序为等权重的十分位组,z分数最低的股票在前十分位组。买入最低十分位组(账面市值比最低的股票),并每月重新平衡。

策略合理性

账面市值比(BM比率)比较了公司账面价值相对于其市值的比率。BM比率越高,被分析公司的基本面越便宜。具有高BM比率的公司,即价值股,在熊市中应该表现优于其他公司,因为投资者在不确定时期寻求安全性。相反,在由低利率推动的牛市中,正如过去十年所见,价值股表现欠佳,而成长股表现优异。

论文来源

Long Only Factor Portfolios in India [点击浏览原文]

<摘要>

我们展示了从印度前200只股票中构建的月度重新平衡、等权重的仅多头赢家投资组合,这些投资组合基于支撑动量、低波动率和质量的流行因子的系统规则,在研究期间产生了阿尔法。我们观察到,所有风格策略的市场敞口都显著,这使得策略之间的相关性远高于学术因子回报的相关性。我们为某些因子引入了替代计算方法,并发现并非所有因子策略的实现方式都相同,并非所有策略的换手率都很高。实际上,像低波动率和质量这样的策略显示出相对较低的换手率。因子敞口的持久性随时间变化因策略而异,在实施因子风格策略时应考虑持久性。我们还发现,因子的规模和行业偏好是动态的,这可能会减少预期的多样化收益。最后,我们展示了动量、低波动率和质量策略的阿尔法在现实世界的实施成本下仍然有效。

回测表现

年化收益率16.47%
波动率22.65%
Beta0.142
夏普比率0.73
索提诺比率N/A
最大回撤N/A
胜率88%

完整python代码

from AlgorithmImports import *
from datetime import datetime
import data_tools
# endregion

class LowValueFactorInIndia(QCAlgorithm):

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

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

        self.max_missing_days:int = 5
        
        self.PB_data:dict = {}
        self.data:dict[str, data_tools.SymbolData] = {}

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

        # load price to book values
        csv_string_file:str = self.Download('data.quantpedia.com/backtesting_data/equity/india_stocks/price_to_book.csv')
        lines:list = csv_string_file.split('\r\n')
        tickers_with_PB_values:list = lines[0].split(';')[1:]

        for line in lines[1:]: # skip header
            if line == '':
                continue

            line_split:list = line.split(';')

            date:datetime.date = datetime.strptime(line_split[0], '%Y-%m-%d').date()
            # init dictionary for this date
            self.PB_data[date] = {}

            length = len(line_split[1:])
            for i in range(length):
                PB_value:float = float(line_split[i + 1])

                if PB_value != 0:
                    ticker:str = tickers_with_PB_values[i]

                    self.PB_data[date][ticker] = PB_value

        # subscribe india stocks
        csv_string_file:str = self.Download('data.quantpedia.com/backtesting_data/equity/india_stocks/india_nifty_100_tickers.csv')
        lines:list = csv_string_file.split('\r\n')

        for line in lines[:99]:
            line_split:list = line.split(';')

            for ticker in line_split:
                # subscribe india stock
                security:Security = self.AddData(data_tools.QuantpediaIndiaStocks, ticker, Resolution.Daily)
                security.SetFeeModel(data_tools.CustomFeeModel())
                security.SetLeverage(self.leverage)

                india_stock_symbol:Symbol = security.Symbol

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

        self.selection_flag = False
        self.Schedule.On(self.DateRules.MonthEnd(self.market), self.TimeRules.BeforeMarketClose(self.market), self.Selection)

    def OnData(self, data: Slice):
        curr_date:datetime.date = self.Time.date()

        if curr_date in self.PB_data:
            PB_values:dict[ticker, float] = self.PB_data[curr_date] 
            
            for ticker, PB_value in PB_values.items():
                self.data[ticker].update_PB_value(PB_value)

        # rebalance monthly
        if not self.selection_flag:
            return
        self.selection_flag = False

        book_to_market:dict[Symbol, float] = {}
        all_book_to_markets:list = []

        for _, symbol_obj in self.data.items():
            if not symbol_obj.is_ready():
                continue
            
            india_stock_symbol:Symbol = symbol_obj.india_stock_symbol
            if self.Securities[india_stock_symbol].GetLastData() and (self.Time.date() - \
                self.Securities[india_stock_symbol].GetLastData().Time.date()).days < self.max_missing_days:
                curr_PB_value:float = symbol_obj.PB_value
                book_to_market_value:float = 1 / curr_PB_value
                all_book_to_markets.append(book_to_market_value)
                book_to_market[india_stock_symbol] = book_to_market_value

            # clear stocks PB_value for last month
            symbol_obj.clear_PB_value()

        # make sure there are enough stocks for selection
        if len(book_to_market) <= self.quantile:
            self.Liquidate()
            return

        # z-score calculation
        z_score:dict[Symbol, float] = {}
        mean_book_to_markets:float = np.mean(all_book_to_markets)
        std_book_to_markets:float = np.std(all_book_to_markets)

        # z-score transform
        z_score:dict[Symbol, float] = {symbol:((value - mean_book_to_markets) / std_book_to_markets) for symbol, value in book_to_market.items()}
        z_score_transform:dict[Symbol, float] = {symbol : (1+z) if z >= 0 else (1/ (1-z)) for symbol, z in z_score.items()}

        quantile:int = int(len(z_score_transform) / self.quantile)
        sorted_by_z_score:list[Symbol] = [x[0] for x in sorted(z_score_transform.items(), key=lambda item: item[1])]

        # Buy the bottom decile (stocks with the lowest book-to-market ratios)
        long_part:list[Symbol] = sorted_by_z_score[:quantile]
        long_part_length:int = len(long_part)

        # trade execution
        invested:list[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested]
        for symbol in invested:
            if symbol not in long_part:
                self.Liquidate(symbol)

        for symbol in long_part:
            self.SetHoldings(symbol, 1 / long_part_length)

    def Selection(self):
        self.selection_flag = True

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading