Quant BuffetRelax, Not Over Thinking

Active Collar Strategy

Log in to collect

Academic paper

An Update of 'Loosening Your Collar: Alternative Implementations of QQQ Collars': Credit Crisis and Out-of-Sample Performance

AuthorsEdward Szado; Thomas Schneeweis

Institute
  • Providence College
  • University of Massachusetts Amherst
  • ?University of Massachusetts Amherst - Isenberg School of Management

Strategy in a nutshell

The strategy maintains a 100% Nasdaq index position via QQQ ETF, writing 1-month calls each month and using premiums to buy 6-month puts. Call/put ratios and option moneyness are adjusted monthly based on momentum (SMA crossovers), volatility (VIX relative to MA), and macroeconomic trends (unemployment claims and NBER cycle). Option strikes vary between ATM and 5% OTM, creating a dynamically managed collar

Economic rationale

The strategy’s collar structure exchanges upside potential for downside protection, reshaping the return distribution of the Nasdaq position. Incorporating systematic factors—momentum, volatility, and macroeconomic trends—enhances risk-adjusted returns, providing a more favorable risk/return profile for investors.

Backtest performance

Annualised return12.52%
Volatility11.34%
Beta0.619
Sharpe ratio0.75
Sortino ratio0.444
Maximum drawdown-21.5%
Win rate53%

Full Python code

import numpy as np
from AlgorithmImports import *
from dateutil.relativedelta import relativedelta
class ActiveCollarStrategy(QCAlgorithm):
def Initialize(self):
 self.SetStartDate(2015, 1, 1)
 self.SetCash(100000)
 
 # collar settings
 self.targets = np.array([0.95, 1.05])   # initial target
 self.vix_signal = 0
 self.sma_signal_set = False
 self.vix_signal_set = False 
 self.macro_signal_set = False        
 
 option = self.AddOption("QQQ", Resolution.Minute)
 option.SetFilter(-60, +60, timedelta(0), timedelta(35))
 
 # index and sma
 data = self.AddEquity("QQQ", Resolution.Minute)
 data.SetLeverage(10)
 self.symbol = data.Symbol
 
 self.sma_5 = self.SMA(self.symbol, 5, Resolution.Daily)
 self.sma_50 = self.SMA(self.symbol, 50, Resolution.Daily)
 self.sma_150 = self.SMA(self.symbol, 150, Resolution.Daily)
 self.sma_200 = self.SMA(self.symbol, 200, Resolution.Daily)
 
 self.index_smas = [
     (None, self.sma_50),
     (self.sma_5, self.sma_150),
     (None, self.sma_200)
     ]
     
 # vix and SMAs
 self.vix = self.AddData(CBOE, 'VIX', Resolution.Daily).Symbol
 self.vix_sma_5 = self.SMA(self.vix, 5, Resolution.Daily)
 self.vix_std_5 = self.STD(self.vix, 5, Resolution.Daily)
 self.vix_sma_150 = self.SMA(self.vix, 150, Resolution.Daily)
 self.vix_std_150 = self.STD(self.vix, 150, Resolution.Daily)
 self.vix_sma_250 = self.SMA(self.vix, 250, Resolution.Daily)
 self.vix_std_250 = self.STD(self.vix, 250, Resolution.Daily)
 
 self.vix_sma_std = [
     (self.vix_sma_5, self.vix_std_5),
     (self.vix_sma_150, self.vix_std_150),
     (self.vix_sma_250, self.vix_std_250)
     ]
 # recession indicator, claims data and SMAs
 self.us_recession = self.AddData(FREDData, 'USREC', Resolution.Daily).Symbol     # monthly data  
 self.initial_claims = self.AddData(FREDData, 'ICSA', Resolution.Daily).Symbol    # weekly data
 self.claims_sma_10 = self.SMA(self.initial_claims, 10, Resolution.Daily)
 self.claims_sma_30 = self.SMA(self.initial_claims, 30, Resolution.Daily)
 self.claims_sma_40 = self.SMA(self.initial_claims, 40, Resolution.Daily)
 self.claims_smas = [
     self.claims_sma_10,
     self.claims_sma_30,
     self.claims_sma_40
     ]        
 
 self.SetWarmUp(250, Resolution.Daily)
 
 # Next expiry date.
 self.expiry_date = None
 self.recession_signal_lagged = RollingWindow[float](2)
 self.recession_signal_lagged.Add(-1)
 
 self.last_day = -1
 
