Overall Statistics
Total Trades
42
Average Win
30.59%
Average Loss
-20.13%
Compounding Annual Return
-47.379%
Drawdown
28.900%
Expectancy
0.200
Net Profit
-18.640%
Sharpe Ratio
-1.55
Probabilistic Sharpe Ratio
1.545%
Loss Rate
52%
Win Rate
48%
Profit-Loss Ratio
1.52
Alpha
-0.694
Beta
0.709
Annual Standard Deviation
0.288
Annual Variance
0.083
Information Ratio
-2.983
Tracking Error
0.267
Treynor Ratio
-0.63
Total Fees
$364.00
from QuantConnect.Securities.Option import OptionPriceModels
from datetime import timedelta
import decimal as d
from my_calendar import last_trading_day

class DeltaHedgedStraddleAlgo(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2020, 7, 1)
        #self.SetEndDate(2020, 7, 30)
        self.SetCash(25000)
        
        # ----------------------------------------------------------------------
        # Algo params
        # ----------------------------------------------------------------------
        self.MAX_EXPIRY = 10 # max num of days to expiration => will select expiries upto 2 weeks out
        self.MIN_EXPIRY = 5 # max num of days to expiration => will select expiries at least 1 week out
        self._no_K = 50 # number of strikes below/above ATM for universe => No need to change this
        self.tkr = "SPY"  # "SPY", "GOOG", ... 
        self.quantity = 50 # number of credit spreads to trade
        
        consolidatedBar = TradeBarConsolidator(timedelta(hours=1)) # options data is minute by default, so hourly bar, change timedelta param for different bar lengths
        
        # bollinger band params
        self.bbLookback = 20
        self.bbBand = 2
        
        # profit targets
        self.profitTargetA,self.profitFactorA = 0.0,0.5 # profit factor the fraction of open to close i.e 0.5 = 50%
        self.profitTargetB = -0.05
        self.hedgeRatio = 1 / 20 # set as fraction for hedges per spread; set zero for no hedging
        
        self.shortDelta, self.hedgeDelta = -0.2,-0.3 # target deltas for short leg and hedge
        # ----------------------------------------------------------------------

        # add underlying Equity 
        self.resol = Resolution.Minute   # Resolution.Minute .Hour  .Daily
        equity = self.AddEquity(self.tkr, self.resol)  
        equity.SetDataNormalizationMode(DataNormalizationMode.Raw) # IMPORTANT: default
        
        self.equity_symbol = equity.Symbol
        
        # Consolidate to 1hr bars and create bollingber band indicator
        self.SubscriptionManager.AddConsolidator(self.tkr, consolidatedBar)
        consolidatedBar.DataConsolidated += self.OnconsolidatedBar
        
        self.bb = BollingerBands(self.bbLookback,2) 
        self.RegisterIndicator(self.tkr, self.bb, consolidatedBar)
        
        # Add options
        option = self.AddOption(self.tkr, self.resol)
        self.option_symbol = option.Symbol

        # set our strike/expiry filter for this option chain
        option.SetFilter(self.UniverseFunc) # option.SetFilter(-2, +2, timedelta(0), timedelta(30))
        self.SetSecurityInitializer(lambda x: x.SetBuyingPowerModel(CustomBuyingPowerModel(self)))
        
        # for greeks and pricer (needs some warmup) - https://github.com/QuantConnect/Lean/blob/21cd972e99f70f007ce689bdaeeafe3cb4ea9c77/Common/Securities/Option/OptionPriceModels.cs#L81
        option.PriceModel = OptionPriceModels.CrankNicolsonFD()  # both European & American, automatically
        
        # Set warm up with 30 trading days to warm up the underlying volatility model
        self.SetWarmUp(30, Resolution.Daily)
        
        # vars for option contract objects
        self.shortLeg,self.longLeg,self.hedgeLeg = None, None, None
            
    
    
    
    def OnconsolidatedBar(self,sender,bar):
        if self.IsWarmingUp: return
        #self.Debug(str(self.Time) + " " + str(bar))
        
        # 2. sell puts if current price below bollinger band
        if not self.Portfolio.Invested:
            if self.bb.LowerBand.Current.Value > self.Securities[self.equity_symbol].Close:
                
                # select contracts
                self.get_contracts(self.CurrentSlice)
                if (not self.shortLeg) or (not self.longLeg) or (not self.hedgeLeg): return
                
                # get quantities for trading
                credit = self.Securities[self.shortLeg.Symbol].Price - self.Securities[self.longLeg.Symbol].Price
                
                qnty = self.quantity
                hedgeQnty = qnty * self.hedgeRatio # hedge (optional)
                self.Log(f"Thresh exceeded: Price {self.Securities[self.equity_symbol].Close} < {self.bb.LowerBand.Current.Value:.2f}. Credit sell to open value ${credit:.2f}")
                
                self.MarketOrder(self.shortLeg.Symbol,-qnty)
                self.MarketOrder(self.longLeg.Symbol,qnty)
                if hedgeQnty > 0: self.MarketOrder(self.hedgeLeg.Symbol,hedgeQnty)
                
                # set profit target credit value
                self.profitTargetA = - credit * self.profitFactorA
                
                
        
    def OnData(self, slice):
        if self.Portfolio.Invested:
            if (not self.shortLeg) or (not self.longLeg) or (not self.hedgeLeg): return
            # 3. Check exit conditions
            credit = self.Securities[self.shortLeg.Symbol].Price - self.Securities[self.longLeg.Symbol].Price
            #1. unrealized profit $ = arbitrary number
            if credit < self.profitTargetA:
                self.Liquidate()
                self.Log(f"Profit Target A hit {credit:.2f} < {self.profitTargetA:.2f}")
                
            #2. unrealized profit $ = -0.05
            if credit < self.profitTargetB:
                self.Liquidate()
                self.Log(f"Profit Target B hit {credit:.2f} < {self.profitTargetB:.2f}")
                
            #3. 0.5 delta on the short leg
            shortDelta = self.get_delta(slice,self.shortLeg) if self.get_delta(slice,self.shortLeg) is not None else 0
            if shortDelta < - 0.5:
                self.Liquidate()
                self.Log(f"Stop loss hit {shortDelta:.2f} < - 0.5 ( {credit:.2f} )")
            
            #4. Today is expiration day
            if self.Time.date() == self.last_trading_day and self.Time.hour == 15:
                self.Liquidate()
                self.Log(f"Liquidating on expiration ( {credit:.2f} )")
                
    def get_contracts(self, slice):
        for kvp in slice.OptionChains:
            if kvp.Key != self.option_symbol: continue

            chain = kvp.Value   # option contracts for each 'subscribed' symbol/key 
            
            spot_price = chain.Underlying.Price
        #   self.Log("spot_price {}" .format(spot_price))
            
            # 1. get the contract series expiring on Friday
            contracts_by_T = [ i for i in chain if i.Expiry.date().weekday() == 4 ]
            
            #contracts_by_T = sorted(chain, key = lambda x: x.Expiry, reverse = False)
            #contracts_by_T = list(filter(lambda x: x.Expiry >= self.Time + timedelta(days = 30), chain))
            if not contracts_by_T: return
            
            #self.Log(f"Chain {[i.Expiry.date() for i in contracts_by_T]}")
        
            self.expiry = contracts_by_T[-1].Expiry.date() # furthest
            self.last_trading_day = last_trading_day(self.expiry)
            
            # sort contracts by strike
            sorted_contracts = sorted(contracts_by_T, key = lambda x: x.Strike, reverse = False)

            #self.Log("Expiry used: {} and shortest {}" .format(self.expiry, contracts_by_T[-1].Expiry.date()) )

            # get all puts below 50 delta
            puts = [i for i in sorted_contracts \
                     if i.Right == OptionRight.Put and i.Strike <= spot_price]
            
            # get short leg - the first strike below delta 
            self.shortLeg = [ i for i in puts if i.Greeks.Delta >= self.shortDelta ][-1] if puts else None
            
            # get long leg - the strike $2 below short leg
            self.longLeg = [ i for i in puts if i.Strike <= self.shortLeg.Strike - 2 ][-1] if puts else None
            
            # get hedge leg - 
            self.hedgeLeg = [ i for i in puts if i.Greeks.Delta >= self.hedgeDelta ][-1] if puts else None
            
            self.Log(f"Deltas: short {self.shortLeg.Greeks.Delta}, long {self.longLeg.Greeks.Delta}")
            
    def get_delta(self, slice,leg):
        """
        Take a data slice and an option leg as args; return the delta of that leg in slice
        """

        if not leg: return
        
        for kvp in slice.OptionChains:
            if kvp.Key != self.option_symbol: continue
            chain = kvp.Value   # option contracts for each 'subscribed' symbol/key 
            traded_contracts = filter(lambda x: x.Symbol == leg.Symbol, chain)
            if not traded_contracts: self.Log("No traded cointracts"); return
        
            deltas = [i.Greeks.Delta for i in traded_contracts]
            
            return deltas[0]
            
    def UniverseFunc(self, universe):
        return universe.IncludeWeeklys()\
                    .Strikes(-self._no_K, 0)\
                    .Expiration(timedelta(self.MIN_EXPIRY), timedelta(self.MAX_EXPIRY))
    
    # ----------------------------------------------------------------------
    # Other ancillary fncts
    # ----------------------------------------------------------------------   
    def OnOrderEvent(self, orderEvent):
    #   self.Log("Order Event -> {}" .format(orderEvent))
        pass

