
The strategy involves trading 28 commodity futures based on factors like hedging pressure, roll yield, size, and value. It goes long the top 7 and short the bottom 7, rebalanced monthly.
ASSET CLASS: CFDs, futures | REGION: Global | FREQUENCY:
Monthly | MARKET: commodities | KEYWORD: Idiosyncratic, Momentum
I. STRATEGY IN A NUTSHELL
This strategy trades 28 commodity futures, excluding low-liquidity contracts (<1,000 average volume). Idiosyncratic returns are estimated monthly via OLS regression using factor mimicking portfolios based on hedging pressure, roll yield, size, and value. The portfolio goes long on the top 7 and short on the bottom 7 commodities by residual returns over a 3-month period. Positions are equally weighted and rebalanced monthly to capture factor premiums systematically.
II. ECONOMIC RATIONALE
Commodity momentum arises from non-random return patterns. Idiosyncratic return momentum isolates residual returns from systematic factors, reducing exposure to factor crashes. This approach provides a robust, risk-adjusted strategy, consistently outperforming conventional momentum methods during varying market conditions.
III. SOURCE PAPER
Idiosyncratic Momentum in Commodity Futures [Click to Open PDF]
Iuliia Shpak, Sarasin & Partners LLP; Ben Human, Sarasin & Partners; Andrea Nardon, Sarasin & Partners LL
<Abstract>
This paper provides novel findings on idiosyncratic momentum in commodity futures. Momentum strategy that forms portfolios on the basis of commodity-specific returns delivers compelling investment returns which are substantially more robust and superior to total return momentum on an absolute and risk-adjusted basis. Furthermore, idiosyncratic return momentum is materially more persistent than total return momentum in that it delivers statistically significant positive returns over longer term horizons including ranking periods of up to 24 months. A set of commodity specific and equity markets inspired factors are examined. Notably, the results corroborate that hedging pressure and term structure are sources of risk premium in commodity futures. The analysis in this chapter expose that momentum in commodity futures is fundamentally different to the momentum effect in equity markets. Specifically, momentum in commodity futures is entirely attributed to the momentum effect in long-only portfolios whilst none of the short-only strategies’ returns are either profitable or statistically significant. Lastly, the two types of long-only momentum significantly outperform a passive investing into a broad market index such as S&P GSCI.


IV. BACKTEST PERFORMANCE
| Annualised Return | 17.5% |
| Volatility | 29.42% |
| Beta | 0.062 |
| Sharpe Ratio | 1.05 |
| Sortino Ratio | 0.151 |
| Maximum Drawdown | N/A |
| Win Rate | 49% |
V. FULL PYTHON CODE
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