Skip to main content

vix term structure algo - contango/backwardation trading

been researching VIX term structure trades.

contango vs backwardation. predictable patterns.

finally got an algo working.

the concept
#

contango: front month VIX < back month VIX

normal state. markets calm. short VIX products decay.

backwardation: front month VIX > back month VIX

fear state. markets stressed. short VIX products rally.

the transition between states = edge.

the implementation
#

import numpy as np
import pandas as pd
from dataclasses import dataclass
from typing import Optional, Tuple, List
from datetime import datetime, timedelta
from enum import Enum

class TermStructureState(Enum):
    STEEP_CONTANGO = "steep_contango"      # VIX1 << VIX2, calm
    MILD_CONTANGO = "mild_contango"        # VIX1 < VIX2, normal
    FLAT = "flat"                          # VIX1 ≈ VIX2, transitional
    MILD_BACKWARDATION = "mild_backwardation"  # VIX1 > VIX2, caution
    STEEP_BACKWARDATION = "steep_backwardation"  # VIX1 >> VIX2, fear

@dataclass
class VIXTermStructure:
    timestamp: datetime
    vix_spot: float
    vix_1m: float  # front month future
    vix_2m: float  # second month future
    vix_3m: float  # third month future
    contango_1_2: float  # spread between months
    contango_2_3: float
    state: TermStructureState

class VIXTermStructureAlgo:
    def __init__(self,
                 steep_threshold: float = 0.10,  # 10% spread = steep
                 mild_threshold: float = 0.03,    # 3% spread = mild
                 flat_threshold: float = 0.01,    # 1% spread = flat
                 lookback_days: int = 20):
        self.steep_threshold = steep_threshold
        self.mild_threshold = mild_threshold
        self.flat_threshold = flat_threshold
        self.lookback_days = lookback_days
        self.history: List[VIXTermStructure] = []

    def calculate_term_structure(self,
                                  vix_spot: float,
                                  vix_1m: float,
                                  vix_2m: float,
                                  vix_3m: float) -> VIXTermStructure:
        """
        Calculate current term structure and classify state
        """
        contango_1_2 = (vix_2m - vix_1m) / vix_1m
        contango_2_3 = (vix_3m - vix_2m) / vix_2m

        # Classify state based on front spread
        if contango_1_2 > self.steep_threshold:
            state = TermStructureState.STEEP_CONTANGO
        elif contango_1_2 > self.mild_threshold:
            state = TermStructureState.MILD_CONTANGO
        elif contango_1_2 > -self.flat_threshold:
            state = TermStructureState.FLAT
        elif contango_1_2 > -self.mild_threshold:
            state = TermStructureState.MILD_BACKWARDATION
        else:
            state = TermStructureState.STEEP_BACKWARDATION

        return VIXTermStructure(
            timestamp=datetime.now(),
            vix_spot=vix_spot,
            vix_1m=vix_1m,
            vix_2m=vix_2m,
            vix_3m=vix_3m,
            contango_1_2=contango_1_2,
            contango_2_3=contango_2_3,
            state=state
        )

    def detect_state_transition(self,
                                 current: VIXTermStructure) -> Optional[dict]:
        """
        Detect when term structure state changes
        These transitions often signal regime shifts
        """
        if len(self.history) < 2:
            return None

        previous = self.history[-1]

        if previous.state == current.state:
            return None  # No transition

        # Map transitions to trading signals
        transition_signals = {
            # From calm to fear = go defensive
            (TermStructureState.STEEP_CONTANGO, TermStructureState.MILD_CONTANGO): {
                'signal': 'CAUTION',
                'action': 'reduce_short_vol',
                'strength': 0.3
            },
            (TermStructureState.MILD_CONTANGO, TermStructureState.FLAT): {
                'signal': 'WARNING',
                'action': 'close_short_vol',
                'strength': 0.6
            },
            (TermStructureState.FLAT, TermStructureState.MILD_BACKWARDATION): {
                'signal': 'DEFENSIVE',
                'action': 'add_long_vol_hedge',
                'strength': 0.8
            },
            (TermStructureState.MILD_BACKWARDATION, TermStructureState.STEEP_BACKWARDATION): {
                'signal': 'CRISIS',
                'action': 'full_defensive',
                'strength': 1.0
            },
            # From fear to calm = opportunities
            (TermStructureState.STEEP_BACKWARDATION, TermStructureState.MILD_BACKWARDATION): {
                'signal': 'RECOVERY_START',
                'action': 'scale_into_short_vol',
                'strength': 0.4
            },
            (TermStructureState.MILD_BACKWARDATION, TermStructureState.FLAT): {
                'signal': 'RECOVERY_BUILDING',
                'action': 'add_short_vol',
                'strength': 0.6
            },
            (TermStructureState.FLAT, TermStructureState.MILD_CONTANGO): {
                'signal': 'NORMAL_RETURNING',
                'action': 'full_short_vol_position',
                'strength': 0.8
            },
        }

        key = (previous.state, current.state)
        return transition_signals.get(key)

    def calculate_roll_yield(self, current: VIXTermStructure) -> float:
        """
        Calculate expected roll yield from contango
        Positive = favorable for short VIX
        Negative = unfavorable for short VIX
        """
        # Annualized roll yield
        monthly_roll = current.contango_1_2
        annualized = monthly_roll * 12

        return annualized

    def generate_signal(self) -> dict:
        """
        Generate trading signal based on term structure
        """
        if len(self.history) < self.lookback_days:
            return {'signal': 'INSUFFICIENT_DATA'}

        current = self.history[-1]
        transition = self.detect_state_transition(current)
        roll_yield = self.calculate_roll_yield(current)

        # Base signal from state
        state_signals = {
            TermStructureState.STEEP_CONTANGO: {
                'bias': 'SHORT_VOL',
                'confidence': 0.8,
                'expected_decay': 0.02  # 2% monthly
            },
            TermStructureState.MILD_CONTANGO: {
                'bias': 'SHORT_VOL',
                'confidence': 0.6,
                'expected_decay': 0.01
            },
            TermStructureState.FLAT: {
                'bias': 'NEUTRAL',
                'confidence': 0.3,
                'expected_decay': 0.0
            },
            TermStructureState.MILD_BACKWARDATION: {
                'bias': 'LONG_VOL',
                'confidence': 0.5,
                'expected_decay': -0.01
            },
            TermStructureState.STEEP_BACKWARDATION: {
                'bias': 'LONG_VOL',
                'confidence': 0.7,
                'expected_decay': -0.02
            },
        }

        base = state_signals[current.state]

        return {
            'timestamp': current.timestamp,
            'state': current.state.value,
            'bias': base['bias'],
            'confidence': base['confidence'],
            'roll_yield_annualized': roll_yield,
            'expected_decay': base['expected_decay'],
            'vix_spot': current.vix_spot,
            'contango_spread': current.contango_1_2,
            'transition': transition
        }

    def add_observation(self, vix_spot: float, vix_1m: float,
                        vix_2m: float, vix_3m: float):
        """Add new observation and update history"""
        ts = self.calculate_term_structure(vix_spot, vix_1m, vix_2m, vix_3m)
        self.history.append(ts)

        # Keep only lookback period
        if len(self.history) > self.lookback_days * 2:
            self.history = self.history[-self.lookback_days * 2:]

