Overall Statistics
Total Orders
160
Average Win
4.16%
Average Loss
-3.01%
Compounding Annual Return
11.798%
Drawdown
35.600%
Expectancy
0.072
Start Equity
1000000
End Equity
1112867.76
Net Profit
11.287%
Sharpe Ratio
0.279
Sortino Ratio
0.182
Probabilistic Sharpe Ratio
25.093%
Loss Rate
55%
Win Rate
45%
Profit-Loss Ratio
1.38
Alpha
0
Beta
0
Annual Standard Deviation
0.351
Annual Variance
0.123
Information Ratio
0.436
Tracking Error
0.351
Treynor Ratio
0
Total Fees
$16067.35
Estimated Strategy Capacity
$10000000.00
Lowest Capacity Asset
VX YPDDEQD90YQX
Portfolio Turnover
30.86%
#region imports
from AlgorithmImports import *
#endregion


general_setting = {
    "lookback": 60,
    "lookback_RESOLUTION": "HOUR",

    "ratio_method": "Regression",

    "Take_Profit_pct": 0.3,
    "Stop_Loss_pct": 0.08,

    "p_value_threshold_entry": 0.0001,
    "p_value_threshold_exit": 0.00001,
    "rollover_days": 2, 

}

Notebook too long to render.

from AlgorithmImports import *
from QuantConnect.DataSource import *

from config import general_setting
import pickle

import numpy as np
import pandas as pd
import math
import statsmodels.api as sm
from pandas.tseries.offsets import BDay
from pykalman import KalmanFilter
from statsmodels.tsa.stattools import coint, adfuller
from scipy.stats import jarque_bera

