Quant BuffetRelax, Not Over Thinking

The U.S. Dollar and Variance Risk Premia Imbalances

Log in to collect

Academic paper

The U.S. Dollar and Variance Risk Premia Imbalances

AuthorsAnders Merrild Posselt

Institute
  • DKAarhus University
  • ?Aarhus University - CREATES

Strategy in a nutshell

The strategy focuses on developed market currencies: Australia, Canada, France, Germany, Italy, Japan, Netherlands, Switzerland, and the United Kingdom, benchmarked against the US dollar.

-Data: Spot and forward rates (2000–2019) in USD per foreign currency unit, sourced from Thomson Reuters.

Variance Risk Premium (VP): Estimated as the difference between option-implied variance (risk-neutral) and realized variance (physical), following Bollerslev et al. (2009) and related literature.

VPI (Variance Premium Imbalance): Defined as US VP minus the average VP across developed countries. For the eurozone, VP is a GDP-weighted average of member countries.

Dollar Factor: Constructed as an equally weighted long basket of developed currencies against USD.

Trading Rule:

If VPI > 0 → Go long USD (short basket).

If VPI < 0 → Go short USD (long basket).

Rebalancing: Monthly.

Economic rationale

The VPI reflects investor demand for variance hedging, i.e., the cost of variance swaps.

Acts as a proxy for SDF (stochastic discount factor) volatility, the less-studied driver of exchange rate dynamics.

An increase in VPI predicts dollar appreciation, while a decrease predicts depreciation.

Findings hold both in-sample and out-of-sample, providing exploitable trading signals.

Backtest performance

Annualised return4.1%
Volatility8.2%
Beta-0.125
Sharpe ratio0.5
Sortino ratio0.081
Win rate55%

Full Python code

from AlgorithmImports import *

class TheUSDollarandVarianceRiskPremiaImbalances(QCAlgorithm):

def Initialize(self):
 self.SetStartDate(2010, 1, 1)
 self.SetCash(100000)
 
 self.min_expiry:int = 20
 self.max_expiry:int = 35
 
 self.period:int = 22
 self.prices:Dict[Symbol, RollingWindow] = {}
 self.contracts:Dict[str, tuple] = {} # storing option contracts
 self.next_expiry:datetime.datetime.date = None

 self.us:str = 'SPY'  # SPDR S&P 500 ETF

 self.euroarea_gdp:Dict[str, str] = {
     'EWQ' : 'FRA_GDPD',  # iShares MSCI France Index ETF
     'EWG' : 'DEU_GDPD',  # iShares MSCI Germany Index ETF
     'EWI' : 'ITA_GDPD',  # iShares MSCI Italy Index ETF
     'EWN' : 'NLD_GDPD',  # iShares MSCI Netherlands Index ETF
 }

 # subscribe GDP data
 for ticker, gdp_symbol in self.euroarea_gdp.items():
     self.AddData(GDPData, gdp_symbol, Resolution.Daily)

 self.rest_of_the_world:List[str] = [
     'EWA',  # iShares MSCI Australia Index ETF
     'EWC',  # iShares MSCI Canada Index ETF
     'EWJ',  # iShares MSCI Japan Index ETF
     'EWL',  # iShares MSCI Switzerland Index ETF
     'EWU',  # iShares MSCI United Kingdom Index ETF
 ]

 self.tickers:List[str] = [self.us] + list(self.euroarea_gdp.keys()) + self.rest_of_the_world

 for ticker in self.tickers:
     # subscribe to country
     security:Equity = self.AddEquity(ticker, Resolution.Daily)
     
     # change normalization to raw to allow adding option contracts
     security.SetDataNormalizationMode(DataNormalizationMode.Raw)

     # set fee model and leverage
     security.SetFeeModel(CustomFeeModel())
     security.SetLeverage(5)
     
     # create RollingWindow for daily prices
     self.prices[ticker] = RollingWindow[float](self.period)
 
