Quant BuffetRelax, Not Over Thinking

Skewness factor in Chinese Equities

Log in to collect

Academic paper

Market Volatility and Skewness Risks in China

AuthorsFang Zhen

Institute
  • Central University of Finance and Economics
  • ?Central University of Finance and Economics (CUFE) - China Economics and Management Academy

Strategy in a nutshell

Long stocks with high exposure to skewness innovations and short those with low exposure, using monthly value-weighted decile portfolios.

Economic rationale

Stocks sensitive to skewness innovations benefit from leverage effects, short-term reversals, and lottery demand, providing compensation for losses in bear markets.

Backtest performance

Annualised return22.58%
Volatility23.03%
Beta-0.197
Sharpe ratio0.98
Win rate49%

Full Python code

from AlgorithmImports import *
import data_tools
from statsmodels.tsa.arima.model import ARIMA
import numpy as np
from typing import List, Dict
# endregion

class SkewnessfactorinChineseEquities(QCAlgorithm):

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

self.quantile:int = 10
self.leverage:int = 10
self.period:int = 12 * 21
self.max_missing_days:int = 7
self.SetWarmUp(self.period, Resolution.Daily)

# market cap filtering
self.exclusion_flag:bool = True
self.market_cap_cutoff:float = .5

self.data:dict[str, data_tools.SymbolData] = {}
self.weights:dict[Symbol, float] = {}

# cboe data
self.cboe_skewness:Symbol = self.AddData(data_tools.CBOEData, 'CBOE_skew', Resolution.Daily).Symbol
self.cboe_skewness_data:RollingWindow = RollingWindow[float](self.period)

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

self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(self.spy), self.TimeRules.AfterMarketOpen(self.spy), self.Selection)

def OnSecuritiesChanged(self, changes:SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(data_tools.CustomFeeModel())
    security.SetLeverage(self.leverage)

def CoarseSelectionFunction(self, coarse:List[CoarseFundamental]) -> List[Symbol]:
if not self.selection_flag:
    return Universe.Unchanged

if not self.cboe_skewness_data.IsReady:
    return Universe.Unchanged
else:
    # custom data is still comming in
    if self.Securities[self.cboe_skewness].GetLastData() and (self.Time.date() - self.Securities[self.cboe_skewness].GetLastData().Time.date()).days > self.max_missing_days:
        return Universe.Unchanged

selected:List[Symbol] = [x.Symbol for x in coarse if x.HasFundamentalData and x.Price > 5]

return selected

def FineSelectionFunction(self, fine:List[FineFundamental]) -> List[Symbol]:
# filter chinese stocks by BusinessCountryID 
fine:List[FineFundamental] = list(filter(lambda stock: stock.MarketCap != 0 and stock.CompanyReference.BusinessCountryID == 'CHN', fine))

if self.exclusion_flag:
    # exclude 30% of lowest stocks by MarketCap
    sorted_by_market_cap:List[FineFundamental] = sorted(fine, key = lambda x: x.MarketCap)
    fine = sorted_by_market_cap[int(len(sorted_by_market_cap) * self.market_cap_cutoff):]

# calculate skewness beta
skew_beta:Dict[FineFundamental, float] = {}

# skewness innovations
skewness:np.ndarray = np.array(list(self.cboe_skewness_data))
arma_model = ARIMA(skewness, order=(1,0,1))
model_fit = arma_model.fit()
innovations:np.ndarray = model_fit.resid[::-1]

for stock in fine:
    symbol:Symbol = stock.Symbol
    market_cap:float = stock.MarketCap

    history:DF = self.History(symbol, self.period, Resolution.Daily)
    if history.empty or history.loc[symbol].close.size != self.period:
        continue

    closes:np.ndarray = history['close'].values
    stock_returns:np.array = (closes[1:] - closes[:-1]) / closes[:-1]

    model_fit = data_tools.multiple_linear_regression(innovations[2:], stock_returns[:-1])
    skew_beta[stock] = model_fit.params[1]

if len(skew_beta) < self.quantile:
    return Universe.Unchanged

quantile:int = int(len(skew_beta) / self.quantile)
sorted_skew_beta:List[FineFundamental] = [x[0] for x in sorted(skew_beta.items(), key=lambda item: item[1], reverse=True)]
long:List[FineFundamental] = sorted_skew_beta[:quantile]
short:List[FineFundamental] = sorted_skew_beta[-quantile:]

total_long_cap:float = sum(list(map(lambda stock: stock.MarketCap, long)))
for stock in long:
    self.weights[stock.Symbol] = stock.MarketCap / total_long_cap

total_short_cap:float = sum(list(map(lambda stock: stock.MarketCap, short)))
for stock in short:
    self.weights[stock.Symbol] = -stock.MarketCap / total_short_cap

return list(self.weights.keys())

def OnData(self, data: Slice) -> None:
# store CBOE data
if self.cboe_skewness in data and data[self.cboe_skewness]:
    close:float = data[self.cboe_skewness].Value
    self.cboe_skewness_data.Add(close)

if self.IsWarmingUp:
    return

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

# trade execution
stocks_invested:List[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested]
for symbol in stocks_invested:
    if symbol not in self.weights:
        self.Liquidate(symbol)

for symbol, w in self.weights.items():
    if symbol in data and data[symbol]:
        self.SetHoldings(symbol, w)

self.weights.clear()

def Selection(self):
self.selection_flag = True