Quant BuffetRelax, Not Over Thinking

Timing Commodity Factor Portfolios

Log in to collect

Academic paper

Are There Exploitable Trends in Commodity Future Prices?

AuthorsYufeng Han; Ting Hu

Institute
  • University of North Carolina at Charlotte
  • ?University of North Carolina (UNC) at Charlotte - Finance
  • ?School of Economics and Management - School of Economics and Management

Strategy in a nutshell

This strategy trades 35 commodity futures, selecting the top 6-month performers each month. A 5-day moving average filter is used to enter positions only when portfolio performance exceeds the moving average, otherwise remaining out of the market.

Economic rationale

The strategy captures periods of high investor sentiment and elevated real interest rates, allowing effective market timing. Its success stems from exploiting market portfolio trends to enhance returns and reduce risk during varying conditions.

Backtest performance

Annualised return8.04%
Volatility11.3%
Beta0.14
Sharpe ratio0.71
Sortino ratio0.221
Win rate53%

Full Python code

from collections import deque
from AlgorithmImports import *
import numpy as np
class TimingCommodityFactor(QCAlgorithm):
def Initialize(self):
 self.SetStartDate(2002, 1, 1)
 self.SetCash(100000)
 self.symbols = [
     "CME_S1",   # Soybean Futures, Continuous Contract
     "CME_W1",   # Wheat Futures, Continuous Contract
     "CME_SM1",  # Soybean Meal Futures, Continuous Contract
     "CME_BO1",  # Soybean Oil Futures, Continuous Contract
     "CME_C1",   # Corn Futures, Continuous Contract
     "CME_O1",   # Oats Futures, Continuous Contract
     "CME_LC1",  # Live Cattle Futures, Continuous Contract
     "CME_FC1",  # Feeder Cattle Futures, Continuous Contract
     "CME_LN1",  # Lean Hog Futures, Continuous Contract
     "CME_GC1",  # Gold Futures, Continuous Contract
     "CME_SI1",  # Silver Futures, Continuous Contract
     "CME_PL1",  # Platinum Futures, Continuous Contract
     "CME_CL1",  # Crude Oil Futures, Continuous Contract
     "CME_HG1",  # Copper Futures, Continuous Contract
     "CME_LB1",  # Random Length Lumber Futures, Continuous Contract
     # "CME_NG1",  # Natural Gas (Henry Hub) Physical Futures, Continuous Contract
     "CME_PA1",  # Palladium Futures, Continuous Contract 
     "CME_RR1",  # Rough Rice Futures, Continuous Contract
     "CME_DA1",  # Class III Milk Futures
     "ICE_RS1",  # Canola Futures, Continuous Contract
     "ICE_GO1",  # Gas Oil Futures, Continuous Contract
     "CME_RB2",  # Gasoline Futures, Continuous Contract
     "CME_KW2",  # Wheat Kansas, Continuous Contract
     "ICE_WT1",  # WTI Crude Futures, Continuous Contract
                 
     "ICE_CC1",  # Cocoa Futures, Continuous Contract 
     "ICE_CT1",  # Cotton No. 2 Futures, Continuous Contract
     "ICE_KC1",  # Coffee C Futures, Continuous Contract
     "ICE_O1",   # Heating Oil Futures, Continuous Contract
     "ICE_OJ1",  # Orange Juice Futures, Continuous Contract
     "ICE_SB1",  # Sugar No. 11 Futures, Continuous Contract
 ]
 self.data = {}
 self.top = {}
 self.low = {}
 
 ret_period = 120
 vol_period = 60
 ma_period = 5
 
 self.SetWarmUp(max(ret_period, vol_period, ma_period))
 
 for symbol in self.symbols:
     data = self.AddData(QuantpediaFutures, symbol, Resolution.Daily)
     data.SetLeverage(5)
     data.SetFeeModel(CustomFeeModel())
     
     ma = self.SMA(symbol, ma_period, Resolution.Daily)
     self.data[symbol] = SymbolData(symbol, ret_period, vol_period, ma)
 self.Schedule.On(self.DateRules.MonthStart(self.symbols[0]), self.TimeRules.At(0, 0), self.Rebalance)
