Chinese stocks’ momentum portfolios are formed using six-month returns, skipping a month to reduce biases. Value-weighted portfolios are rebalanced monthly, holding six overlapping portfolios for six months to capture momentum.

I. STRATEGY IN A NUTSHELL

The strategy trades Chinese stocks from the CSMAR database (USD values), excluding the smallest 5% by market capitalization. Momentum portfolios are constructed using past six-month returns, held for six months, and ranked monthly into low, medium, and high categories. A one-month gap is applied between ranking and holding to reduce biases. The momentum portfolio goes long on winners and short on losers, using value-weighted, overlapping holdings, ensuring six simultaneous portfolios. Monthly rebalancing captures the momentum effect efficiently.

II. ECONOMIC RATIONALE

Momentum arises when recent winners continue to outperform, driven by both risk and behavioral factors. Behavioral explanations—overconfidence and self-attribution—cause investors to overreact to good news and underreact to bad news. In China, A-shares are dominated by retail investors, prone to noise trading and reversals, whereas B-shares have more sophisticated domestic and foreign investors who underreact to information, creating momentum. Foreign ownership quotas and currency restrictions reinforce investor segmentation, explaining stronger reversals in A-shares and stronger momentum in B-shares, consistent with observed market behavior.

III. SOURCE PAPER

Momentum, Reversals, and Investor Clientele[Click to Open PDF]

Andy C.W. Chui, A. Subrahmanyam and Sheridan Titman.Hong Kong Polytechnic University.University of California, Los Angeles (UCLA) – Finance Area; Financial Research Network (FIRN).University of Texas at Austin – Department of Finance; National Bureau of Economic Research (NBER).

<Abstract>

Different share classes on the same firms provide a natural experiment to explore how investor clienteles affect momentum and short-term reversals. Domestic retail investors have a greater presence in Chinese A shares, and foreign institutions are relatively more prevalent in B shares. These differences result from currency conversion restrictions and mandated investment quotas. We find that only B shares exhibit momentum and earnings drift, and only A shares exhibit monthly reversals. Institutional ownership strengthens momentum in B shares. These patterns arise in a model with informed investors who underreact to fundamental signals, and retail investors who trade on noise.

BACKTEST PERFORMANCE

Annualised Return14.72%
Volatility16.59%
Beta-0.012
Sharpe Ratio0.89
Sortino RatioN/A
Maximum DrawdownN/A
Win Rate52%

V. FULL PYTHON CODE

from AlgorithmImports import *
import data_tools
class MomentumeffectinChineseBshares(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)
        
        # chinese stock universe
        self.top_size_symbol_count:int = 300
        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.period:int = 21 * 7    # Storing 7 months of daily closes
        self.skip_period:int = 21   # Skip this period in performance calculation
        self.quantile:int = 3
        # trenching
        self.managed_queue:List[RebalanceQueueItem] = []
        self.holding_period:int = 6             # months
        self.value_weighted:bool = True         # True - value weighted; False - equally weighted
        self.data:dict[str, data_tools.SymbolData] = {}    # symbol data
        self.leverage:int = 5
        self.SetWarmUp(self.period, Resolution.Daily)
        for t in self.tickers:
            data = self.AddData(data_tools.ChineseStocks, t, Resolution.Daily)
            data.SetFeeModel(data_tools.CustomFeeModel())
            data.SetLeverage(self.leverage)
            self.data[data.Symbol] = data_tools.SymbolData(self.period)
        
        self.recent_month:int = -1
    def OnData(self, data: Slice):
        performances:dict[Symbol, bool] = {}
        # store daily data
        for symbol, symbol_data in self.data.items():
            if data.ContainsKey(symbol):
                price_data:dict[str, str] = data[symbol].GetProperty('price_data')
                # valid price data
                if data[symbol].Value != 0. and price_data:
                    # update price and market cap
                    close:float = float(data[symbol].Value)
                    symbol_data.update_price(close)
                    mc:float = float(price_data['marketValue'])
                    symbol_data.update_market_cap(mc)
                    if symbol_data.is_ready():
                        if self.recent_month != self.Time.month and not self.IsWarmingUp:
                            mc:float = symbol_data.recent_market_cap()
                            if mc != 0:
                                performances[symbol] = symbol_data.performance(self.skip_period)
        # rebalance monthly
        if self.recent_month == self.Time.month:
            return
        self.recent_month = self.Time.month
        if self.IsWarmingUp:
            return
        long:List[Symbol] = []
        short:List[Symbol] = []
        if len(performances) >= self.quantile:
            # sort by performance
            tercile:int = int(len(performances) / self.quantile)
            sorted_by_perf:List[Symbol] = [x[0] for x in sorted(performances.items(), key=lambda item: item[1])]
        
            long = sorted_by_perf[-tercile:]
            short = sorted_by_perf[:tercile]
        
        if long and short:
            # calculate quantities for long and short trenche
            if self.value_weighted:
                total_market_cap_long:float = sum([self.data[x].recent_market_cap() for x in long])
                total_market_cap_short:float = sum([self.data[x].recent_market_cap() for x in short])
                
                long_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period
                short_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period
                
                long_symbol_q:List[tuple[Symbol, float]] = [(x, np.floor(long_w * (self.data[x].recent_market_cap() / total_market_cap_long) / data[x].Value)) for x in long]
                short_symbol_q:List[tuple[Symbol, float]] = [(x, -np.floor(short_w * (self.data[x].recent_market_cap() / total_market_cap_short) / data[x].Value)) for x in short]
                
                self.managed_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))
            else:
                long_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(long)
                short_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(short)
                
                long_symbol_q:List[tuple[Symbol, float]] = [(x, np.floor(long_w / data[x].Value)) for x in long]
                short_symbol_q:List[tuple[Symbol, float]] = [(x, -np.floor(short_w / data[x].Value)) for x in short]
                
                self.managed_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))
        
        # trade execution - rebalance portfolio
        remove_item:RebalanceQueueItem|None = None
        for item in self.managed_queue:
            # liquidate
            if item.holding_period == self.holding_period: # all portfolio parts are held for n months
                for symbol, quantity in item.opened_symbol_q:
                    self.MarketOrder(symbol, -quantity)
                            
                remove_item = item
            
            # trade execution    
            if item.holding_period == 0: # all portfolio parts are held for n months
                opened_symbol_q:List[tuple[Symbol, float]] = []
                
                for symbol, quantity in item.opened_symbol_q:
                    self.MarketOrder(symbol, quantity)
                    opened_symbol_q.append((symbol, quantity))
                            
                # only opened orders will be closed        
                item.opened_symbol_q = opened_symbol_q
                
            item.holding_period += 1
            
        # need to remove closed part of portfolio after loop. Otherwise it will miss one item in self.managed_queue
        if remove_item:
            self.managed_queue.remove(remove_item)
class RebalanceQueueItem():
    def __init__(self, symbol_q:List):
        # symbol/quantity collections
        self.opened_symbol_q:List[tuple[Symbol, float]] = symbol_q  
        self.holding_period:int = 0

Leave a Reply

Discover more from Quant Buffet

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

Continue reading