Convenience Yield Risk Factor Predicts Commodity Futures Returns
Log in to collectAcademic paper
Marcel Prokopczuk; Lazaros Symeonidis; Chardin Wese Simen; Robert Wichmann
- DELeibniz University Hannover
- ICMA Centre
- University of Reading
- ?Leibniz Universität Hannover - Faculty of Economics and Management
- ?University of Reading - ICMA Centre
- University of Essex
- ?Essex Business School, University of Essex
- University of Liverpool
- ?University of Liverpool Management School
- ?ICMA Centre, University of Reading
Strategy in a nutshell
Monthly commodity futures strategy: compute CYR from differences in convenience yield volatility of nearby contracts, sort 27 commodities by CYR, go long high-CYR, short low-CYR portfolios, equally weighted, rebalanced monthly.
Economic rationale
CYR captures commodity-specific risk unexplained by carry, momentum, or macro factors. The long-short strategy exploits this risk premium, remains profitable after transaction costs, and passes robustness checks.
Backtest performance
Annualised return6.93%
Volatility15.07%
Beta-0.037
Sharpe ratio0.46
Sortino ratio-0.112
Win rate50%
Full Python code
from AlgorithmImports import *
import data_tools
import numpy as np
from typing import List, Dict, Tuple
# endregion
class ConvenienceYieldRiskFactorPredictsCommodityFuturesReturns(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2008, 1, 1)
self.SetCash(100000)
self.period:int = 12
self.filter_period:int = 182
self.tickers:dict[str, str] = {
"CME_S1" : Futures.Grains.Soybeans,
"CME_W1" : Futures.Grains.Wheat,
"CME_SM1" : Futures.Grains.SoybeanMeal,
"CME_BO1" : Futures.Grains.SoybeanOil,
"CME_C1" : Futures.Grains.Corn,
"CME_O1" : Futures.Grains.Oats,
"CME_LC1" : Futures.Meats.LiveCattle,
"CME_FC1" : Futures.Meats.FeederCattle,
"CME_LN1" : Futures.Meats.LeanHogs,
"CME_GC1" : Futures.Metals.Gold,
"CME_SI1" : Futures.Metals.Silver,
"CME_PL1" : Futures.Metals.Platinum,
"CME_HG1" : Futures.Metals.Copper,
"CME_LB1" : Futures.Forestry.RandomLengthLumber,
"CME_NG1" : Futures.Energies.NaturalGas,
"CME_PA1" : Futures.Metals.Palladium,
"CME_CU1" : Futures.Energies.ChicagoEthanolPlatts,
"CME_DA1" : Futures.Dairy.ClassIIIMilk,
"ICE_CC1" : Futures.Softs.Cocoa,
"ICE_CT1" : Futures.Softs.Cotton2,
"ICE_KC1" : Futures.Softs.Coffee,
"ICE_O1" : Futures.Energies.HeatingOil,
"ICE_OJ1" : Futures.Softs.OrangeJuice,
"ICE_SB1" : Futures.Softs.Sugar11CME,
}
self.leverage:int = 2
self.data:Dict[Symbol, Tuple[float, float]] = {}
self.cyr_signal:Dict[Symbol, RollingWindow] = {}
self.futures_data:dict[Symbol, data_tools.FuturesData] = {}
for qp_ticker, qc_ticker in self.tickers.items():
# subscribe Quantpedia data
security:Security = self.AddData(data_tools.QuantpediaFutures, qp_ticker, Resolution.Daily)
security.SetFeeModel(data_tools.CustomFeeModel())
security.SetLeverage(self.leverage)
qp_symbol:Symbol = security.Symbol
# QC futures
future:Future = self.AddFuture(qc_ticker, Resolution.Daily, dataNormalizationMode=DataNormalizationMode.Raw)
future.SetFilter(0, self.filter_period)
future_symbol:str = future.Symbol
self.futures_data[future_symbol] = qp_symbol
self.recent_month:int = -1
def OnData(self, data:Slice):
qp_custom_data_last_update_date:Dict[Symbol, datetime.date] = data_tools.QuantpediaFutures._last_update_date
if all([self.Securities[x].GetLastData() for x in list(self.futures_data.keys())]) and any([self.Time.date() >= qc_custom_data_last_update_date[x] for x in qc_custom_data_last_update_date]):
self.Liquidate()
return
# save daily data
for contract_symbol, chain in data.FutureChains.items():
if len([i for i in chain]) >= 3:
sorted_by_date:List[Symbol] = sorted(chain, key=lambda x: x.Expiry)
first_convenience_yield:float = (365 * (sorted_by_date[0].LastPrice - sorted_by_date[1].LastPrice)) / ((sorted_by_date[1].Expiry - self.Time).days - (sorted_by_date[0].Expiry - self.Time).days)
second_convenience_yield:float = (365 * (sorted_by_date[1].LastPrice - sorted_by_date[2].LastPrice)) / ((sorted_by_date[2].Expiry - self.Time).days - (sorted_by_date[1].Expiry - self.Time).days)
if contract_symbol not in self.data:
self.data[contract_symbol] = []
self.data[contract_symbol].append((first_convenience_yield, second_convenience_yield))
# monthly rebalance
if self.Time.month == self.recent_month:
return
self.recent_month = self.Time.month
if len(self.data) == 0:
self.Liquidate()
return
# save convenience yield risk values
for contract_symbol, cyr_values in self.data.items():
if len(cyr_values) != 0:
first_std:float = np.std([i[0] for i in cyr_values])
second_std:float = np.std([i[1] for i in cyr_values])
if contract_symbol not in self.cyr_signal:
self.cyr_signal[contract_symbol] = RollingWindow[float](self.period)
self.cyr_signal[contract_symbol].Add(first_std - second_std)
self.data.clear()
if len(self.cyr_signal) == 0:
self.Liquidate()
return
cyr_mean:Dict[Symbol, float] = {}
# mean of 12 months convenience yield risk values
for contract_symbol, cyr_signals in self.cyr_signal.items():
if cyr_signals.IsReady:
if contract_symbol not in cyr_mean:
cyr_mean[contract_symbol] = np.mean(list(cyr_signals))
# sort and divide
if len(cyr_mean) != 0:
sorted_cyr:List[Symbol] = sorted(cyr_mean.items(), key=lambda x:x[1])
CYR_median:float = np.median([i[1] for i in sorted_cyr])
high:List[Symbol] = [symbol for symbol, cyr_value in sorted_cyr if cyr_value > CYR_median]
low:List[Symbol] = [symbol for symbol, cyr_value in sorted_cyr if cyr_value <= CYR_median]
# trade execution
invested:List[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested]
for symbol in invested:
if symbol not in high + low:
self.Liquidate(symbol)
for symbol in high:
if self.futures_data[symbol] in data and data[self.futures_data[symbol]]:
self.SetHoldings(self.futures_data[symbol], 1 / len(high))
for symbol in low:
if self.futures_data[symbol] in data and data[self.futures_data[symbol]]:
self.SetHoldings(self.futures_data[symbol], -1 / len(low))