Quant BuffetRelax, Not Over Thinking

Factor Momentum in the Chinese Stock Market

Log in to collect

Academic paper

Factor Momentum in the Chinese Stock Market

AuthorsTian Ma; Cunfei Liao; Fuwei Jiang

Institute
  • Minzu University of China
  • ?School of Economics, Minzu University of China
  • Hunan University of Finance and Economics
  • ?Hunan University - College of Finance and Statistics
  • Central University of Finance and Economics
  • ?Central University of Finance and Economics (CUFE)

Strategy in a nutshell

The strategy focuses on A-share stocks from Shanghai and Shenzhen exchanges, constructing ten non-momentum factor portfolios (size, value, profitability, investment, illiquidity, earnings-to-price, accruals, cash flow-to-price, turnover, and betting-against-beta). Annualized factor returns are calculated as the difference between top and bottom quintile returns. Investors go long factors with above-median previous-year returns and short factors with below-median returns. Portfolios are equal-weighted and rebalanced monthly.

Economic rationale

The factor momentum arises from mispricing corrections and limits to arbitrage. It strengthens during periods of high idiosyncratic volatility, low investor sentiment, and among stocks with high information asymmetry or short-sale constraints. Increased arbitrageur activity amplifies factor momentum.

Backtest performance

Annualised return7.02%
Volatility8.78%
Beta-0.007
Sharpe ratio0.8
Win rate25%

Full Python code

from AlgorithmImports import *
from data_tools import ChineseStocks, CustomFeeModel, ChineseBalanceSheet, \
ChineseIncomeStatement, ChineseCashflowStatement, SymbolData, FactorData
# endregion

class FactorMomentumInTheChineseStockMarket(QCAlgorithm):

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

self.top_size_symbol_count:int = 200
ticker_file_str:str = self.Download('data.quantpedia.com/backtesting_data/equity/chinese_stocks/large_cap_500.csv')
self.tickers:List[str] = ticker_file_str.split('\r\n')[:self.top_size_symbol_count]

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

self.period:int = 3 * 21
self.monthly_returns_period:int = 1

self.min_prices:int = 15
self.min_volumes:int = 15
self.max_missing_statement_days:int = 3 * 30

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

self.factors_identificators:list[str] = ['SIZE', 'BM', 'ILLIQUIDITY','EARNINGS-TO-PRICE','CF-TO-PRICE','TURNOVER']
self.factors_data:dict[str, FactorData] = { identificator: FactorData(self.monthly_returns_period) \
    for identificator in self.factors_identificators }

for t in self.tickers:
    # price data
    data = self.AddData(ChineseStocks, t, Resolution.Daily)
    data.SetFeeModel(CustomFeeModel())
    data.SetLeverage(self.leverage)

    china_stock_symbol:Symbol = data.Symbol
    income_symbol:Symbol = self.AddData(ChineseIncomeStatement, t, Resolution.Daily).Symbol
    balance_symbol:Symbol = self.AddData(ChineseBalanceSheet, t, Resolution.Daily).Symbol
    cashflow_symbol:Symbol = self.AddData(ChineseCashflowStatement, t, Resolution.Daily).Symbol

    self.data[china_stock_symbol] = SymbolData(self.period, income_symbol, balance_symbol, cashflow_symbol)

self.recent_month:int = -1
self.spy:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.Schedule.On(self.DateRules.MonthEnd(self.spy), self.TimeRules.BeforeMarketClose(self.spy, 0), self.UpdateFactorsPerformances)

def OnData(self, data: Slice):
factors:dict[str, dict] = { identificator: {} for identificator in self.factors_identificators }
curr_date:datetime.date = self.Time.date()

