
“分析A股股票时,根据36个月波动率将其分为十分位。做多波动率最低的十分位,做空波动率最高的十分位,使用价值加权投资组合每月重新平衡,排除微盘股并要求有效的市值数据。”
资产类别: 股票 | 地区: 中国 | 周期: 每月 | 市场: 股票 | 关键词: 波动率、中国、A股
I. 策略概要
投资范围包括在MSCI中国A股境内指数和境内可投资市场指数中上市的深交所和上交所A股股票,不包括微盘股。股票必须具有有效的市值数据和36个月的月度回报观测值才能计算波动率。每个月,股票根据其过去36个月的波动率(以月度总回报的标准差衡量)分为十分位。最高十分位包括波动率最高的股票,而最低十分位包括波动率最低的股票。该策略做多波动率最低的十分位,做空波动率最高的十分位,使用价值加权投资组合每月重新平衡。
II. 策略合理性
该论文证实,波动性是低风险异象的主要驱动因素,在短期(1个月)和长期(5年)波动性估计期、跨行业和跨时间都具有一致的影响。Fama-French风格的VOL因子揭示了比其他Fama-French-Carhart模型因子更高的风险调整溢价。在中国,波动性效应作为一种独特的异象出现,与规模或价值因子无关,也与其他市场的波动性效应不相关,这与统一的基于风险的解释相悖。中国独特的政治经济环境,其特点是外国投资者准入受限和私人投资者主导,表明波动性效应存在多种驱动因素。私人投资者容易出现行为偏差或为具有彩票式特征的风险股票支付过高的价格,其目标是类似于机构投资者的相对业绩目标。这些发现突显了行为因素和市场结构对中国波动性溢价的影响,这与美国等制度化市场不同。
III. 来源论文
The Volatility Effect in China [点击查看论文]
- David Blitz 和 Matthias X. Hanauer 和 Pim van Vliet。Robeco 量化投资。慕尼黑工业大学 (TUM);Robeco 机构资产管理。Robeco 量化投资
<摘要>
本文表明,在中国A股市场,低风险股票的表现明显优于高风险股票。这种低风险异常现象的主要驱动因素是波动性,而不是贝塔。Fama-French风格的VOL因子无法用Fama-French-Carhart因子解释,并且在所有这些因子中具有最强的独立表现。我们的发现跨行业和跨时间都具有稳健性,并且与之前美国和国际市场的实证证据一致。此外,VOL溢价表现出卓越的可投资性特征,因为它涉及较低的换手率,并且仅应用于最大和流动性最强的股票时仍然强劲。我们的结果意味着波动性效应是一种高度普遍的现象,并且解释应该能够解释其在高度机构化市场(如美国)以及私人投资者主导交易的中国市场中的存在。


IV. 回测表现
| 年化回报 | 10.6% |
| 波动率 | 21.2% |
| β值 | -0.158 |
| 夏普比率 | 0.61 |
| 索提诺比率 | N/A |
| 最大回撤 | N/A |
| 胜率 | 51% |
V. 完整的 Python 代码
from AlgorithmImports import *
from data_tools import SymbolData, CustomFeeModel, ChineseStocks
import numpy as np
# endregion
class VolatilityEffectInTheChineseAShareMarket(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)
self.data:dict[Symbol, SymbolData] = {}
self.traded_weights:dict[Symbol, float] = {}
self.value_weighted:bool = False # True - value weighted; False - equally weighted
self.period:int = 12 * 21 * 3 # Need three years of daily returns
self.quantile:int = 10
self.leverage:int = 5
self.symbol: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.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)
def OnSecuritiesChanged(self, changes) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(self.leverage)
def CoarseSelectionFunction(self, coarse:List[CoarseFundamental]) -> List[Symbol]:
for stock in coarse:
symbol:Symbol = stock.Symbol
# updating RollingWindow every day
if symbol in self.data:
self.data[symbol].update(stock.AdjustedPrice)
if not self.selection_flag:
return Universe.Unchanged
selected:List[Symbol] = [x.Symbol for x in coarse if x.HasFundamentalData and x.Symbol.Value not in ['SNP', 'KBSF', 'LLL']]
return selected
def FineSelectionFunction(self, fine:List[FineFundamental]) -> List[Symbol]:
# filter only chinese stocks with market capitalization
fine = [x for x in fine if x.CompanyReference.BusinessCountryID == 'CHN' and x.MarketCap != 0]
# exclude 80% of lowest stocks by MarketCap
sorted_by_market_cap:List = sorted(fine, key = lambda x: x.MarketCap)
top_by_market_cap:List[FineFundamental] = sorted_by_market_cap[int(len(sorted_by_market_cap) * 0.8):]
volatility:dict[Symbol, float] = {}
for stock in fine:
symbol = stock.Symbol
# NOTE: Due to time and memory complexity we put history in FineSelectionFunction.
# We can filter chinese stocks only in this function.
if symbol not in self.data:
self.data[symbol] = SymbolData(self.period)
history:pd.dataframe = self.History(symbol, self.period, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet")
continue
closes = history.loc[symbol].close
for time, close in closes.iteritems():
self.data[symbol].update(close)
if self.data[symbol].is_ready():
volatility[stock] = self.data[symbol].volatility()
long:List[FineFundamental] = []
short:List[FineFundamental] = []
if len(volatility) >= self.quantile:
quantile:int = int(len(volatility) / self.quantile)
sorted_by_volatility:List[FineFundamental] = [x[0] for x in sorted(volatility.items(), key = lambda item: item[1])]
# The strategy takes a long position in the lowest decile and a short position in portfolios within the highest decile.
long = sorted_by_volatility[:quantile]
short = sorted_by_volatility[-quantile:]
# weight calculation
if self.value_weighted:
total_market_cap_long:float = sum([x.MarketCap for x in long])
total_market_cap_short:float = sum([x.MarketCap for x in short])
for stock in long:
self.traded_weights[stock.Symbol] = stock.MarketCap / total_market_cap_long
for stock in short:
self.traded_weights[stock.Symbol] = -stock.MarketCap / total_market_cap_short
else:
long_c:int = len(long)
short_c:int = len(short)
for stock in long:
self.traded_weights[stock.Symbol] = 1 / long_c
for stock in short:
self.traded_weights[stock.Symbol] = -1 / short_c
return list(self.traded_weights.keys())
def OnData(self, data:Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
# trade execution
stocks_invested = [x.Key for x in self.Portfolio if x.Value.Invested]
for symbol in stocks_invested:
if symbol not in self.traded_weights:
self.Liquidate(symbol)
for symbol, w in self.traded_weights.items():
self.SetHoldings(symbol, w)
self.traded_weights.clear()
def Selection(self):
self.selection_flag = True
class SymbolData():
def __init__(self, period):
self.price = RollingWindow[float](period)
def update(self, value):
self.price.Add(value)
def is_ready(self) -> bool:
return self.price.IsReady
def volatility(self) -> float:
closes = [x for x in self.price]
# Monthly volatility calc.
separete_months = [closes[x:x+21] for x in range(0, len(closes), 21)]
monthly_returns = [(x[0] - x[-1]) / x[-1] for x in separete_months]
return np.std(monthly_returns)
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))