
The strategy trades international ETF options, buying low implied volatility return terciles and selling high ones, rebalancing monthly, exploiting mispricings in implied volatility for systematic returns.
ASSET CLASS: options | REGION: Global | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: International, Volatility, Arbitrage
I. STRATEGY IN A NUTSHELL
The strategy trades international ETF options using implied volatility returns, calculated as 1 minus the ratio of prior-year realized volatility to current ATM implied volatility. ATM straddles are ranked daily and grouped into three terciles, with a long-short portfolio buying low-volatility (cheap) options and selling high-volatility (expensive) options. Portfolios are rebalanced monthly on the fourth Friday.
II. ECONOMIC RATIONALE
Volatility deviations across international option markets create exploitable mispricings. The strategy captures these inefficiencies, delivering strong risk-adjusted returns, low volatility, and neutral equity exposure, with opportunities largely untapped by hedge funds focused on domestic markets.
III. SOURCE PAPER
International Volatility Arbitrage [Click to Open PDF]
Adriano Tosi.Wellington Management
<Abstract>
Are options on exchange-traded products (ETPs) and indexes consistently priced internationally? The cross-section of international option returns exhibits a mispricing by sorting on ex-ante volatility returns. In addition, selling international ETP options and buying their corresponding index options commands a positive risk premium. Both empirical findings are economically large and pervasive internationally, whereas they are comparably small domestically. While volatility hedge funds are exposed towards domestic option products, they neglect the possibility of engaging in foreign volatility arbitrage. These findings entail that alpha seekers may expand their horizon towards international derivatives which at first glance are similar, but institutionally are not.


IV. BACKTEST PERFORMANCE
| Annualised Return | 16.38% |
| Volatility | 8.93% |
| Beta | 0.004 |
| Sharpe Ratio | 1.83 |
| Sortino Ratio | N/A |
| Maximum Drawdown | N/A |
| Win Rate | 44% |
V. FULL PYTHON CODE
from AlgorithmImports import *
import calendar
import datetime
#endregion
class InternationalVolatilityArbitrage(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(1000000)
self.min_expiry = 20
self.max_expiry = 90
self.percentage_traded = 0.2 # traded percentage of the portfolio
self.period = 12 * 21 # need 12 months of daily prices
self.prices = {} # storing daily prices
self.contracts = {} # storing option contracts
self.tickers_symbols = {} # storing symbols under their tickers
self.tickers = [
"EWA", # iShares MSCI Australia Index ETF
"EWO", # iShares MSCI Austria Investable Mkt Index ETF
"EWK", # iShares MSCI Belgium Investable Market Index ETF
"EWZ", # iShares MSCI Brazil Index ETF
"EWC", # iShares MSCI Canada Index ETF
"FXI", # iShares China Large-Cap ETF
"EWQ", # iShares MSCI France Index ETF
"EWG", # iShares MSCI Germany ETF
"EWH", # iShares MSCI Hong Kong Index ETF
"EWI", # iShares MSCI Italy Index ETF
"EWJ", # iShares MSCI Japan Index ETF
"EWM", # iShares MSCI Malaysia Index ETF
"EWW", # iShares MSCI Mexico Inv. Mt. Idx
"EWN", # iShares MSCI Netherlands Index ETF
"EWS", # iShares MSCI Singapore Index ETF
"EZA", # iShares MSCI South Africe Index ETF
"EWY", # iShares MSCI South Korea ETF
"EWP", # iShares MSCI Spain Index ETF
"EWD", # iShares MSCI Sweden Index ETF
"EWL", # iShares MSCI Switzerland Index ETF
"EWT", # iShares MSCI Taiwan Index ETF
"THD", # iShares MSCI Thailand Index ETF
"EWU", # iShares MSCI United Kingdom Index ETF
"SPY", # SPDR S&P 500 ETF
]
for ticker in self.tickers:
# subscribe to etf
security = self.AddEquity(ticker, Resolution.Minute)
# change normalization to raw to allow adding etf contracts
security.SetDataNormalizationMode(DataNormalizationMode.Raw)
# set fee model and leverage
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(5)
# get etf symbol
symbol = security.Symbol
# store etf symbol under etf ticker
self.tickers_symbols[ticker] = symbol
# create RollingWindow for daily prices
self.prices[symbol] = RollingWindow[float](self.period)
self.fourth_friday = self.FindFourthFriday(self.Time.year, self.Time.month)
self.day = -1
self.selection_flag = False
def OnData(self, data):
# execute once a day
if self.day == self.Time.day:
return
self.day = self.Time.day
# update RollingWindow with daily prices
for _, symbol in self.tickers_symbols.items():
# update RollingWindow with daily prices
if symbol in data and data[symbol]:
self.prices[symbol].Add(data[symbol].Value)
if data.OptionChains.Count >= 3 and self.selection_flag:
# stop rebalance
self.selection_flag = False
self.Liquidate()
vol_metric = {} # storing volatility differences for each etf
for kvp in data.OptionChains:
chain = kvp.Value
# get etf symbol
symbol = self.tickers_symbols[chain.Underlying.Symbol.Value]
# get contracts
contracts = [x for x in chain]
# check if there are enough contracts for option and daily prices are ready
if len(contracts) < 2 or not self.prices[symbol].IsReady or symbol not in self.contracts:
continue
# get call and put implied volatility
call_iv, put_iv = self.GetImpliedVolatilities(contracts)
if call_iv and put_iv:
# make mean from call implied volatility and put implied volatility
iv = (call_iv + put_iv) / 2
# get historical volatility
hv = self.GetHistoricalVolatility(self.prices[symbol])
# store metrics 1 - ratio between historical and implied volatility
vol_metric[symbol] = 1 - (hv / iv)
# can't perform selection when there aren't enough contracts
if len(vol_metric) > 3:
# perform selection
tercile = int(len(vol_metric) / 3)
sorted_by_vol_metric = [x[0] for x in sorted(vol_metric.items(), key=lambda item: item[1])]
# short expensive (high) tercile
short = sorted_by_vol_metric[-tercile:]
# long cheap (low) tercile
long = sorted_by_vol_metric[:tercile]
# trade execution
self.Liquidate()
# trade long
self.TradeOptions(long, True)
# trade short
self.TradeOptions(short, False)
# rebalance on fourth friday
if self.fourth_friday <= self.Time.date():
next_month = 1 if self.Time.month == 12 else self.Time.month + 1
year = self.Time.year + 1 if next_month == 1 else self.Time.year
# find fourth friday of next month
self.fourth_friday = self.FindFourthFriday(year, next_month)
# remove old contracts on rebalance
for _, symbol in self.tickers_symbols.items():
if symbol in self.contracts:
# remove Contracts object for current symbol
del self.contracts[symbol]
# perform new selection
self.selection_flag = True
self.Liquidate()
# subscribe to new contracts
for _, symbol in self.tickers_symbols.items():
# don't subscribe contracts of already subscribed symbols
if symbol in self.contracts:
continue
# get all contracts for current etf
contracts = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
# get current price for etf
underlying_price = self.Securities[symbol].Price
# get strikes from commodity future contracts
strikes = [i.ID.StrikePrice for i in contracts]
# can't filter contracts, if there isn't any strike price
if len(strikes) <= 0:
continue
# filter calls and puts contracts with one month expiry
calls, puts = self.FilterContracts(strikes, contracts, underlying_price)
# make sure, there is at least one call and put contract
if len(calls) and len(puts):
# sort by expiry and select contracts with latest expiry
call = sorted(calls, key=lambda x: x.ID.Date)[0]
put = sorted(puts, key=lambda x: x.ID.Date)[0]
subscriptions = self.SubscriptionManager.SubscriptionDataConfigService.GetSubscriptionDataConfigs(call.Underlying)
if subscriptions:
# add call contract
self.AddContract(call)
# add put contract
self.AddContract(put)
# store contracts with expiry date under etf symbol
self.contracts[symbol] = Contracts(underlying_price, [call, put])
def FindFourthFriday(self, year, month):
date = datetime.datetime(year, month, 1).date()
week_day = date.weekday()
# Taken from https://stackoverflow.com/questions/28680896/how-can-i-get-the-3rd-friday-of-a-month-in-python
calendar_obj = calendar.Calendar(firstweekday=week_day)
monthcal = calendar_obj.monthdatescalendar(year, month)
fridays = [day for week in monthcal for day in week if \
day.weekday() == calendar.FRIDAY and \
day.month == month]
fourth_friday = fridays[3] if len(fridays) > 3 else fridays[-1]
return fourth_friday
def FilterContracts(self, strikes, contracts, underlying_price):
''' filter call and put contracts from contracts parameter '''
''' return call and put contracts '''
# Straddle
call_strike:float = min(strikes, key=lambda x: abs(x-underlying_price))
put_strike = call_strike
calls = [] # storing call contracts
puts = [] # storing put contracts
for contract in contracts:
# check if contract has one month expiry
if self.min_expiry < (contract.ID.Date - self.Time).days < self.max_expiry:
# check if contract is call
if contract.ID.OptionRight == OptionRight.Call and contract.ID.StrikePrice == call_strike:
calls.append(contract)
# check if contract is put
elif contract.ID.OptionRight == OptionRight.Put and contract.ID.StrikePrice == put_strike:
puts.append(contract)
# return filtered calls and puts with one month expiry
return calls, puts
def AddContract(self, contract):
''' subscribe option contract, set price mondel and normalization mode '''
option = self.AddOptionContract(contract, Resolution.Minute)
option.PriceModel = OptionPriceModels.CrankNicolsonFD()
def GetImpliedVolatilities(self, contracts):
''' retrieve implied volatility of contracts from contracts parameteres '''
''' returns call and put implied volatility '''
call_iv = None
put_iv = None
# go through option contracts
for c in contracts:
if c.Right == OptionRight.Call:
# found call option
call_iv = c.ImpliedVolatility
else:
# found put option
put_iv = c.ImpliedVolatility
return call_iv, put_iv
def GetHistoricalVolatility(self, rolling_window_prices):
''' calculate historical volatility based on daily prices in rolling_window_prices parameter '''
prices = np.array([x for x in rolling_window_prices])
returns = (prices[:-1] - prices[1:]) / prices[1:]
return np.std(returns)
def TradeOptions(self, symbols, long_flag):
''' on long signal buy call and put option contract '''
''' on short signal sell call and put option contract '''
length = len(symbols)
# trade etf's call and put contracts
for symbol in symbols:
# get call and put contract
contracts = self.contracts[symbol].contracts
call = contracts[0]
put = contracts[1]
# get underlying price
underlying_price = self.contracts[symbol].underlying_price
options_q = int(((self.Portfolio.TotalPortfolioValue*self.percentage_traded) / length) / (underlying_price * 100))
if self.Securities[call].IsTradable and self.Securities[put].IsTradable:
if long_flag:
self.Buy(call, options_q)
self.Buy(put, options_q)
else:
self.Sell(call, options_q)
self.Sell(put, options_q)
class Contracts():
def __init__(self, underlying_price, contracts):
self.underlying_price = underlying_price
self.contracts = contracts
# custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))