Quant Buffet放轻松,别过度思虑

由交易者承诺报告信息预测的股票横截面收益

登录后收藏

学术论文

Is There Smart Money? How Information in the Futures Market Gets Priced into the Cross-Section of Stock Returns with Delay

作者Steven Wei Ho; Alexandre R. Lauwers

机构
  • Columbia University
  • ?Columbia University, Graduate School of Arts and Sciences, Department of Economics
  • CHUniversity of Geneva
  • CHGraduate Institute of International and Development Studies
  • ?University of Geneva - Graduate Institute, Geneva (IHEID)

策略概要

该策略利用商品期货交易委员会(CFTC)的交易商持仓报告 (COT) 数据,交易与11种商品(例如,金属、能源和软商品)相关的纽约证券交易所、美国证券交易所和纳斯达克普通股。对于每个与四位SIC代码相关联的商品,识别出具有匹配代码的上市公司,从而形成11个商品特定投资组合。

分类COT(DCOT)报告提供每周交易商持仓数据。多头比例增长指标计算为管理资金(MM)交易商多头头寸增长率除以总头寸(MMlong/(MMlong+MMshort+2MMspreading))。股票每周根据滞后信号变量被分类为多空投资组合。对于具有正信号增长率的股票采取多头头寸,而空头头寸则针对负信号增长率的股票。

该策略使用等权重投资组合,但也允许其他加权方案,例如价值加权或度加权组合。投资组合每周根据更新的COT信号重新平衡,利用交易商头寸趋势来预测商品相关股票的表现。

II. 策略合理性

DCOT报告显示,管理资金(MM)交易者在商品期货中的头寸为股票回报提供了有价值的预测信号。MM交易者以其投机策略、杠杆和市场洞察力而闻名,比生产商(PM)更能有效地预测股票回报的横截面,后者头寸缺乏预测能力。MM头寸反映了对未来商品价格的看法,这与生产商股票的价格相关。该策略从MM多头/空头头寸中产生了经济和统计上显著的阿尔法。这些结果在各种衡量标准、加权方案、时间滞后和商业周期中均保持稳健。此外,多变量回归证实,MM信号独立于市场敞口、规模或动量等传统预测因子,能够预测股票回报。

回测表现

波动率28.57%
夏普比率0.67
索提诺比率0.042
胜率52%

完整 Python 代码

from AlgorithmImports import *
from functools import reduce
from typing import List, Dict, Tuple
from numpy import isnan
class CrossSectionOfStockReturnsPredictedByCommitmentOfTradersInformation(QCAlgorithm):
def Initialize(self):
 self.SetStartDate(2005, 1, 1)
 self.SetCash(100000)
 
 self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']	
 
 self.min_share_price:int = 5
 self.leverage:int = 5
 self.SIC_stocks = {}    # storing list of stocks symbols keyed by SIC code
 
 self.COT_tickers_SICs:List[Tuple[List]] = [
     (['QHG'], [1020, 1021, 3331]), # Copper
     (['QGC'], [1040, 1041]), # Gold
     (['QSI'], [1044]), # Silver
     (['QLB'], [2400]), # Lumber
     (['QGO', 'QCL'], [1310, 1311]), # Gas, Oil
     (['QPL', 'QPA'], [3449, 3491, 3492, 3493, 3494, 3495, 3496, 3497, 3498, 3499]), # Platinum, Palladium
 ]
 
 # create 1D list from SIC codes
 self.SIC_universe:List[int] = map(lambda x: x[1], self.COT_tickers_SICs)
 self.SIC_universe:List[int] = reduce(lambda x,y: x+y , self.SIC_universe)
 
 self.last_long_prop:Dict[str, None] = {
     'QHG': None,
     'QGC': None,
     'QSI': None,
     'QLB': None,
     'QGO': None,
     'QCL': None,
     'QPL': None,
     'QPA': None
 }
 
 # subscribe to COT data
 for cot_ticker, _ in self.last_long_prop.items():
     data = self.AddData(CommitmentsOfTraders, cot_ticker, Resolution.Daily)
     
 self.symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
 
 self.selection_flag:bool = False
 self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
 self.UniverseSettings.Resolution = Resolution.Daily
 self.AddUniverse(self.FundamentalSelectionFunction)
 
 self.Schedule.On(self.DateRules.MonthStart(self.symbol), self.TimeRules.BeforeMarketClose(self.symbol, 0), self.Selection)
 self.settings.daily_precise_end_time = False
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
 for security in changes.AddedSecurities:
     security.SetFeeModel(CustomFeeModel())
     security.SetLeverage(self.leverage)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
 # selection on monthly basis
 if not self.selection_flag:
     return Universe.Unchanged
 self.selection_flag = False
 
 # filter all symbol of stocks
 selected:List[Fundamental] = [
     x for x in fundamental if x.HasFundamentalData and x.Price > self.min_share_price \
     and not isnan(x.AssetClassification.SIC != 0) and (x.AssetClassification.SIC != 0) \
     and (x.AssetClassification.SIC in self.SIC_universe) and x.SecurityReference.ExchangeId in self.exchange_codes
 ]
 
 selected_symbols:List[Symbol] = []
 # firstly clear stocks from old selection
 self.SIC_stocks.clear()
 
 # store relevant stocks symbols into their basket according to SIC code
 for stock in selected:
     symbol = stock.Symbol
     SIC_code = stock.AssetClassification.SIC
     
     # make sure list for stocks is initialized
     if SIC_code not in self.SIC_stocks:
         self.SIC_stocks[SIC_code] = []
     
     # add stock's symbol to it's basket based on SIC code
     self.SIC_stocks[SIC_code].append(symbol)
 
     selected_symbols.append(symbol)
     
 return selected_symbols
 
def OnData(self, data: Slice) -> None:
 COT_data_last_update_date:Dict[Symbol, datetime.date] = CommitmentsOfTraders.get_last_update_date()
 # storing tuples (SIC_list, long_proportion_growth_value)
 long_proportion_growth:List[Tuple[List, float]] = []
 rebalance_flag:bool = False
 
 for COT_ticker_list, SIC_list in self.COT_tickers_SICs:
     
     long_proportion_growth_values:List[float] = []
     
     for COT_ticker in COT_ticker_list:
         
         if self.Securities[COT_ticker].GetLastData() and self.Time.date() < COT_data_last_update_date[COT_ticker]:
             if COT_ticker in data and data[COT_ticker]:
                 rebalance_flag = True
                 
                 # retrieve needed values from data object
                 large_spec_long:int = data[COT_ticker].get_Item('LARGE_SPECULATOR_LONG')
                 large_spec_short:int = data[COT_ticker].get_Item('LARGE_SPECULATOR_SHORT')
                 
                 if large_spec_long == 0 or large_spec_short == 0:
                     continue
                 
                 if not self.last_long_prop[COT_ticker]:
                     value:float = large_spec_long / (large_spec_short + large_spec_long + 0)
                     self.last_long_prop[COT_ticker] = value
                     continue
                 
                 curr_long_proportion:float = large_spec_long / (large_spec_short + large_spec_long + 0)
                 growth_value:float = (curr_long_proportion - self.last_long_prop[COT_ticker]) / self.last_long_prop[COT_ticker]
                 
                 # append long proportion growth value for current COT data
                 long_proportion_growth_values.append(growth_value)
                 
                 # update last long proporiton value
                 self.last_long_prop[COT_ticker] = curr_long_proportion
         
     if len(long_proportion_growth_values) != 0:
         # storing tuples (SIC_list, long_proportion_growth_value)
         long_proportion_growth.append( (SIC_list, np.mean(long_proportion_growth_values)) )
 
 # rebalance weekly
 if len(long_proportion_growth) != 0 and rebalance_flag:
     # long stocks with positive signal growth rates and short stocks with negative signal growth.
     long, short = self.CreateLongShortPortfolio(long_proportion_growth)
     
     # order execution
     targets:List[PortfolioTarget] = []
     for i, portfolio in enumerate([long, short]):
         for symbol in portfolio:
             if symbol in data and data[symbol]:
                 targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
     
     self.SetHoldings(targets, True)
     
 elif len(long_proportion_growth) == 0 and rebalance_flag:
     self.Liquidate()
         
def CreateLongShortPortfolio(self, long_proportion_growth:Tuple):
 long:List[Symbol] = []
 short:List[Symbol] = []
 
 # long stocks with positive signal growth rates and short stocks with negative signal growth.
 for SIC_list, value in long_proportion_growth:
     for SIC in SIC_list:
         
         # make sure SIC code has stocks
         if SIC not in self.SIC_stocks:
             continue
         
         if value > 0:
             long += self.SIC_stocks[SIC]
         else:
             short += self.SIC_stocks[SIC]
 
 return long, short
 
def Selection(self) -> None:
 self.selection_flag = True
# Commitments of Traders data.
# NOTE: IMPORTANT: Data order must be ascending (datewise).
# Data source: https://commitmentsoftraders.org/cot-data/
# Data description: https://commitmentsoftraders.org/wp-content/uploads/Static/CoTData/file_key.html
class CommitmentsOfTraders(PythonData):
_last_update_date:Dict[Symbol, datetime.date] = {}
@staticmethod
def get_last_update_date() -> Dict[Symbol, datetime.date]:
return CommitmentsOfTraders._last_update_date
def GetSource(self, config, date, isLiveMode):
 return SubscriptionDataSource("data.quantpedia.com/backtesting_data/futures/cot/{0}.PRN".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
# File example.
# DATE   OPEN     HIGH        LOW       CLOSE     VOLUME   OI
# ----   ----     ----        ---       -----     ------   --
# DATE   LARGE    SPECULATOR  COMMERCIAL HEDGER   SMALL TRADER
#        LONG     SHORT       LONG      SHORT     LONG     SHORT
def Reader(self, config, line, date, isLiveMode):
 data = CommitmentsOfTraders()
 data.Symbol = config.Symbol
 
 if not line[0].isdigit(): return None
 split = line.split(',')
 
 # Prevent lookahead bias.
 data.Time = datetime.strptime(split[0], "%Y%m%d") + timedelta(days=1)
 
 data['LARGE_SPECULATOR_LONG'] = int(split[1])
 data['LARGE_SPECULATOR_SHORT'] = int(split[2])
 data['COMMERCIAL_HEDGER_LONG'] = int(split[3])
 data['COMMERCIAL_HEDGER_SHORT'] = int(split[4])
 data['SMALL_TRADER_LONG'] = int(split[5])
 data['SMALL_TRADER_SHORT'] = int(split[6])
 data.Value = int(split[1])
 if config.Symbol.Value not in CommitmentsOfTraders._last_update_date:
     CommitmentsOfTraders._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()
 if data.Time.date() > CommitmentsOfTraders._last_update_date[config.Symbol.Value]:
     CommitmentsOfTraders._last_update_date[config.Symbol.Value] = data.Time.date()
 return data
 
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
 fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
 return OrderFee(CashAmount(fee, "USD"))