特有商品动量
登录后收藏学术论文
Idiosyncratic Momentum in Commodity Futures
Idiosyncratic Momentum in Commodity Futures [点击查看论文]
- ?Sarasin & Partners LLP
- ?Sarasin & Partners
策略概要
投资范围包括28种商品的期货合约。平均交易量低于1,000份合约的期货被排除在外。每个月,使用一个模型估计商品的特质回报,其中回报基于阿尔法、因子模拟投资组合和残差回报,参数使用OLS回归估计。因子溢价使用Fama & French方法构建,投资组合基于对冲压力(HP)、展期收益率、规模和价值因子。对冲压力通过未平仓合约衡量;展期收益率基于合约交割排名;价值基于现货价格的对数比率;规模由未平仓合约和合约价值定义。投资组合根据其3个月期间的残差回报,做多前7种商品,做空后7种商品。投资组合每月重新平衡,每种商品等权重。该策略旨在捕捉商品期货的因子溢价。
II. 策略合理性
动量效应有两种解释:行为的和基于风险的。在商品中,期货的回报不遵循随机游走,从而允许基于过去表现模式的盈利策略。特质回报动量源于时变动量暴露于系统性因子和传统策略面临的动量崩盘的证据。传统的动量策略在表现强劲时期对系统性因子产生正载荷,在表现疲软时期产生负载荷,从而在因子表现发生变化时造成损失。相比之下,将回报分解为系统性和特质性成分的特质回报动量提供了一种更稳健、风险调整后的策略,该策略始终优于传统策略。
回测表现
波动率29.42%
夏普比率1.05
索提诺比率0.151
胜率49%
完整 Python 代码
from AlgorithmImports import *
import data_tools
import statsmodels.api as sm
import numpy as np
#endregion
class IdiosyncraticCommodityMomentum(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2004, 1, 1)
self.SetCash(100000)
tickers:dict[str, str] = {
"CME_S1" : Futures.Grains.Soybeans, # Soybean Futures, Continuous Contract
"CME_W1" : Futures.Grains.Wheat, # Wheat Futures, Continuous Contract
"CME_SM1" : Futures.Grains.SoybeanMeal, # Soybean Meal Futures, Continuous Contract
"CME_BO1" : Futures.Grains.SoybeanOil, # Soybean Oil Futures, Continuous Contract
"CME_C1" : Futures.Grains.Corn, # Corn Futures, Continuous Contract
"CME_O1" : Futures.Grains.Oats, # Oats Futures, Continuous Contract
"CME_LC1" : Futures.Meats.LiveCattle, # Live Cattle Futures, Continuous Contract
"CME_FC1" : Futures.Meats.FeederCattle, # Feeder Cattle Futures, Continuous Contract
"CME_LN1" : Futures.Meats.LeanHogs, # Lean Hog Futures, Continuous Contract
"CME_GC1" : Futures.Metals.Gold, # Gold Futures, Continuous Contract
"CME_SI1" : Futures.Metals.Silver, # Silver Futures, Continuous Contract
"CME_PL1" : Futures.Metals.Platinum, # Platinum Futures, Continuous Contract
"CME_HG1" : Futures.Metals.Copper, # Copper Futures, Continuous Contract
"CME_LB1" : Futures.Forestry.RandomLengthLumber, # Random Length Lumber Futures, Continuous Contract
"CME_PA1" : Futures.Metals.Palladium, # Palladium Futures, Continuous Contract
"CME_DA1" : Futures.Dairy.ClassIIIMilk, # Class III Milk Futures
"CME_RB1" : Futures.Energies.Gasoline, # Gasoline Futures, Continuous Contract
"ICE_CC1" : Futures.Softs.Cocoa, # Cocoa Futures, Continuous Contract
"ICE_CT1" : Futures.Softs.Cotton2, # Cotton No. 2 Futures, Continuous Contract #1
"ICE_KC1": Futures.Softs.Coffee, # Coffee C Futures, Continuous Contract #1
"ICE_O1" : Futures.Energies.HeatingOil, # Heating Oil Futures, Continuous Contract
"ICE_OJ1": Futures.Softs.OrangeJuice, # Orange Juice Futures, Continuous Contract #1
"ICE_SB1" : Futures.Softs.Sugar11CME, # Sugar No. 11 Futures, Continuous Contract
}
self.ranking_period:int = 3
self.month_period:int = 21
self.one_year_period:int = 12 * self.month_period
self.period:int = 5.5 * self.one_year_period
self.leverage:int = 5
self.trade_count:int = 4
self.percentage_from_total_count:float = 0.15
self.min_prices:int = 15
self.wanted_monthly_returns:int = 1
self.qp_max_missing_days:int = 5
self.cot_max_missing_days:int = 10
self.futures_max_missing_days:int = 5
self.hedging_pressure_factor_symbols:list[Symbol, bool] = []
self.hedging_pressure_factor_vector:list[float] = []
self.term_structure_factor_symbols:list[Symbol, bool] = []
self.term_structure_factor_vector:list[float] = []
self.value_factor_symbols:list[Symbol, bool] = []
self.value_factor_vector:list[float] = []
self.data:dict[Symbol, data_tools.Symbol] = {}
self.futures_data:dict[str, data_tools.FuturesData] = {}
min_expiration_days:int = 0
max_expiration_days:int = 360
for qp_ticker, qc_ticker in tickers.items():
# Add quantpedia back-adjusted data.
security = self.AddData(data_tools.QuantpediaFutures, qp_ticker, Resolution.Daily)
security.SetFeeModel(data_tools.CustomFeeModel())
security.SetLeverage(self.leverage)
qp_symbol:Symbol = security.Symbol
cot_ticker:str = 'Q' + qp_ticker.split('_')[1][:-1]
cot_symbol:Symbol = self.AddData(data_tools.CommitmentsOfTraders, cot_ticker, Resolution.Daily).Symbol
# QC futures
future:Future = self.AddFuture(qc_ticker, Resolution.Daily, dataNormalizationMode=DataNormalizationMode.Raw)
future.SetFilter(timedelta(days=min_expiration_days), timedelta(days=max_expiration_days))
future_ticker:str = future.Symbol.Value
self.futures_data[future_ticker] = data_tools.FuturesData(self.ranking_period)
self.data[qp_symbol] = data_tools.SymbolData(cot_symbol, future_ticker,
self.ranking_period * 4, self.period)
self.recent_month:int = -1
self.settings.daily_precise_end_time = False
self.settings.minimum_order_margin_portfolio_percentage = 0.
def FindAndUpdateContracts(self, futures_chain, ticker:str) -> None:
near_contract:FuturesContract = None
dist_contract:FuturesContract = None
if ticker in futures_chain:
contracts:list[:FuturesContract] = [contract for contract in futures_chain[ticker] if contract.Expiry.date() > self.Time.date()]
if len(contracts) >= 2:
contracts:list[:FuturesContract] = sorted(contracts, key=lambda x: x.Expiry, reverse=False)
near_contract = contracts[0]
dist_contract = contracts[1]
self.futures_data[ticker].update_contracts(near_contract, dist_contract)
def OnData(self, data):
curr_date:datetime.date = self.Time.date()
for qp_symbol, symbol_obj in self.data.items():
# store daily price
if qp_symbol in data and data[qp_symbol]:
price:float = data[qp_symbol].Value
spliced:float = data[qp_symbol].GetProperty('spliced')
symbol_obj.update_prices(price)
cot_symbol:Symbol = symbol_obj.cot_symbol
if cot_symbol in data and data[cot_symbol]:
speculator_long_count:float = data[cot_symbol].GetProperty('LARGE_SPECULATOR_LONG')
speculator_short_count:float = data[cot_symbol].GetProperty('LARGE_SPECULATOR_SHORT')
if speculator_long_count != 0 and speculator_short_count != 0:
hedging_pressure_value:float = speculator_long_count / (speculator_long_count + speculator_short_count)
symbol_obj.update_hedging_pressure_values(hedging_pressure_value)
# daily update qc future data
if data.FutureChains.Count > 0:
for ticker, future_obj in self.futures_data.items():
# check if near contract is expired or is not initialized
if not future_obj.is_initialized() or \
(future_obj.is_initialized() and future_obj.near_contract.Expiry.date() == curr_date):
self.FindAndUpdateContracts(data.FutureChains, ticker)
# update QC futures rolling return
if future_obj.is_initialized():
near_c:FuturesContract = future_obj.near_contract
dist_c:FuturesContract = future_obj.distant_contract
if near_c.Symbol in data and data[near_c.Symbol] and dist_c.Symbol in data and data[dist_c.Symbol]:
near_price:float = data[near_c.Symbol].Value * self.Securities[ticker].SymbolProperties.PriceMagnifier
dist_price:float = data[dist_c.Symbol].Value * self.Securities[ticker].SymbolProperties.PriceMagnifier
if near_price != 0 and dist_price != 0:
roll_yield:float = near_price / dist_price
future_obj.update_roll_yields(roll_yield)
# rebalance monthly
if self.Time.month == self.recent_month:
return
self.recent_month = self.Time.month
# Factor calculation data.
hedging_pressure:dict[Symbol, float] = {}
roll_yield:dict[Symbol, float] = {}
value:dict[Symbol, float] = {}
custom_data_last_update_date: Dict[Symbol, datetime.date] = data_tools.LastDateHandler.get_last_update_date()
for qp_symbol, symbol_obj in self.data.items():
future_ticker:str = symbol_obj.future_ticker
# check if data is still coming
if any([self.securities[symbol].get_last_data() and self.time.date() > custom_data_last_update_date[symbol] for symbol in [qp_symbol, symbol_obj.cot_symbol]]):
self.liquidate(qp_symbol)
continue
if symbol_obj.monthly_prices_ready(self.min_prices):
symbol_obj.update_monthly_returns()
symbol_obj.reset_monthly_prices()
# update metrics
if symbol_obj.prices_for_value_factor_ready():
value[qp_symbol] = symbol_obj.get_value(self.one_year_period) # The average spot price from 4.5 to 5.5 years ago divided by most recent spot price.
if symbol_obj.hedging_pressure_values_ready():
hedging_pressure[qp_symbol] = symbol_obj.get_mean_hedging_pressure_values()
if self.futures_data[future_ticker].roll_yields_ready():
roll_yield[qp_symbol] = self.futures_data[future_ticker].get_mean_roll_yields()
if int(len(hedging_pressure) * self.percentage_from_total_count) >= 1:
factor_return:float = self.CalcFactorReturn(self.hedging_pressure_factor_symbols, self.wanted_monthly_returns)
if factor_return != 0:
self.hedging_pressure_factor_vector.append(factor_return)
else:
self.hedging_pressure_factor_vector.clear()
self.hedging_pressure_factor_symbols = self.GetNewFactorSymbols(hedging_pressure)
else:
# require consecutive data in regression
self.hedging_pressure_factor_symbols.clear()
self.hedging_pressure_factor_vector
if int(len(roll_yield) * self.percentage_from_total_count) >= 1:
factor_return:float = self.CalcFactorReturn(self.term_structure_factor_symbols, self.wanted_monthly_returns)
if factor_return != 0:
self.term_structure_factor_vector.append(factor_return)
else:
self.term_structure_factor_vector.clear()
self.term_structure_factor_symbols = self.GetNewFactorSymbols(roll_yield)
else:
# require consecutive data in regression
self.term_structure_factor_symbols.clear()
self.term_structure_factor_vector.clear()
if int(len(value) * self.percentage_from_total_count) >= 1:
factor_return:float = self.CalcFactorReturn(self.value_factor_symbols, self.wanted_monthly_returns)
if factor_return != 0:
self.value_factor_vector.append(factor_return)
else:
self.value_factor_vector.clear()
self.value_factor_symbols = self.GetNewFactorSymbols(roll_yield)
else:
# require consecutive data in regression
self.value_factor_symbols.clear()
self.value_factor_vector.clear()
min_len:int = min(len(self.hedging_pressure_factor_vector), len(self.term_structure_factor_vector), len(self.value_factor_vector))
# all vectors are filled
if min_len < self.ranking_period:
self.Liquidate()
return
residual_return:dict[Symbol, float] = {}
for qp_symbol, symbol_obj in self.data.items():
if symbol_obj.monthly_returns_ready(min_len):
monthly_returns:list[float] = symbol_obj.get_last_n_monthly_returns(min_len)
shorten_recent_hp_factor = self.hedging_pressure_factor_vector[-min_len:]
shorten_recent_ts_factor = self.term_structure_factor_vector[-min_len:]
shorten_recent_v_factor = self.value_factor_vector[-min_len:]
# residual return calc.
x:list[list[float]] = [shorten_recent_hp_factor, shorten_recent_ts_factor, shorten_recent_v_factor]
regression_model = self.MultipleLinearRegresion(x, monthly_returns[-min_len:])
residual_return[qp_symbol] = sum(regression_model.resid[-self.ranking_period:])
if len(residual_return) < (self.trade_count * 2):
self.Liquidate()
return
sorted_by_residual_return:list[Symbol] = [x[0] for x in sorted(residual_return.items(), key=lambda item: item[1])]
long_leg:list[Symbol] = sorted_by_residual_return[-self.trade_count:]
short_leg:list[Symbol] = sorted_by_residual_return[:self.trade_count]
# trade execution.
stocks_invested:list[Symbol] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
for symbol in stocks_invested:
if symbol not in long_leg + short_leg:
self.Liquidate(symbol)
for symbol in long_leg:
if symbol in data and data[symbol]:
self.SetHoldings(symbol, 1 / self.trade_count)
for symbol in short_leg:
if symbol in data and data[symbol]:
self.SetHoldings(symbol, -1 / self.trade_count)
def MultipleLinearRegresion(self, x, y):
x = np.array(x).T
x = sm.add_constant(x)
result = sm.OLS(endog=y, exog=x).fit()
return result
def CalcFactorReturn(self, factor_symbols:list, period:int) -> float:
factor_return:float = 0
for symbol, long_flag in factor_symbols:
if self.data[symbol].monthly_returns_ready(period):
commodity_returns = self.data[symbol].get_last_n_monthly_returns(period)
for commodity_return in commodity_returns:
factor_return += commodity_return if long_flag else -commodity_return
return factor_return
def GetNewFactorSymbols(self, value_by_symbol:dict) -> list:
sorted_by_value:dict[Symbol, float] = sorted(value_by_symbol.items(), key=lambda x: x[1])
count:int = int(len(sorted_by_value) * self.percentage_from_total_count)
long_leg:list[list[Symbol, bool]] = [(x[0], True) for x in sorted_by_value[-count:]]
short_leg:list[list[Symbol, bool]] = [(x[0], False) for x in sorted_by_value[:count]]
return long_leg + short_leg