
“该策略根据12个月的动量将32种商品期货分为高4、低4和中等。投资者做多高4商品,做空低4商品,等权重,每月重新平衡。”
资产类别: 差价合约、期货 | 地区: 全球 | 周期: 每月 | 市场: 大宗商品 | 关键词: 价差、动量
I. 策略概要
该策略涉及32种商品期货,每个月都使用近月第一和第二合约之间12个月动量的差异,将商品分为三组:高4、低4和中等。高4包含动量排名最高的四种商品,低4包含动量排名最低的四种商品。投资者做多高4组中的四种商品,做空低4组中的四种商品。投资组合等权重,每月重新平衡。该策略旨在利用商品之间的动量差异获利,目标是做多高表现商品,做空低表现商品。
II. 策略合理性
研究表明,结果是由特定期限套期保值者的价格压力驱动的,这导致现货和期限溢价随时间和单一商品的不同合约而变化。这种压力主要影响展期收益,因为期货市场的套期保值导致期货价格在到期时收敛于现货价格。套期保值者的价格压力影响期货曲线,导致基差动量,表现为持续的陡峭或平坦化,无论市场条件如何,如现货溢价或期货溢价。这种持续性是由期货市场中生产者、消费者和投机者的决策中嵌入的信息解释的。
III. 来源论文
基差动量 [点击查看论文]
- Martijin Boons 和 Melissa Prado,Nova 商学院与经济学学院,Nova 商学院与经济学学院;经济政策研究中心 (CEPR)
<摘要>
我们引入了一个与期货期限结构的斜率和曲率相关的回报预测因子:基差动量。基差动量在预测商品现货和期限溢价的时间序列和横截面方面,显著优于基准特征。基差动量的风险敞口在商品排序投资组合和单个商品中都有定价。我们认为,基差动量捕捉了当投机者和中介机构的市场清算能力受损时出现的期货合约供需失衡,并且基差动量代表了定价风险的补偿。我们的发现与基于存储、库存和套期保值压力的其他解释不一致。


IV. 回测表现
| 年化回报 | 18.38% |
| 波动率 | 19.98% |
| β值 | -0.053 |
| 夏普比率 | 0.92 |
| 索提诺比率 | 0.195 |
| 最大回撤 | N/A |
| 胜率 | 52% |
V. 完整的 Python 代码
from AlgorithmImports import *
import data_tools
#endregion
class SpreadMomentum(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2009, 1, 1)
self.SetCash(100000)
self.tickers = {
"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_RB2": Futures.Energies.Gasoline, # Gasoline Futures, Continuous Contract
"ICE_CC1": Futures.Softs.Cocoa, # Cocoa Futures, Continuous Contract
"ICE_O1": Futures.Energies.HeatingOil, # Heating Oil Futures, Continuous Contract
"ICE_SB1": Futures.Softs.Sugar11CME, # Sugar No. 11 Futures, Continuous Contract
"ICE_WT1": Futures.Energies.CrudeOilWTI, # WTI Crude Futures, Continuous Contract
}
self.futures_data:Dict[str, data_tools.FutureData] = {}
self.max_missing_days:int = 5
self.futures_num:int = 4
self.lookup_period:int = 12 * 21
min_expiration_days:int = 2
max_expiration_days:int = 360
# subscribe data
for qp_ticker, qc_ticker in self.tickers.items():
security = self.AddData(data_tools.QuantpediaFutures, qp_ticker, Resolution.Daily)
security.SetFeeModel(data_tools.CustomFeeModel())
security.SetLeverage(5)
qp_symbol:Symbol = security.Symbol
# QC futures
future:Future = self.AddFuture(qc_ticker, Resolution.Daily)
future.SetFilter(timedelta(days=min_expiration_days), timedelta(days=max_expiration_days))
self.futures_data[future.Symbol.Value] = data_tools.FuturesData(qp_symbol, self.lookup_period)
self.recent_month:int = -1
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
def FindAndUpdateContracts(self, futures_chain, ticker) -> 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_time:datetime.datetime = self.Time
curr_date:datetime.date = curr_time.date()
# 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]:
raw_price1:float = data[near_c.Symbol].Value
raw_price2:float = data[dist_c.Symbol].Value
if raw_price1 != 0 and raw_price2 != 0:
future_obj.update_rate_of_change(raw_price1, raw_price2, curr_time)
# update, when qp data still coming
for _, future_obj in self.futures_data.items():
qp_symbol:Symbol = future_obj.quantpedia_future
if qp_symbol in data and data[qp_symbol]:
future_obj.update_quantpedia_last_update(curr_date)
# rebalance monthly
if self.recent_month != curr_time.month:
self.recent_month = curr_time.month
self.Rebalance(curr_date)
def Rebalance(self, curr_date:datetime.date) -> None:
if self.IsWarmingUp: return
diff:Dict[Symbol, float] = {}
last_update_date:Dict[str, datetime.date] = data_tools.QuantpediaFutures.get_last_update_date()
# filter ready futures and reset futures, which do not recieve new data
for _, future_obj in self.futures_data.items():
data_ready_flag:bool = future_obj.is_ready()
# make sure data are ready and up to date
if data_ready_flag and self.Securities[future_obj.quantpedia_future].GetLastData() and \
future_obj.quantpedia_future.Value in last_update_date and self.Time.date() < last_update_date[future_obj.quantpedia_future.Value]:
diff[future_obj.quantpedia_future] = future_obj.get_difference()
# reset future's data
elif data_ready_flag:
future_obj.reset_data()
self.Liquidate()
# make sure there are enough futures
if len(diff) < (self.futures_num * 2): return
sorted_by_diff:List[Symbol] = [x[0] for x in sorted(diff.items(), key=lambda item: item[1])]
long:List[Symbol] = sorted_by_diff[-self.futures_num:]
short:List[Symbol] = sorted_by_diff[:self.futures_num]
# Trade execution
for i, portfolio in enumerate([long, short]):
for symbol in portfolio:
self.SetHoldings(symbol, ((-1) ** i) / len(portfolio))