
The strategy invests in commodities by sorting them into volatility-based groups. It buys from the “Low” volatility group and sells from the “High” volatility group, with monthly rebalancing and equal weighting.
ASSET CLASS: CFDs, futures | REGION: Global | FREQUENCY:
Monthly | MARKET: Commodity | KEYWORD: Commodity, Implied, Volatility, Strategy
I. STRATEGY IN A NUTSHELL
The strategy trades 25 commodities, sorted into four groups according to their 30-day implied volatility, de-trended by the prior 12 months’ average. Commodities in the “Low” group (lowest 25% volatility) are bought, while those in the “High” group (highest 25% volatility) are sold. The portfolio is equally weighted and rebalanced monthly, implementing a systematic long-short approach based on volatility differences.
II. ECONOMIC RATIONALE
The VOL strategy profits from predictable spot returns driven by the cost of volatility insurance. Commodities with expensive hedging tend to decline, while those with cheap hedging tend to rise. Limited arbitrage and capital constraints affect hedgers’ ability to manage inventories: high hedging costs reduce inventory, creating selling pressure, whereas low costs encourage hedging and support prices. This dynamic underpins the observed performance of the VOL strategy.
III. SOURCE PAPER
Commodity Option Implied Volatilities and the Expected Futures Returns [Click to Open PDF]
Gao, Luxembourg School of Finance; Universite du Luxembourg
<Abstract>
The detrended implied volatility of commodity options (VOL) forecasts the cross section of the commodity futures returns significantly. A zero-cost strategy that is long in low VOL and short in high VOL commodities yields an annualized return of 12.66% and a Sharpe ratio of 0.69. Notably, the excess returns based on the volatility strategy emanate mainly from its forecasting power for the future spot component, different from the other commodity strategies examined so far in the literature which are all driven by roll returns. This strategy demonstrates low correlations (below 10%) with the other strategies such as momentum or basis and performs especially well in recessions. Our results are robust after controlling for illiquidity, other commodity pricing factors, and exposure to the aggregate commodity market volatility. The VOL measure is associated with hedging pressure on the futures and especially on the options market. News media also helps amplify the uncertainty impact. Variables related to investors’ lottery preferences and market frictions are able to explain part of the predictive relationship.


IV. BACKTEST PERFORMANCE
| Annualised Return | 12.66% |
| Volatility | 18.48% |
| Beta | -0.079 |
| Sharpe Ratio | 0.69 |
| Sortino Ratio | 0.342 |
| Maximum Drawdown | N/A |
| Win Rate | 50% |
V. FULL PYTHON CODE
from AlgorithmImports import *
#endregion
# https://quantpedia.com/strategies/commodity-option-implied-volatility-strategy/
#
# The investment universe consists of 25 commodities.
# Commodities are sorted into four groups based on the 30-days implied volatility de-trended by the previous 12 months mean of implied volatility (see page 8 for exact formula).
# The “Low” (“High”) group contains the top 25% of all commodities with the lowest (highest) volatilities.
# The portfolio is long-short and buys commodities from the group “Low” and sells commodities from the group “High”.
# The portfolio is equally-weighted and is rebalanced on a monthly basis.
#
# QC Implementation:
import numpy as np
class CommodityOptionImpliedVolatilityStrategy(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)
self.min_expiry = 25
self.max_expiry = 35
self.period = 12 # need n of implied volatilities
self.iv = {} # storing implied volatilies in RollingWindow
self.contracts = {} # storing option contracts
self.tickers_symbols = {} # storing commodities symbols under their tickers
self.tickers = ['GLD', 'USO', 'UNG', 'SLV', 'DBA', 'DBB', 'PPLT', 'PALL']
self.next_expiry = None
for ticker in self.tickers:
# subscribe to commodity
security = self.AddEquity(ticker, Resolution.Minute)
# change normalization to raw to allow adding contracts
security.SetDataNormalizationMode(DataNormalizationMode.Raw)
# set fee model and leverage
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(5)
# get commodity symbol
symbol = security.Symbol
# store symbol under ticker
self.tickers_symbols[ticker] = symbol
# create RollingWindow for implied volatilities
self.iv[symbol] = RollingWindow[float](self.period)
self.day = -1
def OnData(self, data):
# rebalance daily
if self.day == self.Time.day:
return
self.day = self.Time.day
if self.next_expiry and self.Time.date() >= self.next_expiry.date():
self.Liquidate()
for symbol in self.tickers_symbols:
if symbol in self.contracts:
# remove expired contracts
for contract in self.contracts[symbol]:
self.RemoveSecurity(contract)
# remove contracts from dictionary
del self.contracts[symbol]
if not self.Portfolio.Invested:
for symbol in self.tickers_symbols:
if symbol not in self.contracts:
# get all contracts for current commodity
contracts = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
# get current price for commodity
underlying_price = self.Securities[symbol].Price
# get strikes from commodity contracts
strikes = [i.ID.StrikePrice for i in contracts]
if len(strikes) > 0:
# get at the money strike
atm_strike:float = min(strikes, key=lambda x: abs(x-underlying_price))
atm_calls:list = [i for i in contracts if i.ID.OptionRight == OptionRight.Call and
i.ID.StrikePrice == atm_strike and
self.min_expiry < (i.ID.Date - self.Time).days < self.max_expiry]
atm_puts:list = [i for i in contracts if i.ID.OptionRight == OptionRight.Put and
i.ID.StrikePrice == atm_strike and
self.min_expiry < (i.ID.Date - self.Time).days < self.max_expiry]
if len(atm_calls) and len(atm_puts):
# sort by expiry
atm_call = sorted(atm_calls, key = lambda x: x.ID.Date)[0]
atm_put = sorted(atm_puts, key = lambda x: x.ID.Date)[0]
self.next_expiry = min(atm_call.ID.Date, atm_put.ID.Date)
# add contracts
option = self.AddOptionContract(atm_call, Resolution.Minute)
option.PriceModel = OptionPriceModels.CrankNicolsonFD()
option.SetDataNormalizationMode(DataNormalizationMode.Raw)
option = self.AddOptionContract(atm_put, Resolution.Minute)
option.PriceModel = OptionPriceModels.CrankNicolsonFD()
option.SetDataNormalizationMode(DataNormalizationMode.Raw)
# store atm contracts by symbol
self.contracts[symbol] = [atm_call, atm_put]
iv_detrend = {} # storing detrend implied volatility for options
if data.OptionChains.Count != 0:
for kvp in data.OptionChains:
chain = kvp.Value
contracts = [x for x in chain]
# check if there are enough contracts for option
if len(contracts) < 2:
continue
atm_call_iv = None
atm_put_iv = None
# get ticker
ticker = chain.Underlying.Symbol.Value
# go through option contracts
for c in contracts:
if c.Right == OptionRight.Call:
# found atm call
atm_call_iv = c.ImpliedVolatility
else:
# found put option
atm_put_iv = c.ImpliedVolatility
if atm_call_iv and atm_put_iv:
# make mean from atm call implied volatility and atm put implied volatility
iv = (atm_call_iv + atm_put_iv) / 2
# get symbol based on ticker from option contract
commodity_symbol = self.tickers_symbols[ticker]
# check if there are enough data of mean implied volatilities
if self.iv[commodity_symbol].IsReady:
# calculate mean of previous mean implied volatilities
vol_mean = np.mean([x for x in self.iv[commodity_symbol]])
# calculate detrend implied volatility and store it by symbol
iv_detrend[commodity_symbol] = iv - vol_mean
# add current mean of implied volatility
self.iv[commodity_symbol].Add(iv)
# can't perform quintile selection
if len(iv_detrend) < 4:
self.Liquidate()
return
quintile = int(len(iv_detrend) / 4)
sorted_by_iv_detrend = [x[0] for x in sorted(iv_detrend.items(), key=lambda item: item[1])]
# go long smallest quintile
long = sorted_by_iv_detrend[:quintile]
# go short largest quintile
short = sorted_by_iv_detrend[-quintile:]
# trade execution
long_length = len(long)
short_length = len(short)
for symbol in long:
self.SetHoldings(symbol, 1 / long_length)
for symbol in short:
self.SetHoldings(symbol, -1 / short_length)
# custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))
VI. Backtest Performance