“该策略涉及交易65种期货,使用基于基差符号的时间序列动量。对具有正动量和正基差的证券建立多头头寸,同时做空具有负动量和负基差的证券。”

I. 策略概要

投资范围包括65种期货,包括商品、股票指数、固定收益和外汇。市场基差定义为现货价格与期货价格的对数差,并年化。投资者采用基于基差符号的时间序列动量策略。对具有正时间序列动量和正基差的证券建立多头头寸,同时对具有负时间序列动量和负基差的证券建立空头头寸。该策略旨在利用由基差确定的有利或不利市场条件下的证券动量。

II. 策略合理性

回归结果显示,基差显著影响时间序列动量表现。只做多的时间序列动量与基差呈正相关,而只做空的动量则呈负相关。基差有助于趋势方向,这对动量策略至关重要。其影响因资产类别而异,固定收益贡献最大(超过50%),而股票贡献约8%。平均而言,基差占时间序列动量回报的36%。基于基差符号的条件策略的夏普比率为0.92,优于无条件策略的夏普比率0.75。这种改进在各种回溯期和市场条件下都成立,尤其是在危机期间,这表明其稳健性和经济意义。

III. 来源论文

Time-Series Momentum, Carry and Hedging Premium [点击查看论文]

<摘要>

本论文考察了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

发表评论

了解 Quant Buffet 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读