for symbol, symbol_data in self.data.items():
    income_symbol:Symbol = symbol_data.income_symbol
    if income_symbol in data and data[income_symbol] and data[income_symbol].GetProperty('statement'):
        symbol_data.update_income_data(curr_date, data[income_symbol].GetProperty('statement'))

    balance_symbol:Symbol = symbol_data.balance_symbol
    if balance_symbol in data and data[balance_symbol] and data[balance_symbol].GetProperty('statement'):
        symbol_data.update_balance_data(curr_date, data[balance_symbol].GetProperty('statement'))

    cashflow_symbol:Symbol = symbol_data.cashflow_symbol
    if cashflow_symbol in data and data[cashflow_symbol] and data[cashflow_symbol].GetProperty('statement'):
        symbol_data.update_cashflow_data(curr_date, data[cashflow_symbol].GetProperty('statement'))

    if symbol in data and data[symbol] and data[symbol].Value and data[symbol].GetProperty('price_data'):
        price_data:dict = data[symbol].GetProperty('price_data')
    
        price:float = data[symbol].Value 
        volume:float = price_data['turnoverVol']
        symbol_data.update_prices_volumes_last_update(price, volume, curr_date)

        if self.recent_month != self.Time.month \
            and symbol_data.statements_still_coming(curr_date, self.max_missing_statement_days):

            balance_data:dict[str, str] = symbol_data.balance_data
            income_data:dict[str, str] = symbol_data.income_data
            cashflow_data:dict[str, str] = symbol_data.cashflow_data

            market_cap:float = float(price_data['marketValue'])
            total_assets:float = float(balance_data['TAssets'])

            if market_cap != 0 and total_assets != 0 and symbol_data.turnover_volumes_ready() \
                and symbol_data.prices_ready(self.min_prices) and symbol_data.volumes_ready(self.min_volumes):
                
                factors['SIZE'][symbol] = market_cap

                book_value:float = total_assets - float(balance_data['TLiab'])
                book_to_market:float = book_value / market_cap
                factors['BM'][symbol] = book_to_market

                factors['ILLIQUIDITY'][symbol] = symbol_data.get_illiquidity()

                net_income:float = float(income_data['NIncome'])
                earnings_to_price:float = net_income / market_cap
                factors['EARNINGS-TO-PRICE'][symbol] = earnings_to_price

                shares_oustanding:float = market_cap / price
                avg_vol_for_turnover:float = symbol_data.get_avg_vol_for_turnover()
                factors['TURNOVER'][symbol] = avg_vol_for_turnover / shares_oustanding

                operating_cashflow:float = float(cashflow_data['NCFOperateA'])
                factors['CF-TO-PRICE'][symbol] = operating_cashflow / market_cap

            symbol_data.reset_prices_and_volumes()

if self.recent_month != self.Time.month:
    self.Liquidate()
    self.recent_month = self.Time.month

    if len(factors[self.factors_identificators[0]]) >= self.quantile:
        for factor_identificator, factor_value_by_symbol in factors.items():
            quantile:int = int(len(factor_value_by_symbol) / self.quantile)
            sorted_by_factor_value:list[Symbol] = [x[0] for x in sorted(factor_value_by_symbol.items(), key=lambda item: item[1])]

            highest_quantile:list[Symbol] = sorted_by_factor_value[-quantile:]
            lowest_quantile:list[Symbol] = sorted_by_factor_value[:quantile]

            self.factors_data[factor_identificator].set_factor_quantiles(highest_quantile, lowest_quantile)

        factor_cumulative_perf:dict[str, float] = { identificator: factor_data.get_cumulative_perf() \
            for identificator, factor_data in self.factors_data.items() if factor_data.factor_monthly_perfs_ready() }
        
        factor_perf_median:float = np.median(list(factor_cumulative_perf.values()))

        long_leg:list[Symbol] = []
        short_leg:list[Symbol] = []

        for identificator, cumulative_perf in factor_cumulative_perf.items():
            if cumulative_perf > factor_perf_median:
                long_leg += list(factors[identificator].keys())
            else:
                short_leg += list(factors[identificator].keys())

        long_len:int = len(long_leg)
        short_len:int = len(short_leg)

        for symbol in long_leg:
            self.SetHoldings(symbol, 1 / long_len)

        for symbol in short_leg:
            self.SetHoldings(symbol, -1 / short_len)

def UpdateFactorsPerformances(self) -> None:
for identificator, factor_data in self.factors_data.items():
    if factor_data.factor_quantiles_ready():
        highest_quantile_perf:float = sum([self.data[symbol].get_performance() for symbol in factor_data.highest_quantile])
        lowest_quantile_perf:float = -sum([self.data[symbol].get_performance() for symbol in factor_data.lowest_quantile])

        factor_perf:float = highest_quantile_perf + lowest_quantile_perf

        factor_data.update_factor_monthly_perfs(factor_perf)

    else:
        factor_data.reset_monthly_perfs()

    factor_data.reset_factor_quantiles()

def AreDataStillComing(self, symbol:Symbol, max_missing_days:int) -> bool:
return (self.Time.date() - self.Securities[symbol].GetLastData().Time.date()).days <= max_missing_days