
Trade Chinese A-shares by seasonal return differences, going long on the top decile and short on the bottom, using value-weighted portfolios rebalanced monthly, excluding microcaps from the CSMAR database.
ASSET CLASS: stocks | REGION: China | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Seasonal Difference, China
I. STRATEGY IN A NUTSHELL
Invest in Chinese A-shares by sorting stocks monthly based on seasonal return differences (same-month minus other-month returns, using 5–20 years of historical data). Go long the top decile and short the bottom decile, with value-weighted portfolios rebalanced monthly.
II. ECONOMIC RATIONALE
Seasonal return patterns persist in Chinese stocks, similar to other markets. High same-month returns predict positive future returns, while other-month returns indicate negative trends. Retail-driven mispricing explains the anomaly, which is robust across state-owned and private firms and unaffected by limits to arbitrage.
III. SOURCE PAPER
Anomalies in the China A-share Market [Click to Open PDF]
Jansen, Maarten and Swinkels, Laurens and Zhou, Weili, Robeco Quantitative Investments, Erasmus University Rotterdam (EUR); Robeco Asset Management, Robeco Institutional Asset Management
<Abstract>
This paper sheds light on the similarities and differences with respect to the presence of anomalies in the China A-share market and other markets. To this end, we examine the existence of 32 anomalies in the China A-share market over the period 2000-2019. We find that value, risk, and trading anomalies carry over to China A-shares. Evidence for anomalies in the size, quality, and past return categories is substantially weaker, with the exception of a strong residual momentum and reversal effect. We document that most anomalies cannot be explained by industry composition, and are present among large, mid, and small capitalization stocks. We are the first to examine the existence of residual reversal, return seasonalities, and connected firm momentum for the China A-share market. We find strong out-of-sample evidence for the former two, but not the latter. Specific characteristics of the China A-share market, such as short-sale restrictions, the prevalence of state-owned enterprises, and the effect of stock market reforms, are examined in more detail. These features do not seem to be important drivers of our empirical findings.


IV. BACKTEST PERFORMANCE
| Annualised Return | 11.35% |
| Volatility | 17.06% |
| Beta | 0.025 |
| Sharpe Ratio | 0.66 |
| Sortino Ratio | -0.578 |
| Maximum Drawdown | N/A |
| Win Rate | 49% |
V. FULL PYTHON CODE
from AlgorithmImports import *
import numpy as np
from typing import List, Dict
from pandas.core.frame import dataframe
from pandas.core.series import Series
#endregion
class SeasonalDifferenceInChina(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100_000)
self.period: int = 5 * 21 # storing n months of daily prices for each month
self.quantile: int = 10
self.leverage: int = 10
self.min_share_price: float = 1.
self.market_cap_quantile: int = 3
self.traded_percentage: float = .1
self.data: Dict[Symbol, SymbolData] = {}
self.weight: Dict[Symbol, float] = {}
market: Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
self.selection_flag: bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(self.leverage)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
current_month: int = self.Time.month
# update the rolling window every day
for stock in fundamental:
symbol: Symbol = stock.Symbol
if symbol in self.data:
self.data[symbol].update(current_month, stock.AdjustedPrice)
# rebalace monthly
if not self.selection_flag:
return Universe.Unchanged
selected: List[Fundamental] = [
f for f in fundamental if f.HasFundamentalData
and f.MarketCap != 0
and f.Market == 'usa'
and f.CompanyReference.BusinessCountryID == 'CHN'
and f.Price >= self.min_share_price
]
# exclude 30% of lowest stocks by MarketCap
sorted_by_market_cap: List[Fundamental] = sorted(selected, key = lambda x: x.MarketCap)
selected = sorted_by_market_cap[int(len(sorted_by_market_cap) / self.market_cap_quantile):]
seasonal_difference: Dict[Fundamental, float] = {}
# calculate seasonal return for this month
for stock in fundamental:
symbol: Symbol = stock.Symbol
# Get stock's closes
if symbol not in self.data:
self.data[symbol] = SymbolData(self.period)
# five years of daily prices
history: dataframe = self.History(symbol, self.period * 12, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet")
continue
closes: Series = history.loc[symbol].close
for time, close in closes.items():
self.data[symbol].update(time.month, close)
if self.data[symbol].is_ready():
# calculate average return for each month separately
months_averages: Dict[int, float] = self.data[symbol].months_averages()
# get seasonal momentum of the month
same_month_avg_return: Dict[int, float] = months_averages[current_month]
# sum averages of all other seasonal returns
other_months_avg_return: float = sum([avg_return for month_num, avg_return in months_averages.items() if month_num != current_month])
# calculate seasonal diffrence for current stock
seasonal_difference[stock] = same_month_avg_return - other_months_avg_return
if len(seasonal_difference) < self.quantile:
return Universe.Unchanged
# selection based on seasonal difference
quantile: int = int(len(seasonal_difference) / self.quantile)
sorted_by_seasonal_diff: List[Fundamental] = sorted(seasonal_difference, key=seasonal_difference.get)
# long the top decile and short the bottom decile
long: List[Fundamental] = sorted_by_seasonal_diff[-quantile:]
short: List[Fundamental] = sorted_by_seasonal_diff[:quantile]
# calculate weights
for i, portfolio in enumerate([long, short]):
mc_sum: float = sum(map(lambda x: x.MarketCap, portfolio))
for stock in portfolio:
self.weight[stock.Symbol] = (((-1) ** i) * stock.MarketCap / mc_sum) * self.traded_percentage
return list(self.weight.keys())
def OnData(self, slice: Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
# trade execution
portfolio: List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if slice.contains_key(symbol) and slice[symbol]]
self.SetHoldings(portfolio, True)
self.weight.clear()
def Selection(self) -> None:
self.selection_flag = True
class SymbolData():
def __init__(self, period: int) -> None:
self.daily_prices: Dict[int, RollingWindow] = {}
for i in range(1, 13):
# Storing daily prices under month number
self.daily_prices[i] = RollingWindow[float](period)
def update(self, month: int , close: float) -> None:
self.daily_prices[month].Add(close)
def is_ready(self) -> bool:
for _, rolling_window in self.daily_prices.items():
# Return False if atleast one rolling window in dictionary isn't ready
if not rolling_window.IsReady:
return False
# If all rolling windows are ready return True
return True
def months_averages(self) -> Dict[int, float]:
averages: Dict[int, float] = {}
for month_num, rolling_window in self.daily_prices.items():
month_performances: List[float] = []
closes: List[float] = list(rolling_window)
# calculate month performacnes
for i in range(0, len(closes), 21):
month_closes: List[float] = closes[i:i+21]
performance: float = (month_closes[0] - month_closes[-1]) / month_closes[-1]
month_performances.append(performance)
averages[month_num] = np.mean(month_performances)
return averages
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))