class CalendarSpread(QCAlgorithm):

    def initialize(self) -> None:
        self.SetTimeZone(TimeZones.NEW_YORK)

        self.set_start_date(2024, 1, 1) 
        # self.set_end_date(2024,9,10)
        self.set_cash(1000000) 
        self.universe_settings.asynchronous = True

        # self.zscore_df = {}
        # self.note1_price = {}
        # self.note2_price = {}


        # Requesting  data
        # Futures.Currencies.EUR
        # Futures.Currencies.MICRO_EUR
        # Futures.Financials.Y_2_TREASURY_NOTE
        # Futures.Financials.Y_5_TREASURY_NOTE
        # Futures.Indices.MICRO_NASDAQ_100_E_MINI
        # Futures.Indices.SP_500_E_MINI
        # Futures.Indices.VIX

        future_vix = self.add_future(Futures.Indices.VIX, resolution = Resolution.HOUR, extended_market_hours = True) 
        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
        future_vix.set_filter(0, 180)
        self.future_vix_symbol = future_vix.symbol

        self.first_vix_contract = None
        self.second_vix_contract = None
        self.third_vix_contract = None

        self.first_vix_expiry = None
        self.second_vix_expiry = None
        self.third_vix_expiry = None

        self.lookback  = general_setting['lookback']
        self.p_threshold_entry = general_setting['p_value_threshold_entry']
        self.p_threshold_exit = general_setting['p_value_threshold_exit']
        self.rollover_days = general_setting['rollover_days']

        self.wt_1 = None
        self.wt_2 = None

        self.roll_signal = False
        self.Margin_Call = False

        self.prev_cap = None

        self.large_diff = None
        self.backwardation = False

        self.diversion = None
        self.coefs = []

        self.entry1 = 1.557283
        self.entry2 = 2.118554
        self.entry3 = -0.948035
        self.entry4 = -1.825264

        self.exit1 = 0.824238
        self.exit2 = -0.471902

        self.ratio_60 = {}
        self.quantile25_60_30_pos = {}
        self.quantile50_60_30_pos = {}
        self.quantile75_60_30_pos = {}

        self.quantile25_60_30_neg = {}
        self.quantile50_60_30_neg = {}
        self.quantile75_60_30_neg = {}

    def stats(self):
        # Request Historical Data
        df_vix1 = self.History(self.first_vix_contract.symbol, timedelta(self.lookback), Resolution.HOUR).rename(columns = {'close':'first'})
        df_vix2 = self.History(self.second_vix_contract.symbol, timedelta(self.lookback), Resolution.HOUR).rename(columns = {'close':'second'})
        # df_vix3 = self.History(self.third_vix_contract.symbol,timedelta(self.lookback), Resolution.HOUR).rename(columns = {'close':'third'})

        df_merge = pd.merge(df_vix1, df_vix2, on = ['time'], how = 'inner')

        vix1_log = np.array(df_merge['first'].apply(lambda x: math.log(x))) 
        vix2_log = np.array(df_merge['second'].apply(lambda x: math.log(x))) 
        # vix3_log = np.array(df_Gold3.apply(lambda x: math.log(x))) 

        # 1st & 2nd

    
        X1 = sm.add_constant(vix1_log)
        Y1 = vix2_log
        model1 = sm.OLS(Y1, X1)
        results1 = model1.fit()
        sigma1 = math.sqrt(results1.mse_resid)
        slope1 = results1.params[1]
        intercept1 = results1.params[0]
        res1 = results1.resid
        zscore1 = res1/sigma1

        adf1 = adfuller(res1)

        p_value1 = adf1[1]

        # spread = res1[len(res1)-1]
        df_merge['spread'] = df_merge['second'] - df_merge['first']

        
        spread = np.array(df_merge['spread'])
        statistic, pvalue_jb = jarque_bera(spread)
        # test_passed1 = p_value1 <= self.p_threshold
        # self.debug(f"p value is {p_value1}")
        if pvalue_jb < 0.05:
            print("reject H0: Data do not follow Normal Distribution")
        else:
            print("cannot reject H0: Data follow Normal Distribution")

        return [p_value1, zscore1, slope1, spread, pvalue_jb]

    
    def on_data(self, slice: Slice) -> None:


        # Entry signal
        # if self.time.minute == 0 or self.time.minute ==10 or self.time.minute == 20 or self.time.minute==30 or self.time.minute == 40 or self.time.minute == 50:
        if self.roll_signal == False and self.time.hour < 17 and self.time.hour > 8:
            if not self.portfolio.Invested:

                chain = slice.futures_chains.get(self.future_vix_symbol)
                if chain:
                    contracts = [i for i in chain ]
                    
                    e = [i.expiry for i in contracts]
                    e = sorted(list(set(sorted(e, reverse = True))))
                    # e = [i.expiry for i in contracts if i.expiry- self.Time> timedelta(5)]
                    # self.debug(f"the first contract is {e[0]}, the length of e is {len(e)}")
                    # expiry = e[0]
                    
                    try:
                        self.first_vix_contract = [contract for contract in contracts if contract.expiry == e[0]][0]
                        self.second_vix_contract = [contract for contract in contracts if contract.expiry == e[1]][0]
                        # self.third_gold_contract = [contract for contract in contracts if contract.expiry == e[2]][0]
                        self.first_vix_expiry = e[0]
                        self.second_vix_expiry = e[1]
                        # self.third_gold_expiry = e[2]

                        stats = self.stats()
                        sigma = stats[3].std()
                        mean = stats[3].mean()
                        last_spread = stats[3][-1]
                        n = (last_spread-mean)/sigma
                        self.coefs.append(n)
                        self.ratio_60[self.time] = n
                        
                        if len(self.coefs) >= 24 * 30:
                            self.coefs = self.coefs[-24 * 30:]
                            self.pos_coefs = [i for i in self.coefs if i > 0]
                            self.neg_coefs = [i for i in self.coefs if i < 0]
                            if len(self.pos_coefs) > 24 * 10:
                                pos_quantile = np.quantile( self.pos_coefs, [0.25,0.5,0.75])
                                self.entry1 = pos_quantile[1]
                                self.entry2 = pos_quantile[2]
                                self.exit1 = pos_quantile[0]
                                self.quantile25_60_30_pos[self.time] = pos_quantile[0]
                                self.quantile50_60_30_pos[self.time] = pos_quantile[1]
                                self.quantile75_60_30_pos[self.time] = pos_quantile[2]

                            if len(self.neg_coefs) > 24 * 10:
                                neg_quantile = np.quantile( self.neg_coefs, [0.25,0.5,0.75])
                                self.entry3 = neg_quantile[1]
                                self.entry4 = neg_quantile[0]
                                self.exit2 = neg_quantile[2]
                                self.quantile25_60_30_neg[self.time] = neg_quantile[0]
                                self.quantile50_60_30_neg[self.time] = neg_quantile[1]
                                self.quantile75_60_30_neg[self.time] = neg_quantile[2]

                        
                        # if (self.first_vix_expiry.date() - self.time.date()).days > self.rollover_day:
                        self.trade_signal = True
                        
                        # else:
                        #     self.trade_signal = False

                        if self.trade_signal and ((self.first_vix_expiry.date() - self.time.date()).days > self.rollover_days):
                            
                            self.wt_1 = 1/(1+stats[2])
                            self.wt_2 = 1 - self.wt_1


                            # if stats[3]<0:
                            if  n > self.entry1 and (n < self.entry2):
                                self.set_holdings(self.first_vix_contract.symbol, -self.wt_1, tag = f'spread  = mean + {round(n,2)}*sigma (diversion)')
                                self.set_holdings(self.second_vix_contract.symbol,  self.wt_2, tag = f'spread = mean + {round(n,2)}*sigma  (diversion)')
                                self.prev_cap = self.portfolio.total_portfolio_value
                                self.large_diff = True

                            if  (n > self.entry2):

                                self.set_holdings(self.first_vix_contract.symbol, self.wt_1, tag = f'spread  = mean + {round(n,2)}*sigma (mean reversion)')
                                self.set_holdings(self.second_vix_contract.symbol,  -self.wt_2, tag = f'spread = mean + {round(n,2)}*sigma  (mean reversion)')
                                self.prev_cap = self.portfolio.total_portfolio_value
                                self.large_diff = True
                                # self.debug(f"enter position: z score is {stats[1][-1]}")

                            elif n < self.entry3 and n > self.entry4:

                                self.set_holdings(self.first_vix_contract.symbol, self.wt_1, tag = f'spread < mean - {round(abs(n),2)}*sigma (diversion)')
                                self.set_holdings(self.second_vix_contract.symbol, -self.wt_2, tag = f'spread < mean - {round(abs(n),2)}*sigma (diversion)')
                                self.prev_cap = self.portfolio.total_portfolio_value
                                self.large_diff = False
                                # self.debug(f"enter position: z score is {stats[1][-1]}")
                                self.diversion = True

                            elif n < self.entry4:
                                self.set_holdings(self.first_vix_contract.symbol, -self.wt_1, tag = f'spread < mean - {round(abs(n),2)}*sigma (mean reversion)')
                                self.set_holdings(self.second_vix_contract.symbol, self.wt_2, tag = f'spread < mean - {round(abs(n),2)}*sigma (mean reversion)')
                                self.prev_cap = self.portfolio.total_portfolio_value
                                self.large_diff = False
                                # self.debug(f"enter position: z score is {stats[1][-1]}")

                            self.trade_signal = False

                    except:
                        return

            else:
                # exit signal
                stats = self.stats()
                sigma = stats[3].std()
                mean = stats[3].mean()
                last_spread = stats[3][-1]
                n = (last_spread-mean)/sigma
                self.wt_1 = 1/(1+stats[2])
                self.wt_2 = 1 - self.wt_1
                self.coefs.append(n)
                self.ratio_60[self.time] = n
                
                if len(self.coefs) >= 24 * 30:
                    self.coefs = self.coefs[-24 * 30:]
                    self.pos_coefs = [i for i in self.coefs if i > 0]
                    self.neg_coefs = [i for i in self.coefs if i < 0]
                    if len(self.pos_coefs) > 24 * 10:
                        pos_quantile = np.quantile( self.pos_coefs, [0.25,0.5,0.75])
                        self.entry1 = pos_quantile[1]
                        self.entry2 = pos_quantile[2]
                        self.exit1 = pos_quantile[0]
                        self.quantile25_60_30_pos[self.time] = pos_quantile[0]
                        self.quantile50_60_30_pos[self.time] = pos_quantile[1]
                        self.quantile75_60_30_pos[self.time] = pos_quantile[2]

                    if len(self.neg_coefs) > 24 * 10:
                        neg_quantile = np.quantile( self.neg_coefs, [0.25,0.5,0.75])
                        self.entry3 = neg_quantile[1]
                        self.entry4 = neg_quantile[0]
                        self.exit2 = neg_quantile[2]
                        self.quantile25_60_30_neg[self.time] = neg_quantile[0]
                        self.quantile50_60_30_neg[self.time] = neg_quantile[1]
                        self.quantile75_60_30_neg[self.time] = neg_quantile[2]

                # self.zscore_df[self.time] = stats[1][-1]
                # self.note1_price[self.time] = self.Securities[self.first_vix_contract.symbol].Price
                # self.note2_price[self.time] = self.Securities[self.second_vix_contract.symbol].Price
                # Roll over

                if ((self.first_vix_expiry.date() - self.time.date()).days <= self.rollover_days and self.time.hour == 10 ):
                    self.roll_signal = True
                    if self.portfolio.total_portfolio_value>= self.prev_cap:
                        self.liquidate(tag = 'rollover; Win')
                    else:
                        self.liquidate(tag = 'rollover; Loss')
                    self.prev_cap = None
                    self.large_diff = None

                    return
                    
                # Take Profit / Stop Loss    
                # if self.prev_cap :
                #     if self.portfolio.total_portfolio_value> 1.1 * self.prev_cap:
                #         self.liquidate(tag = 'Take Profit')
                #         self.prev_cap = None
                #         self.large_diff = None
                #         return

                #     elif self.portfolio.total_portfolio_value< 0.93 * self.prev_cap:
                #         self.liquidate(tag = 'Stop Loss')
                #         self.prev_cap = None
                #         self.large_diff = None
                #         return

                if self.diversion == True:

                    if (n >  self.entry2 and self.large_diff == True):
                        if self.portfolio.total_portfolio_value>= self.prev_cap:
                            self.liquidate(tag = 'Diversion; Win')
                        else:
                            self.liquidate(tag = 'Diversion; Loss')

                        self.set_holdings(self.first_vix_contract.symbol, self.wt_1, tag = f'spread  = mean + {round(n,2)}*sigma (mean_revesion)')
                        self.set_holdings(self.second_vix_contract.symbol,  -self.wt_2, tag = f'spread = mean + {round(n,2)}*sigma (mean_reversion)')
                        self.prev_cap = self.portfolio.total_portfolio_value

                        self.large_diff = True
                        self.diversion = False

                    elif (n < self.entry4 and self.large_diff == False):
                        if self.portfolio.total_portfolio_value>= self.prev_cap:
                            self.liquidate(tag = 'Diversion; Win')
                        else:
                            self.liquidate(tag = 'Diversion; Loss')

                        self.set_holdings(self.first_vix_contract.symbol, -self.wt_1, tag = f'spread  = mean - {abs(round(n,2))}*sigma (mean_revesion)')
                        self.set_holdings(self.second_vix_contract.symbol,  self.wt_2, tag = f'spread = mean - {abs(round(n,2))}*sigma (mean_reversion)')
                        self.prev_cap = self.portfolio.total_portfolio_value
                        self.large_diff = False
                        self.diversion = False

                    # elif : 
                    #     if self.portfolio.total_portfolio_value>= self.prev_cap:
                    #         self.liquidate(tag = 'Diversion; Win')
                    #     else:
                    #         self.liquidate(tag = 'Diversion; Loss')

                    #     stats = self.stats()
                    #     self.zscore_df[self.time] = stats[1][-1]
                    #     self.note1_price[self.time] = self.Securities[self.first_vix_contract.symbol].Price
                    #     self.note2_price[self.time] = self.Securities[self.second_vix_contract.symbol].Price
                    #     sigma = stats[3].std()
                    #     mean = stats[3].mean()
                    #     last_spread = stats[3][-1]
                    #     n = (last_spread-mean)/sigma
                    #     self.set_holdings(self.first_vix_contract.symbol, self.wt_1, tag = f'spread  = mean + {round(n,2)}*sigma (mean_revesion)')
                    #     self.set_holdings(self.second_vix_contract.symbol,  -self.wt_2, tag = f'spread = mean + {round(n,2)}*sigma (mean_reversion)')
                    #     self.prev_cap = self.portfolio.total_portfolio_value

                    #     self.large_diff = True
                    #     # self.debug(f"exit position: z score is {stats[1][-1]}")
                    #     self.diversion = False

                else:
                    if ( n < self.exit1 and self.large_diff == True):
                        if self.portfolio.total_portfolio_value>= self.prev_cap:
                            self.liquidate(tag = 'Mean Reversion; Win')
                        else:
                            self.liquidate(tag = 'Mean Reversion; Loss')
                        
                        
                        self.diversion = None
                        self.prev_cap = None
                        self.large_diff = None


                        # self.debug(f"exit position: z score is {stats[1][-1]}")
                        
                    elif (n > self.exit2 and self.large_diff == False):
                        if self.portfolio.total_portfolio_value>= self.prev_cap:
                            self.liquidate(tag = 'Mean Reversion; Win')
                        else:
                            self.liquidate(tag = 'Mean Reversion; Loss')

                        self.prev_cap = None
                        self.large_diff = None
                        self.diversion = None

                # if not self.large_diff:
                #     if n > 0:
                #         if self.portfolio.total_portfolio_value>= self.prev_cap:
                #             self.close = self.liquidate(tag = 'Wrong Direction (n > 0); Win')
                #         else:
                #             self.close = self.liquidate(tag = 'Wrong Direction (n > 0); Loss')
                #         return


                # if self.large_diff:
                #     if n < -0.3:
                #         if self.portfolio.total_portfolio_value>= self.prev_cap:
                #             self.close = self.liquidate(tag = 'Wrong Direction (n < 0); Win')
                #         else:
                #             self.close = self.liquidate(tag = 'Wrong Direction (n < 0); Loss')
                #         return



        else:

            stats = self.stats()

            # self.plot('z_score_plot','z_score',stats[1][-1] )            
            # self.plot('p_value_plot','p_value', stats[0])

            if self.first_vix_expiry.date() < self.time.date():
                self.roll_signal = False


        # if self.zscore_df:
        #     df = pd.DataFrame.from_dict(self.zscore_df, orient='index',columns=['zscore'])
        #     file_name = 'CalendarSpread/zscore_df'
        #     self.object_store.SaveBytes(file_name, pickle.dumps(df))


        # if self.note1_price:
        #     df = pd.DataFrame.from_dict(self.note1_price, orient='index',columns=['price1'])
        #     file_name = 'CalendarSpread/note1_df'
        #     self.object_store.SaveBytes(file_name, pickle.dumps(df))

        # if self.ratio_60:
        #     df = pd.DataFrame.from_dict(self.ratio_60, orient='index',columns=['ratio'])
        #     file_name = 'CalendarSpread/ratio_60'
        #     self.object_store.SaveBytes(file_name, pickle.dumps(df))

        # if self.quantile25_60_30_pos:
        #     df = pd.DataFrame.from_dict(self.quantile25_60_30_pos, orient='index',columns=['quantile25_pos'])
        #     file_name = 'CalendarSpread/quantile25_60_30_pos'
        #     self.object_store.SaveBytes(file_name, pickle.dumps(df))


        # if self.quantile50_60_30_pos:
        #     df = pd.DataFrame.from_dict(self.quantile50_60_30_pos, orient='index',columns=['quantile50_pos'])
        #     file_name = 'CalendarSpread/quantile50_60_30_pos'
        #     self.object_store.SaveBytes(file_name, pickle.dumps(df))

        # if self.quantile75_60_30_pos:
        #     df = pd.DataFrame.from_dict(self.quantile75_60_30_pos, orient='index',columns=['quantile75_pos'])
        #     file_name = 'CalendarSpread/quantile75_60_30_pos'
        #     self.object_store.SaveBytes(file_name, pickle.dumps(df))

        # if self.quantile25_60_30_neg:
        #     df = pd.DataFrame.from_dict(self.quantile25_60_30_neg, orient='index',columns=['quantile25_neg'])
        #     file_name = 'CalendarSpread/quantile25_60_30_neg'
        #     self.object_store.SaveBytes(file_name, pickle.dumps(df))

        # if self.quantile50_60_30_neg:
        #     df = pd.DataFrame.from_dict(self.quantile50_60_30_neg, orient='index',columns=['quantile50_neg'])
        #     file_name = 'CalendarSpread/quantile50_60_30_neg'
        #     self.object_store.SaveBytes(file_name, pickle.dumps(df))

        # if self.quantile75_60_30_neg:
        #     df = pd.DataFrame.from_dict(self.quantile75_60_30_neg, orient='index',columns=['quantile75_neg'])
        #     file_name = 'CalendarSpread/quantile75_60_30_neg'
        #     self.object_store.SaveBytes(file_name, pickle.dumps(df))




    def OnOrderEvent(self, orderEvent):

        
        if orderEvent.Status != OrderStatus.Filled:
            return
        

        # Webhook Notification    
        symbol = orderEvent.symbol
        price = orderEvent.FillPrice
        quantity = orderEvent.quantity
        a = { "text": f"[Calendar Arbitrage Paper order update] \nSymbol: {symbol} \nPrice: {price} \nQuantity: {quantity}" }
        payload = json.dumps(a)
        self.notify.web("https://hooks.slack.com/services/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", payload)


    def on_margin_call(self, requests):
        self.debug('Margin Call is coming')
        self.Margin_Call =  True

        a = { "text": f"[Calendar Spread Margin Call update]Margin Call is coming" }
        payload = json.dumps(a)
        self.notify.web("https://hooks.slack.com/services/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", payload)

        return requests

    def OnOrderEvent(self, orderEvent):

        
        if orderEvent.Status != OrderStatus.Filled:
            return

        if self.Margin_Call:
            qty = orderEvent.quantity
            symbol = orderEvent.symbol
            
            self.Margin_Call = False
            self.debug(f'Hit margin call, the qty is {qty}')

            if symbol == self.first_vix_contract.symbol:
                self.debug(f'if come here, symbol is {symbol}, qty is {qty}')
                self.market_order(self.second_vix_contract.symbol, -qty)

            if symbol == self.second_vix_contract.symbol:
                self.debug(f'if come here, symbol is {symbol}, qty is {qty}')
                self.market_order(self.first_vix_contract.symbol, -qty)

            # self.liquidate(tag = 'margin call')