Overall Statistics
Total Orders
1850
Average Win
2.56%
Average Loss
-2.26%
Compounding Annual Return
4.361%
Drawdown
67.300%
Expectancy
0.075
Start Equity
10000000
End Equity
17304322.84
Net Profit
73.043%
Sharpe Ratio
0.216
Sortino Ratio
0.206
Probabilistic Sharpe Ratio
0.024%
Loss Rate
50%
Win Rate
50%
Profit-Loss Ratio
1.13
Alpha
0.128
Beta
-0.49
Annual Standard Deviation
0.388
Annual Variance
0.151
Information Ratio
-0.015
Tracking Error
0.433
Treynor Ratio
-0.171
Total Fees
$3609727.16
Estimated Strategy Capacity
$28000000.00
Lowest Capacity Asset
VX YNNC8UBM7FMX
Portfolio Turnover
31.44%
#region imports
from AlgorithmImports import *

from collections import deque
import statsmodels.api as sm
#endregion

class TermStructureOfVixAlgorithm(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2012, 1, 1)
        self.set_end_date(2024, 11, 1)
        self.set_cash(10_000_000)

        # Add a security initializer so we can trade ES contracts immediately.
        self.set_security_initializer(
            BrokerageModelSecurityInitializer(self.brokerage_model, FuncSecuritySeeder(self.get_last_known_prices))
        )

        self._vix_index = self.add_index("VIX")
        self._vx_future = self.add_future(Futures.Indices.VIX, leverage=1)
        self._vx_future.set_filter(14, 60)
        self._vx_future.prices = pd.Series()
        self._vx_future.trading_days_until_expiry = pd.Series()
        self._vx_future.invested_contract = None

        self._es_future = self.add_future(
            Futures.Indices.SP_500_E_MINI,
            data_normalization_mode=DataNormalizationMode.BACKWARDS_RATIO,
            data_mapping_mode=DataMappingMode.OPEN_INTEREST,
            contract_depth_offset=0
        )
        self._es_future.prices = pd.Series()

        self._get_liquid_price = False
        self.schedule.on(
            self.date_rules.every_day(self._vx_future.symbol),
            self.time_rules.before_market_close(self._vx_future.symbol, 15),
            lambda: setattr(self, '_get_liquid_price', True)
        )

        self._daily_roll_entry_threshold = 0.05 # The paper uses +/-0.1
        self._daily_roll_exit_threshold = 0.025 # The paper uses +/-0.05
        self._lookback = 252 # Not defined in the paper.
        self._enable_hedge = False #True

        self.set_warm_up(timedelta(self._lookback*1.5))

    def on_data(self, data):
        vx_price = None
        selected_contract = None
        trading_days_until_expiry = None
        if self._get_liquid_price:
            chain = data.future_chains.get(self._vx_future.symbol)
            # Get the VIX Future price.
            if chain:
                for contract in chain: # Is the chain always sorted by expiry?
                    # Select the contract with at least 10 trading days until expiry.
                    trading_days_until_expiry = self._trading_days_until_expiry(contract)
                    if trading_days_until_expiry < 10:
                        continue
                    # Wait for the selected contract to have a narrow spread.
                    if (contract.ask_price - contract.bid_price > 0.1 and
                        # If the spread isn't narrow before market close, use the prices right before market close.
                        self._vx_future.exchange.hours.is_open(self.time + timedelta(minutes=1), extended_market_hours=False)):
                        break
                    vx_price = (contract.ask_price + contract.bid_price) / 2
                    selected_contract = contract
        if not vx_price:
            return
        self._get_liquid_price = False

        # "The spot VIX values and the mini-S&P 500 futures prices used in the study are averages of the open and closing quotes during the minute in which the above conditions for VIX futures contracts hold." (p.6)
        vix_price = (self._vix_index.open + self._vix_index.close) / 2
        es_price = (self._es_future.open + self._es_future.close) / 2 # "the front rollover adjusted mini-S&P 500 futures prices" (p.5)

        self.plot('ES', 'Price', es_price)
        self.plot('VIX', 'Future Price', vx_price)
        self.plot('VIX', 'Spot Price', vix_price)

        basis = (vx_price - vix_price)
        in_contango = basis > 0
        self.plot('In Contango?', 'Value', int(in_contango))

        daily_roll = basis / trading_days_until_expiry
        self.plot('Daily Roll', 'Value', daily_roll)

        # "the size of the mini-S&P futures hedge is based on out of sample hedge ratio 
        # estimates. The hedge ratios are constructed from regressions of VIX futures price changes on a
        # constant and on contemporaneous percentage changes of the front mini-S&P 500 futures contract
        # both alone and multiplied by the number of business days that the VIX futures contract is from
        # settlement" (pp. 11-12)
        t = self.time
        self._es_future.prices.loc[t] = es_price
        self._vx_future.prices.loc[t] = vx_price
        self._vx_future.trading_days_until_expiry.loc[t] = trading_days_until_expiry

        self._es_future.prices = self._es_future.prices.iloc[-self._lookback:]
        self._vx_future.prices = self._vx_future.prices.iloc[-self._lookback:]
        self._vx_future.trading_days_until_expiry = self._vx_future.trading_days_until_expiry.iloc[-self._lookback:]

        # Wait until there are sufficient samples to fit the regression model.
        if len(self._vx_future.prices) < self._lookback or self.is_warming_up:
            return
        vx_changes = self._vx_future.prices.pct_change()[1:]
        es_returns = self._es_future.prices.pct_change()[1:]
        X = pd.DataFrame(
            {
                'es_returns': es_returns,
                'es_returns*tts': es_returns * self._vx_future.trading_days_until_expiry[1:]
            },
            index=es_returns.index
        )
        X = sm.add_constant(X)
        y = vx_changes
        model = sm.OLS(y, X).fit()
        beta_1 = model.params[1]
        beta_2 = model.params[2]

        # "The β1 coefficient should be significantly negative in light of the tendency of VIX futures prices to move inversely to equity returns." (p. 12)
        self.plot('Regression B1', 'Value', beta_1)
        # "The β2 coefficient should be significantly positive if the reaction of VIX futures prices to equity returns is more subdued the further contracts are from settlement." (p. 12)
        self.plot('Regression B2', 'Value', beta_2)

        # Calculate the hedge ratio. Equation 4 on page 13.
        hedge_ratio = (
            beta_1 * 1_000
            + beta_2 * trading_days_until_expiry * 1_000
        ) / (0.01 * es_price * 50)
        # "The average hedge ratio is close to one mini-S&P futures contract per VIX futures contract" (p. 13) with a range from about 0.5 to 2.
        self.plot('Hedge Ratio', 'Value', hedge_ratio)

        # Look for trade entries.
        # "Short VIX futures positions are entered when the VIX futures basis is in
        # contango and the daily roll exceeds .10 VIX futures points ($100 per day) and long VIX futures
        # positions are entered when the VIX futures basis is in backwardation and the daily roll is less
        # than -.10 VIX futures points" (p. 14)
        if not self.portfolio.invested:
            if in_contango and daily_roll > self._daily_roll_entry_threshold:
                weight = -0.5 # Enter short VIX Future trade.
            elif not in_contango and daily_roll < -self._daily_roll_entry_threshold:
                weight = 0.5 # Enter long VIX Future trade.
            else:
                return
            self.set_holdings(selected_contract.symbol, weight, tag=f"Entry: Expires on {selected_contract.expiry}")
            self._vx_future.invested_contract = selected_contract

            # Add ES hedge.
            if self._enable_hedge:
                es_contract_symbols = self.future_chain_provider.get_future_contract_list(self._es_future.symbol, self.time)
                es_contract_symbol = sorted(
                    [s for s in es_contract_symbols if s.id.date >= self._vx_future.invested_contract.expiry], 
                    key=lambda symbol: symbol.id.date
                )[0]
                self.add_future_contract(es_contract_symbol)
                self.set_holdings(es_contract_symbol, weight * -np.sign(hedge_ratio))
            return
        
        # Look for trade exits.
        # Exit long VIX Future trade when daily_roll < self._daily_roll_exit_threshold or when the contract expires next day.
        # Exit short VIX Future trade when daily_roll > -self._daily_roll_exit_threshold or when the contract expires next day.
        trading_days_until_expiry = self._trading_days_until_expiry(self._vx_future.invested_contract)
        holding = self.portfolio[self._vx_future.invested_contract.symbol]
        tag = ""
        if trading_days_until_expiry <= 1:
            tag = f"Exit: Expires in {trading_days_until_expiry} trading day(s)"
        elif holding.is_long and daily_roll < self._daily_roll_exit_threshold:
            tag = f"Exit: daily roll ({daily_roll}) < threshold"
        elif holding.is_short and daily_roll > -self._daily_roll_exit_threshold:
            tag = f"Exit: daily roll ({daily_roll}) > -threshold"
        if tag:
            self.liquidate(tag=tag)
            self._vx_future.invested_contract = None
            
    def _trading_days_until_expiry(self, contract):
        return len(list(self.trading_calendar.get_trading_days(self.time, contract.expiry - timedelta(1))))