# necessitated by incorrect default margining of credit spreads in QC
class CustomBuyingPowerModel(BuyingPowerModel):
    def __init__(self, algorithm):
        self.algorithm = algorithm

    def HasSufficientBuyingPowerForOrder(self, parameters):
        # custom behavior: this model will assume that there is always enough buying power
        hasSufficientBuyingPowerForOrderResult = HasSufficientBuyingPowerForOrderResult(True)
        #self.algorithm.Log(f"CustomBuyingPowerModel: {hasSufficientBuyingPowerForOrderResult.IsSufficient}")
        return hasSufficientBuyingPowerForOrderResult
# ------------------------------------------------------------------------------
# Business days
# ------------------------------------------------------------------------------
from datetime import timedelta #, date
from pandas.tseries.holiday import (AbstractHolidayCalendar,    # inherit from this to create your calendar
                                    Holiday, nearest_workday,   # to custom some holidays
                                    #
                                    USMartinLutherKingJr,       # already defined holidays
                                    USPresidentsDay,            # "     "   "   "   "   "
                                    GoodFriday,
                                    USMemorialDay,              # "     "   "   "   "   "
                                    USLaborDay,
                                    USThanksgivingDay           # "     "   "   "   "   "
                                    )


class USTradingCalendar(AbstractHolidayCalendar):
    rules = [
      Holiday('NewYearsDay', month=1, day=1, observance=nearest_workday),
      USMartinLutherKingJr,
      USPresidentsDay,
      GoodFriday,
      USMemorialDay,
      Holiday('USIndependenceDay', month=7, day=4, observance=nearest_workday),
      USLaborDay,
      USThanksgivingDay,
      Holiday('Christmas', month=12, day=25, observance=nearest_workday)
    ]

# TODO: to be tested
def last_trading_day(expiry):
    # American options cease trading on the third Friday, at the close of business 
    # - Weekly options expire the same day as their last trading day, which will usually be a Friday (PM-settled), [or Mondays? & Wednesdays?]
    # 
    # SPX cash index options (and other cash index options) expire on the Saturday following the third Friday of the expiration month. 
    # However, the last trading day is the Thursday before that third Friday. Settlement price Friday morning opening (AM-settled).
    # http://www.daytradingbias.com/?p=84847
    
    dd = expiry     # option.ID.Date.date()
    
    # if expiry on a Saturday (standard options), then last trading day is 1d earlier 
    if dd.weekday() == 5:
        dd -= timedelta(days=1)   # dd -= 1 * BDay()
        
    # check that Friday is not an holiday (e.g. Good Friday) and loop back
    while USTradingCalendar().holidays(dd, dd).tolist():    # if list empty (dd is not an holiday) -> False
        dd -= timedelta(days=1) 
        
    return dd