Trade Chinese A-shares based on idiosyncratic asymmetry (IE), going long on stocks with low IE and short on stocks with high IE, with value-weighted portfolios rebalanced monthly.

I. STRATEGY IN A NUTSHELL

Long low idiosyncratic asymmetry (IE) stocks, short high IE stocks in Chinese A-shares. Monthly value-weighted rebalancing.

II. ECONOMIC RATIONALE

Investors overreact to stocks with high extreme-profit potential (high IE), causing mispricing. Low IE stocks are undervalued and yield higher returns, so the strategy exploits this behavioral bias.

III. SOURCE PAPER

Stock Return Asymmetry in China [Click to Open PDF]

Ke Wu, School of Finance, Renmin University of China; Yifeng Zhu, School of Finance, Renmin University of China; Dongxu Chen, School of Finance, Central University of Finance and Economics

<Abstract>

In this study, we find that the upside asymmetry calculated based on a new distribution-based asymmetry measure proposed by Jiang et al. (2020) is negatively related to average future returns in the cross-section of Chinese stock returns. Conversely, when using the conventional skewness measure, the relationship between asymmetry and the average returns is unclear. Furthermore, an asymmetry factor constructed from the new asymmetry measure cannot be explained by the three- (CH-3) or four-factor (CH-4) models proposed by Liu, Stambaugh, and Yuan (2019). When augmenting the CH-3 model with our asymmetry factor, the augmented four-factor model can explain 32 anomalies out of a universe of 37 significant anomalies in the Chinese stock market, outperforming both the CH-3 and CH-4 models.

IV. BACKTEST PERFORMANCE

Annualised Return10.2%
Volatility15.75%
Beta-0.122
Sharpe Ratio0.65
Sortino Ratio0.183
Maximum DrawdownN/A
Win Rate50%

V. FULL PYTHON CODE

from AlgorithmImports import *
import statsmodels.api as sm
from typing import List, Dict
#endregion
class IdiosyncraticAsymmetryFactorInChina(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100_000)
        
        self.leverage: int = 10
        self.market_cap_quantile: int = 3
        self.quantile: int = 5 # 10 = decile selection, 5 = quintile selection, ...
        self.regression_period: int = 6 * 21 + 1 # need n daily prices
        self.data: Dict[Symbol, SymbolData] = {}
        self.weights: Dict[Symbol, float] = {}
        
        self.market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        # warm up SPY prices    
        self.PerformHistory(self.market)
        
        self.selection_flag: bool = False
        self.UniverseSettings.Leverage = self.leverage
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthStart(self.market), self.TimeRules.BeforeMarketClose(self.market, 0), self.Selection)
        self.settings.daily_precise_end_time = False
    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # update daily closes
        for stock in fundamental:
            symbol: Symbol = stock.Symbol
            
            if symbol in self.data:
                # update daily closes
                self.data[symbol].update_closes(stock.AdjustedPrice)
        
        # monthly rebalance
        if not self.selection_flag:
            return Universe.Unchanged
        
        # filter stocks, which symbols isn't SPY equity symbol and has fundamental data
        selected: List[Fundamental] = [
            x for x in fundamental 
            if x.HasFundamentalData 
            and x.MarketCap != 0 
            and x.CompanyReference.BusinessCountryID == 'CHN'
        ]
        
        if not self.data[self.market].are_closes_ready():
            return Universe.Unchanged
            
        regression_x: List[np.ndarray] = [
            self.data[self.market].daily_returns(),
            self.data[self.market].daily_returns_squared()
        ]
        
        # 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):]
        
        IE: Dict[Symbol, float] = {} # storing stocks IE values keyed by stocks symbols
        market_cap: Dict[Symbol, float] = {} # storing stocks market capitalization keyed by stocks symbols
        
        for stock in selected:
            symbol: Symbol = stock.Symbol
            
            # warm up stock prices
            if symbol not in self.data:
                self.PerformHistory(symbol)
            
            # check if closes data are ready
            if not self.data[symbol].are_closes_ready():
                continue
            
            # perform regression
            regression_y: np.ndarray = self.data[symbol].daily_returns()
            
            regression_model = self.MultipleLinearRegression(regression_x, regression_y)
            
            # retrieve all residuals from regression
            daily_residuals: np.ndarray = regression_model.resid
            
            # calcualte IE value from stock daily residuals
            IE_value: float = self.data[symbol].calculate_IE(daily_residuals)
            
            # store stock's IE value keyed by stock's symbol
            IE[symbol] = IE_value
            
            # store stock's market capitalization for value weighting
            market_cap[symbol] = stock.MarketCap
            
        # make sure, there are enough stocks for selection
        if len(IE) < (self.quantile * 2):
            return Universe.Unchanged
        
        # perform selection
        quantile: int = int(len(IE) / self.quantile)
        sorted_by_IE: List[Symbol] = [x[0] for x in sorted(IE.items(), key=lambda item: item[1])]
        # long stocks with low IE values
        long: List[Symbol] = sorted_by_IE[:quantile]
        
        # short stocks with high IE values
        short: List[Symbol] = sorted_by_IE[-quantile:]
        
        for i, portfolio in enumerate([long, short]):
            mc_sum: float = sum(list(map(lambda symbol: market_cap[symbol], portfolio)))
            for symbol in portfolio:
                self.weights[symbol] = ((-1)**i) * market_cap[symbol] / mc_sum
        return list(self.weights.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.weights.items() if slice.contains_key(symbol) and slice[symbol]]
        self.SetHoldings(portfolio, True)
        self.weights.clear()
        
    def PerformHistory(self, symbol: Symbol) -> None:
        ''' warm up stock prices from History object based on symbol parameter '''
        
        self.data[symbol] = SymbolData(self.regression_period)
        history: dataframe = self.History(symbol, self.regression_period, Resolution.Daily)
        
        # make sure history isn't empty
        if history.empty:
            return
        
        closes: Series = history.loc[symbol].close
        for time, close in closes.items():
            self.data[symbol].update_closes(close)
            
    def MultipleLinearRegression(self, x: np.ndarray, y: np.ndarray):
        ''' perform multiple regression and return regression model '''
        
        x: np.ndarray = np.array(x).T
        x = sm.add_constant(x)
        result = sm.OLS(endog=y, exog=x).fit()
        return result
        
    def Selection(self) -> None:
        self.selection_flag = True
        
class SymbolData():
    def __init__(self, regression_period: int) -> None:
        self.closes: RollingWindow = RollingWindow[float](regression_period)
        
    def update_closes(self, close: float) -> None:
        self.closes.Add(close)
        
    def are_closes_ready(self) -> bool:
        return self.closes.IsReady
        
    def daily_returns(self) -> np.ndarray:
        # calculate daily returns for period t-6 to t-1 months
        closes = np.array([x for x in self.closes])[21:]
        daily_returns = (closes[:-1] - closes[1:]) / closes[1:]
        return daily_returns
        
    def daily_returns_squared(self) -> np.ndarray:
        # calculate daily returns for period t-6 to t-1 months
        closes = np.array([x for x in self.closes])[21:]
        daily_returns = (closes[:-1] - closes[1:]) / closes[1:]
        daily_returns_squared = daily_returns**2
        return daily_returns_squared
        
    def calculate_IE(self, residuals: np.ndarray) -> int:
        average_residuals: float = np.average(residuals)
        two_residuals_std: float = 2 * np.std(residuals)
        
        avg_plus_two_std: float = average_residuals + two_residuals_std
        avg_minus_two_std: float = average_residuals - two_residuals_std
        
        over_avg_plus_two_std: int = 0 # counting number of residuals, which were over avg_plus_two_std
        under_avg_minus_two_std: int = 0 # counting number of residuals, which were under avg_minus_two_std
        
        for residual in residuals:
            if residual > avg_plus_two_std:
                over_avg_plus_two_std += 1
            elif residual < avg_minus_two_std:
                under_avg_minus_two_std += 1
                
        IE_value: int = over_avg_plus_two_std - under_avg_minus_two_std
        
        return IE_value
        
# 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"))

VI. Backtest Performance

Leave a Reply

Discover more from Quant Buffet

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

Continue reading