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.

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 Return12.66%
Volatility18.48%
Beta-0.079
Sharpe Ratio 0.69
Sortino Ratio0.342
Maximum DrawdownN/A
Win Rate50%

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

Leave a Reply

Discover more from Quant Buffet

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

Continue reading