Quant BuffetRelax, Not Over Thinking

Disposition Effect in China

Log in to collect

Academic paper

Behavioural Factors in China Stock Market

AuthorsJinpeng Liu

Institute
  • Southwestern University of Finance and Economics
  • ?Southwestern University of Finance and Economics (SWUFE) - China Center for Behavior Economics and Finance

Strategy in a nutshell

The strategy targets A-share stocks listed on the Shanghai and Shenzhen stock markets. Using daily trading volume and price data from the CSMAR database, it estimates the disposition effect through regressions of abnormal trading volume. Stocks are first sorted by size (small vs. big) and then by their disposition effect measure. Stocks with the highest disposition effect are shorted, while those with minimal disposition effect are taken long. The portfolio is value-weighted and rebalanced yearly, aiming to exploit long-term mispricing caused by investors’ tendency to realize gains prematurely and hold onto losses.

Economic rationale

The disposition effect arises because investors are eager to take profits but reluctant to realize losses, causing upward momentum in winners to slow and downward momentum in losers to be suppressed. Stocks most affected by this behavioral bias deviate from their fundamental values, creating mispricing opportunities. By shorting stocks with pronounced disposition effects and going long on minimally affected stocks, the strategy aims to capture the correction toward fundamental value over time.

Backtest performance

Annualised return20.41%
Volatility36.7%
Beta0.027
Sharpe ratio0.56
Win rate59%

Full Python code

from AlgorithmImports import *
from data_tools import SymbolData, CustomFeeModel, ChineseStocks, MultipleLinearRegression
import numpy as np
# endregion

class DispositionEffectinChina(QCAlgorithm):

def Initialize(self):
self.SetStartDate(2015, 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 = 365                       # daily period
self.data:dict[str, SymbolData] = {}    # symbol data
self.max_missing_days:int = 5           # max missing n of price data entries in a row for custom data
self.value_weighted:bool = True         # True - value weighted; False - equally weighted
self.min_symbols:int = 2
self.leverage:int = 5
self.rebalance_month:int = 5            # May rebalance

self.SetWarmUp(self.period, Resolution.Daily)

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

    self.data[data.Symbol] = SymbolData(self.period)

self.recent_month:int = -1

def OnData(self, data: Slice):
total_trading_volume:float = 0
included_symbols:List[Symbol] = []
disposition: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)
            volume:float = float(price_data['turnoverVol'])
            
            included_symbols.append(symbol)
            total_trading_volume += volume
            
            symbol_data.update_price(close)
            symbol_data.update_volume(volume)

            mc:float = float(price_data['marketValue'])
            symbol_data.update_market_cap(mc)

            # update second regression variable
            if symbol_data.is_ready():
                symbol_data.update_disposition_regression_x()

                if self.recent_month != self.Time.month and self.Time.month == self.rebalance_month and not self.IsWarmingUp:
                    if symbol_data.cumulative_raw_volume_is_ready():
                        # first regression - abnormal trading volume
                        regr_data:np.ndarray = symbol_data.get_abnormal_trading_volume_regression_data()
                        ATV_regression_model = MultipleLinearRegression(regr_data[0], regr_data[1])
                        abnormal_trading_volume:np.ndarray = ATV_regression_model.resid

                        # second regression - disposition
                        regr_data:np.ndarray = symbol_data.get_disposition_regression_data()
                        disposition_regression_model = MultipleLinearRegression(regr_data, abnormal_trading_volume.T)

                        # negative beta_1 or positive beta_2 indicates a disposition effect
                        if disposition_regression_model.params[1] < 0 or disposition_regression_model.params[2] > 0:
                            disposition[symbol] = True
                        else:
                            disposition[symbol] = False

# update total market volume for included stocks in todays data structure
for symbol in included_symbols:
    self.data[symbol].update_total_market_volume(total_trading_volume)

# rebalance yearly
if self.recent_month == self.Time.month:
    return
self.recent_month = self.Time.month
if self.Time.month != self.rebalance_month:
    return

if self.IsWarmingUp:
    return

long:List[str] = []
short:List[str] = []

if len(disposition) >= self.min_symbols:
    # according to the paper, long the no disposition small and big size portfolios and short the disposition small and big size portfolios
    long = [symbol for symbol, disp in disposition.items() if disp == False]
    short = [symbol for symbol, disp in disposition.items() if disp == True]

# weight calculation
weights_to_trade = {}

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])

    for symbol in long:
        if symbol in data and data[symbol]:
            weights_to_trade[symbol] = self.data[symbol].recent_market_cap() / total_market_cap_long
    for symbol in short:
        if symbol in data and data[symbol]:
            weights_to_trade[symbol] = -self.data[symbol].recent_market_cap() / total_market_cap_short
else:
    long_c:int = len(long)
    short_c:int = len(short)

    for symbol in long:
        if symbol in data and data[symbol]:
            weights_to_trade[symbol] = 1 / long_c
    for symbol in short:
        if symbol in data and data[symbol]:
            weights_to_trade[symbol] = -1 / short_c

# trade execution
invested = [x.Key for x in self.Portfolio if x.Value.Invested]
for symbol in invested:
    if symbol not in weights_to_trade:
        self.Liquidate(symbol)

for symbol, w in weights_to_trade.items():
    self.SetHoldings(symbol, w)