投资范围包括在上海和深圳证券交易所上市的A股股票,数据来源于CSMAR数据库。处置效应的估算基于两个回归模型,分别通过异常交易量代理和累积原始交易量指标进行分析。每年5月1日,股票根据市值中位数分为小市值和大市值组,再进一步分为有处置效应和无处置效应组。通过识别负的beta_1和正的beta_2,比较处置效应的“严重程度”,构建因子投资组合。策略为做多无处置效应的大小市值组合,做空有处置效应的大小市值组合,按市值加权并每年再平衡。

策略概述

投资范围包括在上海和深圳证券交易所上市的A股股票。数据来源于CSMAR数据库。处置效应的估算基于两个回归模型。首先,对于每只股票的每一天,股票的交易量由一个常数和市场所有股票的总交易量来解释。通过使用过去一年的观察数据进行回归,计算残差,这些残差是异常交易量的代理指标。其次,异常交易量通过一个常数和两个基于价格的累积原始交易量指标来解释。第一个累积原始交易量在t日计算,为前365天中价格高于t日实际价格的日交易量之和。第二个累积原始交易量在t日计算,为前365天中价格低于t日实际价格的日交易量之和。在回归中,我们可以将第一个累积交易量与beta_1系数关联,将第二个与beta_2系数关联。因此,负的beta_1或正的beta_2表明存在处置效应。每年5月1日,进行回归分析,并首先根据过去一年中市值中位数将股票分为小市值和大市值组。其次,将股票分为有处置效应组和无处置效应组。作者提到,“通过检查回归系数的符号进行排序”,但这一定义有些模糊。合理的排序方式是识别出beta_1为负的股票,并计算其绝对值,接着找到beta_2为正的股票,并将这些股票分组。然后,可以通过结合beta_1和beta_2系数的绝对值来比较处置效应的“严重程度”(当beta_1和beta_2的绝对值都为正时,可以取这两个值中的最大值)。作为一个因子投资组合,将股票基于结合beta的中位数进行划分。根据论文,做多无处置效应的大小市值组合,做空有处置效应的大小市值组合。该策略按市值加权,每年再平衡。

策略合理性

处置效应的理性解释非常容易理解。显然,投资者倾向于实现盈利,而不愿意承认亏损。虽然获利可能带来愉悦,但投资者不愿接受亏损,因为他们相信亏损最终会反转。然而,卖出表现良好的股票可能会减缓其上涨动能,而不愿意卖出亏损的股票则会减缓其价格下降。这意味着,受处置效应影响最大的股票容易出现错误定价,因为这些股票的价格偏离了其基本价值。根据该策略,这种偏离在长期内会得到修正,因此做空受处置效应影响最大的股票,做多受影响最小的股票是合理的。

论文来源

Behavioural Factors in China Stock Market [点击浏览原文]

<摘要>

基于三个行为偏差:过度自信、处置效应和羊群效应,我们构建了适用于中国股市的三个行为因子。与传统因子相比,行为因子能够提供增量解释能力并预测投资组合回报。为了适应显著的异常现象,我们的行为模型在表现上优于传统的因子模型和两个流行的新因子模型。本文为学术界和行业提供了研究中国股市行为方法的启示。

回测表现

年化收益率20.41%
波动率36.7%
Beta0.027
夏普比率0.56
索提诺比率N/A
最大回撤N/A
胜率59%

完整python代码

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)

Leave a Reply

Discover more from Quant Buffet

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

Continue reading