backtest results
#

period: jan 2023 - oct 2025

strategy: short SVXY when steep contango, long VXX when steep backwardation

returns:

  • term structure algo: +41.2%
  • buy and hold SPY: +28.4%
  • alpha: +12.8%

key stats:

  • sharpe: 1.38
  • max drawdown: -16.4%
  • win rate (monthly): 64%
  • avg contango roll yield captured: 8.2% annually

observations
#

contango persistence: 75% of trading days in contango

backwardation spikes: avg 3.2 events/year, avg duration 8 days

best alpha: transitions from backwardation → contango

deployment
#

currently paper trading.

will allocate 10% ($50k) after 30 more days of validation.

the NexusFi volatility trading discussions have some good insights on VIX products. helped with the term structure thresholds.


3:15am thursday. vix term structure algo implementation. contango = short vol, backwardation = long vol. state transitions signal regime changes. backtest +41.2% vs SPY +28.4% (2023-2025). sharpe 1.38. paper trading now, $50k allocation planned after 30 more days.

-AK

Related

earnings volatility filter - implementation and early results
been running the earnings volatility filter for a week now. early results are promising. the problem # earnings = binary events. stock moves 5-10% or nothing.
building volatility regime detection
need to stop trading when volatility spikes. building detection system. the problem # this week VIX spiked 18% in 2 days. my strategies got stopped out twice.
sector rotation algo - implementation with relative strength scoring
been working on a sector rotation algo. concept: own the strongest sectors, short the weakest. simple in theory. complex in implementation. the core idea # sectors rotate in predictable cycles.
fall volatility algo adaptation - regime detection update
first real trading days since vacation. volatility already picking up. VIX hit 16.2 today. time to adapt. the seasonal shift # summer algo settings don’t work in fall.
earnings volatility - how my algos adapt to quarterly chaos
earnings week chaos. GOOGL, TSLA, META all this week. how my algos handle it. the earnings problem # normal day: VIX 15, predictable ranges, clean signals
regime detection filter - why it failed march, python implementation fix
march disaster taught lesson. regime detection lagged. cost $6,690 before pausing. fixing implementation. what went wrong # my current filter: