Skip to main content

vix futures term structure as regime filter — auto-switching theta vs momentum

late night. A. went to bed around 10:30, told me not to stay up too late. i said “just finishing something.” she gave me that look. it’s now 2 AM.

been building this regime-switching layer for six weeks. wanted to close it out and document it before i forget the reasoning.

the problem: wrong strategy in the wrong environment
#

my core edge is premium selling — iron condors on SPX, some cash-secured puts on QQQ and sector ETFs. when conditions are right, theta decay is basically clockwork. close at 50% profit, reset, repeat.

but that same strategy gets destroyed when volatility spikes. april 2025 was a lesson. april 2026 was another one. realized vol exploding past implied vol means every short premium position bleeds.

most people add an IV rank filter. that helps — it’s a backward-looking measure of whether premium is expensive. but i wanted something with more forward-looking signal. something that tells me what the options market itself expects to happen next, not just what already happened.

that’s where VIX futures term structure comes in.

what the term structure actually tells you
#

VIX is 30-day implied volatility. VIX futures are contracts on future VIX values. the relationship between near-term and next-month futures prices tells you something important about the market’s regime expectations.

contango (near-term futures cheaper than longer-dated): the market expects current volatility to stay contained. short-term uncertainty is lower than medium-term uncertainty. this is the environment where theta strategies thrive.

backwardation (near-term futures more expensive): the market is stressed right now. vol is elevated and the market expects it to eventually subside, but the near-term outlook is ugly. this is when you don’t want to be short premium.

the metric i care about:

slope = (vix_m2 - vix_m1) / vix_m1

where vix_m1 is front-month VIX futures price, vix_m2 is the next month out.

  • slope > +5%: deep contango → full theta deployment
  • +1% to +5%: mild contango → reduced size, cautious
  • -5% to +1%: neutral zone → minimal new positions
  • slope < -5%: backwardation → defensive, no new short-premium entries

this isn’t a novel concept — you’ll find it discussed extensively in academic quant finance. what i built is the automated plumbing to make it actually drive my real-time strategy selection.

data pipeline — thetadata + redis
#

i pull VIX futures settlement prices from ThetaData. they have clean Python client and solid historical data. the regime classification runs once daily, right after settlement.

import asyncio
from datetime import date, timedelta
from calendar import monthcalendar
import redis
import json


class VIXTermStructureFetcher:
    """Fetches VIX futures prices and computes term structure slope for regime classification."""

    VIX_ROOT = "/VX"  # ThetaData root symbol for VIX futures

    def __init__(self, redis_client: redis.Redis, username: str, password: str):
        from thetadata import ThetaClient
        self.client = ThetaClient(username=username, passwd=password)
        self.redis = redis_client

    async def fetch_front_months(self, target_date: date) -> dict[str, float | None]:
        """Fetch settlement prices for the front two VIX futures contracts."""
        expirations = self._get_next_expirations(target_date, count=2)
        contracts: dict[str, float | None] = {}

        async with self.client.connect():
            for label, exp_date in expirations.items():
                try:
                    from thetadata import DataType
                    data = await self.client.async_get_hist(
                        req=DataType.TRADE,
                        root=self.VIX_ROOT,
                        exp=exp_date,
                        start_date=target_date,
                        end_date=target_date,
                    )
                    if not data.empty:
                        # use last trade as settlement proxy
                        contracts[label] = float(data["price"].iloc[-1])
                    else:
                        contracts[label] = None
                except Exception as exc:
                    print(f"Failed to fetch {label} ({exp_date}): {exc}")
                    contracts[label] = None

        return contracts

    def _get_next_expirations(self, from_date: date, count: int) -> dict[str, date]:
        """Get next N monthly VIX futures expiration dates (Wed before 3rd Friday)."""
        expirations: dict[str, date] = {}
        year, month = from_date.year, from_date.month
        found = 0

        while found < count:
            exp = self._vix_expiry(year, month)
            if exp > from_date:
                expirations[f"m{found + 1}"] = exp
                found += 1
            month += 1
            if month > 12:
                month, year = 1, year + 1

        return expirations

    @staticmethod
    def _vix_expiry(year: int, month: int) -> date:
        """VIX futures expire on Wednesday before the 3rd Friday of the month."""
        cal = monthcalendar(year, month)
        fridays = [week[4] for week in cal if week[4] > 0]
        third_friday = date(year, month, fridays[2])
        return third_friday - timedelta(days=2)  # Wednesday before

    def compute_slope(self, contracts: dict[str, float | None]) -> float | None:
        """Compute (M2 - M1) / M1 term structure slope."""
        m1 = contracts.get("m1")
        m2 = contracts.get("m2")
        if m1 is None or m2 is None or m1 == 0:
            return None
        return (m2 - m1) / m1

    def classify_regime(self, slope: float) -> str:
        """Map slope to named market regime."""
        if slope > 0.05:
            return "CONTANGO_STEEP"    # theta harvest: full deployment
        elif slope > 0.01:
            return "CONTANGO_MILD"     # moderate opportunity: reduced size
        elif slope > -0.05:
            return "NEUTRAL"           # cautious: minimal new entries
        else:
            return "BACKWARDATION"     # defensive: no new short premium

    async def update_regime_state(self, target_date: date) -> dict:
        """Fetch, classify, and cache current regime to Redis."""
        contracts = await self.fetch_front_months(target_date)
        slope = self.compute_slope(contracts)

        if slope is None:
            print("Regime calculation failed — holding previous state")
            return {}

        regime = self.classify_regime(slope)
        state = {
            "date": target_date.isoformat(),
            "vix_m1": contracts.get("m1"),
            "vix_m2": contracts.get("m2"),
            "slope": round(slope, 4),
            "regime": regime,
        }

        self.redis.set("vix_regime_state", json.dumps(state))
        self.redis.expire("vix_regime_state", 86400 * 2)  # 2-day TTL

        print(f"[REGIME] {regime} | slope={slope:.3f} | M1={contracts.get('m1'):.2f} | M2={contracts.get('m2'):.2f}")
        return state

this runs at 4:15 PM ET every trading day via a cron job on the chicago colo. settled prices, fresh regime state in redis, done in under 10 seconds.

the strategy router
#

the strategy selection engine reads from redis before placing any new entries. each regime maps to a different config:

from dataclasses import dataclass
import redis
import json


@dataclass(frozen=True)
class StrategyConfig:
    name: str
    max_delta_per_leg: float       # how far OTM for short strikes
    target_dte: int                # days to expiration at entry
    profit_target_pct: float       # close at X% of max profit
    stop_loss_multiplier: float    # exit at X× credit received
    max_allocation_pct: float      # max account pct per position


REGIME_CONFIGS: dict[str, StrategyConfig] = {
    "CONTANGO_STEEP": StrategyConfig(
        name="iron_condor_wide",
        max_delta_per_leg=0.20,
        target_dte=35,
        profit_target_pct=0.50,
        stop_loss_multiplier=2.0,
        max_allocation_pct=0.12,
    ),
    "CONTANGO_MILD": StrategyConfig(
        name="iron_condor_narrow",
        max_delta_per_leg=0.15,
        target_dte=28,
        profit_target_pct=0.45,
        stop_loss_multiplier=1.8,
        max_allocation_pct=0.08,
    ),
    "NEUTRAL": StrategyConfig(
        name="cash_secured_puts_small",
        max_delta_per_leg=0.10,
        target_dte=21,
        profit_target_pct=0.40,
        stop_loss_multiplier=1.5,
        max_allocation_pct=0.05,
    ),
    "BACKWARDATION": StrategyConfig(
        name="defensive",
        max_delta_per_leg=0.05,
        target_dte=14,
        profit_target_pct=0.30,
        stop_loss_multiplier=1.0,
        max_allocation_pct=0.02,  # basically nothing new
    ),
}


class RegimeStrategyRouter:
    """Routes new trade entries to appropriate strategy config based on live VIX regime."""

    def __init__(self, redis_client: redis.Redis):
        self.redis = redis_client

    def get_active_config(self) -> tuple[StrategyConfig, dict]:
        """Return (config, raw_state) based on current cached regime."""
        raw = self.redis.get("vix_regime_state")
        if raw is None:
            # no cached state — fall back to neutral, don't blow up
            return REGIME_CONFIGS["NEUTRAL"], {"regime": "NEUTRAL", "slope": 0.0}

        state = json.loads(raw)
        regime = state.get("regime", "NEUTRAL")
        config = REGIME_CONFIGS.get(regime, REGIME_CONFIGS["NEUTRAL"])
        return config, state

    def should_open_new_position(self) -> bool:
        """Hard gate: block new entries in severe backwardation."""
        _, state = self.get_active_config()
        regime = state.get("regime", "NEUTRAL")
        slope = state.get("slope", 0.0)

        # full stop on new short premium in deep backwardation
        if regime == "BACKWARDATION" and slope < -0.10:
            print(f"[GATE] Blocked new entry — deep backwardation: slope={slope:.3f}")
            return False

        return True

    def log_decision(self, action: str, reason: str) -> None:
        """Append regime-tagged decision to Redis list for later analysis."""
        _, state = self.get_active_config()
        entry = {
            "action": action,
            "reason": reason,
            "regime": state.get("regime"),
            "slope": state.get("slope"),
        }
        self.redis.rpush("regime_decisions", json.dumps(entry))
        self.redis.ltrim("regime_decisions", -500, -1)  # keep last 500

every time my strategy engine considers a new entry, it calls should_open_new_position() first. if the regime is wrong, nothing enters the book. no manual override needed, no daily babysitting.

what the data actually shows
#

been tracking regime-tagged P&L since october 2024. ran ~19 months through the classifier retrospectively.

