Quant Buffet放轻松,别过度思虑

特有商品动量

登录后收藏

学术论文

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