def OnData(self, slice: Slice) -> None:
 # Open new trades only on market close.
 if not (self.Time.hour == 15 and self.Time.minute == 59):
     return
 
 last_update_date:Dict[str, datetime.date] = FREDData.get_last_update_date()
 # data stopped comming in
 if not all( self.Securities[x].GetLastData() and x.Value in last_update_date and self.Time.date() <= last_update_date[x.Value] for x in [self.us_recession, self.initial_claims] ):
     self.Liquidate()
     return
 # on option roll date
 if self.expiry_date:
     if self.Time.date() < self.expiry_date.date():
         return
     else:
         # update lagged recession signal
         if self.Securities.ContainsKey(self.us_recession):
             recession_signal = self.Securities[self.us_recession].Price
             self.recession_signal_lagged.Add(recession_signal)
 
         # SMA signal calculation - widened or tightened collar
         for sma_pair in self.index_smas:
             if sma_pair[1].IsReady:
                 self.sma_signal_set = True
                 
                 long_sma = sma_pair[1].Current.Value
                 
                 if sma_pair[0] is not None:
                     if sma_pair[0].IsReady:
                         short_sma = sma_pair[0].Current.Value
                         
                         if short_sma > long_sma:
                             self.targets += np.array([-0.01, 0.01])
                             # sma_signal += 1
                         else:
                             self.targets += np.array([+0.01, -0.01])
                             # sma_signal -= 1
                 else:
                     price = self.Securities[self.symbol].Price
                     if price > long_sma:
                         self.targets += np.array([-0.01, 0.01])
                         # sma_signal += 1
                     else:
                         # sma_signal -= 1
                         self.targets += np.array([+0.01, -0.01])
 
         # VIX signal calculation - quantity
         for sma_std in self.vix_sma_std:
             if sma_std[0].IsReady and sma_std[1].IsReady:
                 sma = sma_std[0].Current.Value
                 std = sma_std[1].Current.Value
                 current_vix = self.Securities[self.vix].Price
                 self.vix_signal_set = True
                 if current_vix > sma + 1*std:
                     self.vix_signal += 0.75
                 elif current_vix < sma - 1*std:
                     self.vix_signal += 1.25
                 
         # macroeconomic signal - colar shift
         if self.Securities.ContainsKey(self.initial_claims) and self.recession_signal_lagged.IsReady:
             recession_signal = self.recession_signal_lagged[1]
             if recession_signal != -1:
                 claims_value = self.Securities[self.initial_claims].Price
                 for claims_sma in self.claims_smas:
                     if claims_sma.IsReady:
                         self.macro_signal_set = True
                         if claims_value > claims_sma.Current.Value:
                             if recession_signal == 1:
                                 self.targets += np.array([0.01])
                             else:
                                 self.targets -= np.array([0.01])                
 
 for i in slice.OptionChains:
     chains = i.Value
     if not self.Portfolio.Invested:
         calls = list(filter(lambda x: x.Right == OptionRight.Call, chains))
         puts = list(filter(lambda x: x.Right == OptionRight.Put, chains))
         if not calls or not puts: return
     
         underlying_price = self.Securities[self.symbol].Price
         call_expiries = [i.Expiry for i in calls]
         call_strikes = [i.Strike for i in calls]
         # 1-month to expiration call
         call_expiry = min(call_expiries, key=lambda x: abs((x.date() - self.Time.date()).days - 30))
         put_expiries = [i.Expiry for i in puts]
         put_strikes = [i.Strike for i in puts]
         # 6-months to expiration put
         # put_expiry = min(put_expiries, key=lambda x: abs((x.date()-self.Time.date()).days-180))
         put_expiry = min(put_expiries, key=lambda x: abs((x.date()-self.Time.date()).days-30))  # one month expiration is used instead of 6 months
         # determine strikes
         put_strike = min(put_strikes, key = lambda x:abs(x - float(self.targets[0]) * underlying_price))    # changed by macro
         call_strike = min(call_strikes, key = lambda x:abs(x - float(self.targets[1]) * underlying_price))    # changed by macro
         
         put = [i for i in puts if i.Expiry == put_expiry and i.Strike == put_strike]
         call = [i for i in calls if i.Expiry == call_expiry and i.Strike == call_strike]
         
         if call_expiry:
             self.expiry_date = call_expiry   # store shorter expiry date
         
         if put and call:
             # All three signals were set to trade.
             if self.sma_signal_set and self.vix_signal_set and self.macro_signal_set:
                 options_q:int = int(self.Portfolio.TotalPortfolioValue / (underlying_price * 100))  # changed by vix
                 if options_q >= 1:
                     # buy index.
                     self.SetHoldings(self.symbol, 1)
                     
                     # sell call
                     self.Sell(call[0].Symbol, self.vix_signal)
                     
                     # buy put
                     self.Buy(put[0].Symbol, options_q)
                 
                 # monthly signal reset
                 self.vix_signal = 0
                 self.sma_signal_set = False
                 self.vix_signal_set = False 
                 self.macro_signal_set = False
                 self.targets = np.array([0.95, 1.05])
         else:
             pass
 invested = [x.Key for x in self.Portfolio if x.Value.Invested]
 if len(invested) == 1:
     self.Liquidate(self.symbol)
# Source: https://fred.stlouisfed.org/series/T10Y3M
class FREDData(PythonData):
def GetSource(self, config:SubscriptionDataConfig, date:datetime, isLiveMode:bool) -> SubscriptionDataSource:
 return SubscriptionDataSource(f'data.quantpedia.com/backtesting_data/economic/{config.Symbol.Value}.csv', SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
_last_update_date:Dict[str, datetime.date] = {}
@staticmethod
def get_last_update_date() -> Dict[str, datetime.date]:
return FREDData._last_update_date
def Reader(self, config:SubscriptionDataConfig, line:str, date:datetime, isLiveMode:bool) -> BaseData:
 data = FREDData()
 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=1)
 if split[1] != '.':
     data.Value = float(split[1])
 # store last update date
 if config.Symbol.Value not in FREDData._last_update_date:
     FREDData._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()
 if data.Time.date() > FREDData._last_update_date[config.Symbol.Value]:
     FREDData._last_update_date[config.Symbol.Value] = data.Time.date()
 
 return data