def OnData(self, data):
 last_update_date = {}
 for symbol in self.data:
     # data is still coming
     if self.securities[symbol].get_last_data() and self.time.date() > QuantpediaFutures.get_last_update_date()[symbol]:
         self.liquidate(symbol)
         self.data[symbol].History.clear()
         continue
     symbol_obj = self.Symbol(symbol)
     if symbol_obj in data.Keys:
         if data[symbol_obj]:
             price = data[symbol_obj].Value
             self.data[symbol].Update(price)
             last_update_date[symbol] = self.Time.date()
 
 if self.IsWarmingUp: return

 sma_top = list(data for data in self.top if data[1].IsReady() and data[1].Price > data[1].MA.Current.Value and data[0] in last_update_date)
 sma_low = list(data for data in self.low if data[1].IsReady() and data[1].Price < data[1].MA.Current.Value and data[0] in last_update_date)
 for data in sma_top:
     symbol = data[0]
     symbol_data = data[1]
     
     if not self.Portfolio[symbol].IsLong:
         if symbol_data.Price > symbol_data.MA.Current.Value:
             if symbol_data.Weight != 0:
                 self.SetHoldings(symbol, symbol_data.Weight)
     elif self.Portfolio[symbol].IsLong:
         if symbol_data.Price <= symbol_data.MA.Current.Value:
             self.Liquidate(symbol)

 for data in sma_low:
     symbol = data[0]
     symbol_data = data[1]
     if not self.Portfolio[symbol].IsShort:
         if symbol_data.Price < symbol_data.MA.Current.Value:
             if symbol_data.Weight != 0:
                 self.SetHoldings(symbol, symbol_data.Weight)
     elif self.Portfolio[symbol].IsShort:
         if symbol_data.Price >= symbol_data.MA.Current.Value:
             self.Liquidate(symbol)       
def Rebalance(self):
 if self.IsWarmingUp: return
 self.Liquidate()
 sorted_by_ret = sorted([d for d in self.data.items() if d[1].IsReady()], key=lambda x: x[1].Return(), reverse = True)
 self.top = sorted_by_ret[:int(1/3 * len(sorted_by_ret))]
 self.low = sorted_by_ret[-int(1/3 * len(sorted_by_ret)):]
 
 # Weighting
 total_vol = sum((1.0/data[1].Volatility()) for data in self.top if data[1].IsReady()) + sum((1.0/data[1].Volatility()) for data in self.low if data[1].IsReady())
 for data in self.top + self.low:
     if data[1].IsReady():
         vol = data[1].Volatility()
         data[1].Weight = (1.0 / vol) / total_vol
class SymbolData:
def __init__(self, symbol, ret_lookback, vol_lookback, ma):
 self.Symbol = symbol
 self.History = deque(maxlen=max(ret_lookback, vol_lookback))
 self.Price = 0.0
 self.MA = ma
 self.Weight = 0
 
 self.ret_lookback = ret_lookback
 self.vol_lookback = vol_lookback
def IsReady(self):
 return len(self.History) == self.History.maxlen
 
def Update(self, value):
 self.Price = float(value)
 self.History.append(float(value))
def Return(self):
 prices = np.array(self.History)[-self.ret_lookback:]
 return (prices[-1]-prices[0])/prices[0]
def Volatility(self):
 prices = np.array(self.History)[-self.vol_lookback:]
 returns = (prices[1:]-prices[:-1])/prices[:-1]
 return np.std(returns)
# 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.Value not in QuantpediaFutures._last_update_date:
     QuantpediaFutures._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()
 if data.Time.date() > QuantpediaFutures._last_update_date[config.Symbol.Value]:
     QuantpediaFutures._last_update_date[config.Symbol.Value] = data.Time.date()
 return data
# Custom fee model.
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
 fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
 return OrderFee(CashAmount(fee, "USD"))