def OnData(self, data: Slice) -> None:
 # update RollingWindow with daily prices
 for ticker in self.tickers:
     if ticker in data and data[ticker]:
         self.prices[ticker].Add(data[ticker].Value)

 # check date of expiry
 if self.next_expiry and self.Time.date() >= self.next_expiry.date():
     self.Liquidate()

     for ticker in self.tickers:
         if ticker in self.contracts:
             # remove expired contracts
             self.RemoveSecurity(self.contracts[ticker][0])
             self.RemoveSecurity(self.contracts[ticker][1])
             
             # remove contracts from dictionary
             del self.contracts[ticker]
 
 gdp_last_update_date:Dict[str, datetime.date] = GDPData.get_last_update_date()

 # set selection flag when there're no active contracts or every active contract expired
 if len(self.contracts) == 0:
     for ticker in self.tickers:
         if ticker not in self.contracts:
             if ticker in self.euroarea_gdp and not (self.Securities.ContainsKey(self.euroarea_gdp[ticker]) and self.Securities[self.euroarea_gdp[ticker]].Price != 0):
                 # ticker is in euroarea, yet does not have GDP data subscribed
                 continue

             # get all contracts for current country
             contracts:List[Symbol] = list(self.OptionChainProvider.GetOptionContractList(ticker, self.Time))
             # get current price for country etf
             underlying_price:float = self.Securities[ticker].Price
             
             # get strikes from country contracts
             strikes:List[float] = [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[Symbol] = [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[Symbol] = [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) != 0 and len(atm_puts) != 0:
                     # sort by expiry
                     atm_call:List[Symbol] = sorted(atm_calls, key = lambda x: x.ID.Date)[0]
                     atm_put:List[Symbol] = 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:Option = self.AddOptionContract(atm_call, Resolution.Daily)
                     option.PriceModel = OptionPriceModels.CrankNicolsonFD()
                     
                     option:Option = self.AddOptionContract(atm_put, Resolution.Daily)
                     option.PriceModel = OptionPriceModels.CrankNicolsonFD()
                     
                     # store atm contracts by symbol
                     self.contracts[ticker] = (atm_call, atm_put)
     
 if not self.Portfolio.Invested:
     VP:Dict[str, float] = {} # variance risk premium

     if data.OptionChains.Count != 0:
         for kvp in data.OptionChains:
             chain = kvp.Value
             contracts:List = [x for x in chain]

             # check if there are enough contracts for option
             if len(contracts) < 2 or not self.prices[ticker].IsReady:
                 continue
             
             atm_call_iv:float = None
             atm_put_iv:float = None
             # get ticker
             ticker:str = 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:
                 # risk-neutral measure of the stock market return variance
                 risk_neutral_variance:float = ((atm_call_iv + atm_put_iv) / 2) ** 2 # calculated from average atm put and call iv

                 # physical measure of the stock market return variance
                 prices:np.ndarray = np.array(list(self.prices[ticker]))
                 returns:np.ndarray = prices[:-1] / prices[1:] - 1
                 physical_variance:float = (np.std(returns) * np.sqrt(252)) ** 2

                 # variance risk premium calculation
                 VP[ticker] = risk_neutral_variance - physical_variance
                 
         # at least 2 tickers have VP data stored and one of them is US
         if len(VP) > 1 and self.us in VP:
             # we construct a Euro VP as the GDP weighted average of the VPs available for the Eurozone countries
             euroarea_vp_tickers:List[str] = [x for x in VP if x in self.euroarea_gdp]
             rest_of_the_world_tickers:List[str] = [x for x in VP if x in self.rest_of_the_world]

             # latest known GDP data is used
             total_euroarea_gdp:float = sum([self.Securities[self.euroarea_gdp[x]].Price for x in euroarea_vp_tickers])
             euroarea_vp:float = sum([VP[x] * (self.Securities[self.euroarea_gdp[x]].Price / total_euroarea_gdp) for x in euroarea_vp_tickers])

             vp_mean:float = np.mean([vp for ticker, vp in VP.items() if ticker in rest_of_the_world_tickers] + [euroarea_vp])
             VPI:float = VP[self.us] - vp_mean
             
             # the timing strategy is long (short) in the Dollar factor, and short (long) the USD, whenever VPI is positive (negative)
             traded_tickers:List[str] = euroarea_vp_tickers + rest_of_the_world_tickers
             traded_len:int = len(traded_tickers)

             if VPI > 0:
                 for ticker in traded_tickers:
                     self.SetHoldings(ticker, 1 / traded_len)
             else:
                 for ticker in traded_tickers:
                     self.SetHoldings(ticker, -1 / traded_len)

# custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
 fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
 return OrderFee(CashAmount(fee, "USD"))

# source: https://data.oecd.org/gdp/gross-domestic-product-gdp.htm
class GDPData(PythonData):
_last_update_date:Dict[str, datetime.date] = {}

@staticmethod
def get_last_update_date() -> Dict[str, datetime.date]:
return GDPData._last_update_date

def GetSource(self, config, date, isLiveMode):
 return SubscriptionDataSource(f'data.quantpedia.com/backtesting_data/economic/gdp/{config.Symbol.Value}.csv', SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)

def Reader(self, config, line, date, isLiveMode):
 data = GDPData()
 data.Symbol = config.Symbol
 if not line[0].isdigit(): return None
 split = line.split(';')
 
 # Parse the CSV file's columns into the custom data class
 data.Time = datetime.strptime(split[0], "%Y-%m-%d") + relativedelta(months=2)
 data.Value = float(split[1])

 # store last update date
 if config.Symbol.Value not in GDPData._last_update_date:
     GDPData._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()

 if data.Time.date() > GDPData._last_update_date[config.Symbol.Value]:
     GDPData._last_update_date[config.Symbol.Value] = data.Time.date()
 
 return data