
“该策略涉及交易65种期货,使用基于基差符号的时间序列动量。对具有正动量和正基差的证券建立多头头寸,同时做空具有负动量和负基差的证券。”
资产类别: 差价合约、期货 | 地区: 全球 | 周期: 每月 | 市场: 债券、大宗商品、外汇、股票 | 关键词: 时间序列、动量、套利
I. 策略概要
投资范围包括65种期货,包括商品、股票指数、固定收益和外汇。市场基差定义为现货价格与期货价格的对数差,并年化。投资者采用基于基差符号的时间序列动量策略。对具有正时间序列动量和正基差的证券建立多头头寸,同时对具有负时间序列动量和负基差的证券建立空头头寸。该策略旨在利用由基差确定的有利或不利市场条件下的证券动量。
II. 策略合理性
回归结果显示,基差显著影响时间序列动量表现。只做多的时间序列动量与基差呈正相关,而只做空的动量则呈负相关。基差有助于趋势方向,这对动量策略至关重要。其影响因资产类别而异,固定收益贡献最大(超过50%),而股票贡献约8%。平均而言,基差占时间序列动量回报的36%。基于基差符号的条件策略的夏普比率为0.92,优于无条件策略的夏普比率0.75。这种改进在各种回溯期和市场条件下都成立,尤其是在危机期间,这表明其稳健性和经济意义。
III. 来源论文
Time-Series Momentum, Carry and Hedging Premium [点击查看论文]
- 马拉特·莫利博加(Marat Molyboga)、钱俊凯(Junkai Qian)和何朝华(Chaohua He),EDHEC 商学院。
<摘要>
本论文考察了1975年1月至2016年12月期间,包括股票指数、固定收益、货币和商品在内的所有主要资产类别的65个期货市场的时间序列动量表现。我们发现现货与期货合约之间的基差解释了时间序列动量表现的约36%,这表明时间序列动量与套利之间存在关联。根据基差符号调整交易信号可以将时间序列动量的夏普比率提高约0.17,并且在子时期、时间序列动量实施中的头寸规模选择以及基差计算中使用的回溯期方面都具有鲁棒性。在经济衰退的早期阶段,这种表现的改善尤其强劲,而此时股市表现往往非常糟糕。因此,我们的策略可以显著改善投资者的福利。我们通过检查交易员承诺(COT)报告中对冲者的头寸,调查时间序列动量和套利是否与对冲溢价相关。我们发现强有力的证据表明时间序列动量正在捕捉对冲溢价,而套利交易与对冲溢价仅有微弱关联。因此,时间序列动量和套利相关,因为这两种策略都受益于基差的时间序列和横截面变异性,但它们又不同,因为时间序列动量本身与对冲溢价相关。
IV. 回测表现
| 年化回报 | 9.2% |
| 波动率 | 10% |
| β值 | 0.148 |
| 夏普比率 | 0.92 |
| 索提诺比率 | 0.188 |
| 最大回撤 | N/A |
| 胜率 | 51% |
V. 完整的 Python 代码
from AlgorithmImports import *
import math
import numpy as np
from typing import List, Dict
#endregion
class TimeSeriesMomentumandCarry(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)
self.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_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
"CME_NQ1" : Futures.Indices.NASDAQ100EMini, # E-mini NASDAQ 100 Futures, Continuous Contract #1
"CME_ES1" : Futures.Indices.SP500EMini, # E-mini S&P 500 Futures, Continuous Contract #1
"SGX_NK1" : Futures.Indices.Nikkei225Dollar, # SGX Nikkei 225 Index Futures, Continuous Contract #1
"CME_TY1" : Futures.Financials.Y10TreasuryNote, # 10 Yr Note Futures, Continuous Contract #1
"CME_FV1" : Futures.Financials.Y5TreasuryNote, # 5 Yr Note Futures, Continuous Contract #1
"CME_TU1" : Futures.Financials.Y2TreasuryNote, # 2 Yr Note Futures, Continuous Contract #1
"CME_AD1" : Futures.Currencies.AUD, # Australian Dollar Futures, Continuous Contract #1
"CME_BP1" : Futures.Currencies.GBP, # British Pound Futures, Continuous Contract #1
"CME_CD1" : Futures.Currencies.CAD, # Canadian Dollar Futures, Continuous Contract #1
"CME_EC1" : Futures.Currencies.EUR, # Euro FX Futures, Continuous Contract #1
"CME_JY1" : Futures.Currencies.JPY, # Japanese Yen Futures, Continuous Contract #1
"CME_MP1" : Futures.Currencies.MXN, # Mexican Peso Futures, Continuous Contract #1
"CME_NE1" : Futures.Currencies.NZD, # New Zealand Dollar Futures, Continuous Contract #1
"CME_SF1" : Futures.Currencies.CHF, # Swiss Franc Futures, Continuous Contract #1
}
self.period:int = 12 * 21
self.min_expiration_days:int = 2
self.max_expiration_days:int = 360
leverage: int = 5
self.futures_data:dict[Symbol, RollingWindow] = {}
# subscribe data
for qp_ticker, qc_ticker in self.tickers.items():
security = self.AddData(QuantpediaFutures, qp_ticker, Resolution.Daily)
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(leverage)
qp_symbol:Symbol = security.Symbol
# QC futures
future:Future = self.AddFuture(qc_ticker, Resolution.Daily, dataNormalizationMode=DataNormalizationMode.Raw)
future.SetFilter(timedelta(days=self.min_expiration_days), timedelta(days=self.max_expiration_days))
self.futures_data[future.Symbol.Value] = FuturesData(qp_symbol, 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) -> 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()
# 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 * self.Securities[ticker].SymbolProperties.PriceMagnifier
raw_price2:float = data[dist_c.Symbol].Value * self.Securities[ticker].SymbolProperties.PriceMagnifier
if raw_price1 != 0 and raw_price2 != 0:
future_obj.update_prices(raw_price1, raw_price2)
# Rebalance monthly
if self.recent_month == self.Time.month:
return
self.recent_month = self.Time.month
self.Liquidate()
basis:dict[Symbol, float] = {}
momentum:dict[Symbol, float] = {}
volatility:dict[Symbol, float] = {}
for _, future_obj in self.futures_data.items():
data_ready_flag:bool = future_obj.is_ready()
if self.securities[future_obj.quantpedia_future].get_last_data() and self.time.date() > QuantpediaFutures.get_last_update_date()[future_obj.quantpedia_future]:
self.liquidate()
return
# make sure data are ready
if data_ready_flag:
qp_symbol:Symbol = future_obj.quantpedia_future
basis_value, momentum_value, volatility_value = future_obj.get_metrics()
basis[qp_symbol] = basis_value
momentum[qp_symbol] = momentum_value
volatility[qp_symbol] = volatility_value
# reset future's data
elif data_ready_flag:
future_obj.reset_data()
# make sure there are enough futures for selection
if len(volatility) == 0:
return
# inverse volatility weighting
long_part:List[Symbol] = [x[0] for x in momentum.items() if x[1] > 0 and x[0] in basis and basis[x[0]] > 0]
short_part:List[Symbol] = [x[0] for x in momentum.items() if x[1] < 0 and x[0] in basis and basis[x[0]] < 0]
targets: List[PortfolioTarget] = []
for i, portfolio in enumerate([long_part, short_part]):
vol_sum: float = sum([1 / volatility[x] for x in portfolio if not math.isnan(volatility[x])])
for symbol in portfolio:
if not math.isnan(volatility[symbol]):
if symbol in data and data[symbol]:
targets.append(PortfolioTarget(symbol, ((-1) ** i) / volatility[symbol] / vol_sum))
self.SetHoldings(targets, True)
class FuturesData:
def __init__(self, quantpedia_future:Symbol, period:int) -> None:
self.quantpedia_future:Symbol = quantpedia_future
self.near_contract:FuturesContract = None
self.distant_contract:FuturesContract = None
self.first_contract_prices:RollingWindow = RollingWindow[float](period)
self.second_contract_price:float = None
def update_prices(self, first_contract_price:float, second_contract_price:float) -> None:
self.first_contract_prices.Add(first_contract_price)
self.second_contract_price = second_contract_price
def update_contracts(self, near_contract:FuturesContract, distant_contract:FuturesContract) -> None:
self.near_contract = near_contract
self.distant_contract = distant_contract
def get_metrics(self) -> tuple:
prices:np.array = np.array([x for x in self.first_contract_prices])
momentum_value:float = prices[0] / prices[-1] - 1
returns:np.array = (prices[:-1] - prices[1:]) / prices[1:]
volatility_value:float = np.std(returns)
basis_value:float = np.log(prices[0] - self.second_contract_price)
return basis_value, momentum_value, volatility_value
def reset_data(self) -> None:
self.first_contract_prices.Reset()
self.second_contract_price = None
def is_initialized(self) -> bool:
return self.near_contract is not None and self.distant_contract is not None
def is_ready(self) -> bool:
return self.first_contract_prices.IsReady and self.second_contract_price != None
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))
# Quantpedia data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaFutures(PythonData):
_last_update_date:Dict[Symbol, datetime.date] = {}
@staticmethod
def get_last_update_date() -> Dict[Symbol, datetime.date]:
return QuantpediaFutures._last_update_date
def GetSource(self, config, date, isLiveMode):
return SubscriptionDataSource("data.quantpedia.com/backtesting_data/futures/{0}.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
def Reader(self, config, line, date, isLiveMode):
data = QuantpediaFutures()
data.Symbol = config.Symbol
if not line[0].isdigit(): return None
split = line.split(';')
data.Time = datetime.strptime(split[0], "%d.%m.%Y") + timedelta(days=1)
data['back_adjusted'] = float(split[1])
data['spliced'] = float(split[2])
data.Value = float(split[1])
if config.Symbol not in QuantpediaFutures._last_update_date:
QuantpediaFutures._last_update_date[config.Symbol] = datetime(1,1,1).date()
if data.Time.date() > QuantpediaFutures._last_update_date[config.Symbol]:
QuantpediaFutures._last_update_date[config.Symbol] = data.Time.date()
return data