contango steep: +$1,340 avg daily P&L, 76% win rate across 91 trading days. backwardation: -$920 avg, 37% win rate across 11 days. this is why regime matters. it’s not that premium selling is a bad strategy — it’s a bad strategy in the wrong conditions.

the 91 vs 11 day split also tells you backwardation is relatively rare. when it hits, you just need to not be the person running unconstrained short vega into it.

the term structure over the past year
#

here’s what slope actually looked like across the timeframe i’ve been tracking. the april 2025 tariff chaos and april 2026 whipsaw both show up clearly.

april 2026 went negative during the tariff whipsaw. slope hit around -4% at the worst. the system caught it — new condor entries paused automatically for about 11 days. existing positions held, no new exposure added. when the slope recovered past flat, entries resumed at reduced size.

that decision alone probably saved $30k+ in losses versus running blind through the spike. hard to put an exact number on it but the math is straightforward if you look at what happened to short vega positions that week.

infrastructure: where this actually runs
#

the regime calculation runs on the chicago colo, not my san diego setup. there’s no latency requirement here — regime switching is a daily EOD decision, not intraday. i’m using the colo because it’s already there for execution and the connection to ThetaData is fast.

VIX futures settle at 3:30 PM CT via the SOQ process (special opening quotation is technically the morning calculation, but for settlement pricing i’m using EOD). the colo job runs at 4:15 PM CT:

# crontab on chicago colo
15 16 * * 1-5 /opt/strategies/venv/bin/python /opt/strategies/vix_regime/run_daily.py

the redis state gets replicated to san diego via a simple pub/sub channel. my morning review script reads both environments and flags any discrepancy. if chicago redis and san diego redis disagree on regime, it’s a connectivity issue and the safety fallback kicks in (default to NEUTRAL).

if you’re building something like this from scratch and don’t have a colo: you don’t need one. this whole pipeline runs fine on a $30/month VPS. the data latency for EOD regime classification is irrelevant. what matters is that the calculation runs before your strategy engine considers any new entries the next morning.

may so far
#

first week of may has been mild contango — slope sitting around +2% to +3%. that’s the “cautious” zone, so i’ve been running reduced size. up about $8k on the week across the full book. not a barn-burner but not losing.

april closed well. the regime filter handled the vol spike better than any prior month i’ve traded through. the april wrap post has the details if you missed it.

the full implementation is in my private repos. the code above is enough to reproduce it from scratch. the key insight isn’t the code — it’s the decision: don’t run the same strategy in all environments. the market tells you what it expects. listen to it.

dad was a VP of engineering at a biotech startup. systems thinker, built things to last. sometimes when i’m up at 2 AM deep in this stuff i wonder what he’d think of the redis architecture. he would’ve had fifty questions and probably redesigned half of it. that’s fine. i miss having someone to argue about systems design with.

anyway. going to bed.

-AK

Related

may cycle setup: scanning the iv surface, automating strike selection
2:30 AM. wednesday. april is basically wrapped. last weekly expiration cleared friday. monday was flat, tuesday had one small SPX position that ticked through on delta and I let it ride — closed today for +$1,100. running estimate: april MTD somewhere around +$16,500 when everything settles. YTD is going to land around +1.5%.
april theta harvest: weekly closed clean, colo queue backed up, thursday hit different
2:30 AM. friday night. A. made chicken marsala — she does it maybe once a month and I forget every time how good it is. ate around 7, she went back to her desk, lights off in the bedroom by midnight. apartment’s quiet. been staring at P&L since 11.
fixing the stale iv problem: thetadata websocket streaming for real-time greeks
2:30 AM friday. been at this since 9 PM. promised myself two weeks ago, right in the middle of the tariff chaos, that i’d actually fix the IV rank staleness issue. the signal quality scoring work was the band-aid — a composite gate that tells the system “this signal isn’t reliable right now.” it worked. it’s in production. but the underlying problem was unchanged: during the spike, my IV rank was being computed from options data that was 10-14 minutes old. the signal wasn’t wrong, technically. it was just answering a question about a market that no longer existed.
event risk throttle: dynamic exposure scaling based on vol regime
2:30 AM monday. Q2 week 2 starts in a few hours. Been sitting with something since Thursday when I posted the Q2 week 1 numbers. Said we were running at 60% position size - waiting for the health scoring system to validate before going full deployment.
april done. built a signal quality gate with LightGBM.
2:15 AM. saturday. april closed today. before I get into what I actually built this week I’ll do the quick numbers. april final: +$15,800. minor revision down from the +$16,500 estimate I had wednesday — a few iron condor legs settled a tick or two against on friday’s close, plus a small NQ position gave back $430 into the bell. nothing significant. still a clean month.
execution quality tracking: slippage attribution across 40 algo positions
2:45 AM monday. A. went to bed around midnight after spending the evening fighting a client’s postgres migration that kept deadlocking under load. she was frustrated, said goodnight, gave me a look that meant don’t be up all night. I said I wouldn’t be.