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.

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 Return17.5%
Volatility29.42%
Beta0.062
Sharpe Ratio1.05
Sortino Ratio0.151
Maximum DrawdownN/A
Win Rate49%

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

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading