Skip to main content

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.

True. But it glossed over the part where I set that 60% manually. I decided it. Which means I also have to manually decide when to flip back to 100%. And “until conditions feel better” is not a system - it’s a vibe.

The tariff uncertainty pushing into IV this week made it obvious this needs to be solved properly. Not just for now. For every elevated-vol period going forward. So last night I built an actual event risk throttle.


the problem with fixed sizing
#

every strategy I run has a base position size derived from fractional Kelly. SPX iron condors at X contracts per $100k notional, crypto momentum at Y% of the crypto allocation. these numbers were optimized assuming “normal” market conditions. specifically:

  • bid-ask spreads on SPX options within roughly 2x historical average
  • implied vol accurately pricing 30-day realized vol with positive forward premium
  • execution slippage within 15% of simulated backtest assumptions

when vol spikes, all three break simultaneously:

spreads on SPX options can widen 3-4x. I’ve seen 4x in a bad session. that’s not slippage anymore, that’s a structural cost the backtest never modeled.

the vol surface distorts - skew goes extreme, term structure can invert, and the model inputs that generated your edge become unreliable. the fair-value calculation you did at IV rank 40 doesn’t hold at IV rank 75.

slippage blows out because market makers widen quotes and liquidity at weird strikes evaporates. fills you expected at mid+$0.05 are coming in at mid+$0.30.

running full size when your modeling assumptions are invalid isn’t brave. it’s trading outside the validity range of your edge calculation. the Kelly number was computed for a different market than the one you’re trading in.

the fix: build a quantitative regime classifier that tracks whether current conditions match your assumptions, and scale exposure proportionally. not “reduce when scared.” reduce when conditions deviate from what the model was built for.


building the vol regime classifier
#

three inputs drive the classification:

1. VIX spot vs trailing 252-day percentile

not just raw VIX level - percentile relative to recent history. VIX at 22 after a year averaging 15-18 reads differently than VIX at 22 coming off a 30+ spike. the percentile captures the “how elevated is this vs what we’ve been used to” context.

been following the VIX and volatility discussion thread on NexusFi since early 2023 - 137 replies of genuinely useful vol regime analysis from traders who’ve been through real drawdowns. worth reading before you build anything vol-sensitive.

2. IV rank on the specific instruments I trade

VIX is S&P-wide. what I need is IV rank on SPX, QQQ, and the sector ETFs my condors target. sector vol diverges from index vol more than people think, especially during earnings periods or macro events hitting specific sectors.

3. ATM IV vs 30-day realized vol spread

this one is the most critical for premium selling. if ATM IV is running 8+ points above realized, there’s structural edge in selling premium. if realized vol is catching or exceeding implied, you’re not being compensated for the risk. you’re actually selling cheap options into rising vol. bad trade.

four regime buckets:

regime VIX range IV rank scalar
CALM < 15 < 30 100%
ELEVATED 15–22 30–60 75%
STRESSED 22–32 60–80 50%
CRISIS > 32 > 80 25%

key design choice: hysteresis. regimes downgrade immediately when conditions worsen (fast is right - size down fast when vol spikes). upgrades require N consecutive “better” readings before exposure increases. prevents thrashing at regime boundaries when VIX is oscillating around 22.

from dataclasses import dataclass
from enum import Enum
import numpy as np
import redis
import asyncio
from datetime import datetime


class VolRegime(Enum):
    CALM = "calm"
    ELEVATED = "elevated"
    STRESSED = "stressed"
    CRISIS = "crisis"


@dataclass
class RegimeState:
    regime: VolRegime
    vix_spot: float
    vix_percentile: float   # 0-100
    spx_iv_rank: float      # 0-100
    iv_rv_spread: float     # ATM IV minus 30-day realized vol (annualized pts)
    exposure_scalar: float  # 0.0-1.0
    timestamp: datetime


REGIME_EXPOSURE_MAP = {
    VolRegime.CALM:     1.00,
    VolRegime.ELEVATED: 0.75,
    VolRegime.STRESSED: 0.50,
    VolRegime.CRISIS:   0.25,
}

UPGRADE_PERIODS_REQUIRED = 3   # 3 consecutive better readings to upgrade
DOWNGRADE_PERIODS_REQUIRED = 1  # immediate on worsening


class VolRegimeClassifier:

    def __init__(self, redis_client: redis.Redis, ts_conn):
        self.redis = redis_client
        self.ts = ts_conn
        self._consecutive_upgrades = 0
        self._current_regime = VolRegime.ELEVATED  # start conservative

    async def classify(
        self,
        vix_spot: float,
        spx_iv_rank: float,
        realized_vol_30d: float,
        atm_iv: float
    ) -> RegimeState:
        vix_pct = await self._compute_vix_percentile(vix_spot)
        iv_rv_spread = atm_iv - realized_vol_30d

        raw = self._compute_raw_regime(vix_spot, vix_pct, spx_iv_rank, iv_rv_spread)
        final = self._apply_hysteresis(raw)

        state = RegimeState(
            regime=final,
            vix_spot=vix_spot,
            vix_percentile=vix_pct,
            spx_iv_rank=spx_iv_rank,
            iv_rv_spread=iv_rv_spread,
            exposure_scalar=REGIME_EXPOSURE_MAP[final],
            timestamp=datetime.utcnow()
        )

        await self._publish_state(state)
        await self._log_state(state)
        return state

    def _compute_raw_regime(
        self, vix: float, vix_pct: float,
        iv_rank: float, iv_rv_spread: float
    ) -> VolRegime:
        regime_order = [
            VolRegime.CALM, VolRegime.ELEVATED,
            VolRegime.STRESSED, VolRegime.CRISIS
        ]

        # VIX-based signal
        if vix > 32 or vix_pct > 85:
            vs = VolRegime.CRISIS
        elif vix > 22 or vix_pct > 65:
            vs = VolRegime.STRESSED
        elif vix > 15 or vix_pct > 35:
            vs = VolRegime.ELEVATED
        else:
            vs = VolRegime.CALM

        # IV rank signal
        if iv_rank > 80:
            ir = VolRegime.CRISIS
        elif iv_rank > 60:
            ir = VolRegime.STRESSED
        elif iv_rank > 30:
            ir = VolRegime.ELEVATED
        else:
            ir = VolRegime.CALM

        # IV/RV spread signal - most critical for premium selling edge
        if iv_rv_spread < -5:
            sp = VolRegime.CRISIS   # realized exceeding implied = no edge
        elif iv_rv_spread < 2:
            sp = VolRegime.STRESSED
        elif iv_rv_spread < 8:
            sp = VolRegime.ELEVATED
        else:
            sp = VolRegime.CALM     # wide spread = strong edge

        # Take worst regime across all three signals
        worst = max(
            regime_order.index(vs),
            regime_order.index(ir),
            regime_order.index(sp)
        )
        return regime_order[worst]

    def _apply_hysteresis(self, raw: VolRegime) -> VolRegime:
        regime_order = [
            VolRegime.CALM, VolRegime.ELEVATED,
            VolRegime.STRESSED, VolRegime.CRISIS
        ]
        cur_idx = regime_order.index(self._current_regime)
        raw_idx = regime_order.index(raw)

        if raw_idx >= cur_idx:
            # downgrade: immediate
            self._consecutive_upgrades = 0
            self._current_regime = raw
        else:
            # upgrade: requires N consecutive signals
            self._consecutive_upgrades += 1
            if self._consecutive_upgrades >= UPGRADE_PERIODS_REQUIRED:
                self._current_regime = raw
                self._consecutive_upgrades = 0

        return self._current_regime

    async def _compute_vix_percentile(self, vix_spot: float) -> float:
        rows = await self.ts.fetch(
            "SELECT vix_close FROM market_data.vix_daily "
            "WHERE ts > NOW() - INTERVAL '370 days' ORDER BY ts"
        )
        closes = sorted(r['vix_close'] for r in rows)
        if not closes:
            return 50.0
        return float(np.searchsorted(closes, vix_spot)) / len(closes) * 100

    async def _publish_state(self, state: RegimeState):
        payload = {
            'regime': state.regime.value,
            'exposure_scalar': str(state.exposure_scalar),
            'vix_spot': str(state.vix_spot),
            'vix_percentile': str(state.vix_percentile),
            'spx_iv_rank': str(state.spx_iv_rank),
            'iv_rv_spread': str(state.iv_rv_spread),
            'ts': state.timestamp.isoformat()
        }
        self.redis.hset('vol_regime:current', mapping=payload)
        self.redis.publish('vol_regime:updates', str(payload))

    async def _log_state(self, state: RegimeState):
        await self.ts.execute(
            """INSERT INTO strategy_monitoring.vol_regime_log
               (ts, regime, exposure_scalar, vix_spot, vix_percentile,
                spx_iv_rank, iv_rv_spread)
               VALUES ($1, $2, $3, $4, $5, $6, $7)""",
            state.timestamp, state.regime.value, state.exposure_scalar,
            state.vix_spot, state.vix_percentile,
            state.spx_iv_rank, state.iv_rv_spread
        )

runs on 15-minute cadence via a dedicated asyncio service. feeds off Polygon.io options chain snapshots for IV rank and ThetaData for the Greeks/realized vol data. both are already running for the portfolio risk systems so no new data cost.


how the algos consume it
#

each strategy process does one Redis hash read per position sizing call. sub-millisecond overhead. the scalar comes back, gets applied.

class PositionSizer:

    def __init__(self, redis_client: redis.Redis, base_allocations: dict):
        self.redis = redis_client
        self.base = base_allocations  # strategy_id -> base dollar allocation

    def get_scalar(self) -> float:
        """Read current regime scalar from Redis."""
        val = self.redis.hget('vol_regime:current', 'exposure_scalar')
        # conservative default if classifier is down
        return float(val) if val else 0.50

    def size_position(self, strategy: str, signal_strength: float = 1.0) -> float:
        """
        Returns dollar allocation for this trade.
        regime_scalar is the ceiling, signal_strength adjusts within it.
        """
        base = self.base.get(strategy, 0)
        regime_scalar = self.get_scalar()
        sized = base * regime_scalar * signal_strength

        return sized

signal_strength is an optional strategy-level confidence multiplier - a second layer of sizing reduction based on model confidence for that specific trade setup. regime scalar sets the macro ceiling, signal strength adjusts within it. they’re independent.

every strategy now calls size_position() instead of reading base allocations directly. the regime state propagates automatically without touching strategy code.


infra: timescaledb schema + redis pub/sub
#

storing regime history in TimescaleDB lets me backtest “what would have happened if the throttle was active during [event]” without reconstructing VIX data manually.

CREATE TABLE strategy_monitoring.vol_regime_log (
    ts              TIMESTAMPTZ NOT NULL,
    regime          TEXT NOT NULL,
    exposure_scalar REAL NOT NULL,
    vix_spot        REAL,
    vix_percentile  REAL,
    spx_iv_rank     REAL,
    iv_rv_spread    REAL
);

SELECT create_hypertable('strategy_monitoring.vol_regime_log', 'ts');
CREATE INDEX ON strategy_monitoring.vol_regime_log (regime, ts DESC);

compression activates after 7 days. rows are tiny - ~50 bytes each, 96 per day at 15-min cadence. not going to be a storage issue.

real-time regime change notification across strategy processes via Redis pub/sub:

def listen_regime_changes(redis_client: redis.Redis, on_regime_change):
    pubsub = redis_client.pubsub()
    pubsub.subscribe('vol_regime:updates')

    for message in pubsub.listen():
        if message['type'] == 'message':
            data = eval(message['data'])
            on_regime_change(data['regime'], float(data['exposure_scalar']))


def handle_regime_change(regime: str, scalar: float):
    if regime == 'crisis':
        # trigger position review across all open positions
        asyncio.create_task(emergency_position_review())
    logger.info(f"regime → {regime} (scalar={scalar:.2f})")

crisis regime triggers an immediate position review - not auto-close, just a check pass confirming all open positions are within risk limits at 25% sizing. haven’t needed this yet but want it when I do.


backtesting it against Q1
#

ran the same Q1 trade history through two simulations: full base sizing throughout vs. regime scalar applied. same entry/exit signals. only position size differs.

VIX and corresponding exposure scalar over Q1 2026 - the march spike hit hard:

the week of march 13 VIX closed above 32. CRISIS regime, 25% sizing. the unthrottled version was running full size into that move.

cumulative PnL comparison across Q1 - throttled vs. full size:

throttled ends Q1 at +2.5%. unthrottled at +1.0%.

the throttle costs you in calm periods - smaller size means smaller wins when things are going well. you can see the gap opening up in february when conditions were ELEVATED but manageable, unthrottled pulling ahead. then march hits and the picture reverses hard. the unthrottled version briefly goes negative. throttled never drops below +1.0%.

this is the core trade-off the system is making: give up some upside when vol is manageable, in exchange for dramatically reducing drawdown when conditions break. over the full quarter, the drawdown protection wins by 150 basis points.

on a $1.2M account that’s the difference between a $30k Q1 and a $12k Q1. math’s not hard.


one more thing at 2am
#

A.’s been asleep for a while. she’s deep in a client engagement this week, working late most nights herself - just starts at 7pm instead of midnight.

I’ve been running these simulations since around midnight. watching the equity curves on the backtest, adjusting the hysteresis parameter, running it again. this is the part that’s hardest to explain to people who don’t build this stuff - there’s a specific kind of focus that kicks in when a system starts working and you’re just tuning it into sharpness.

dad was an engineer. VP of engineering at a biotech. he was the kind of person who built frameworks instead of relying on intuition. “if you can’t quantify it you can’t manage it” was basically a family motto. I think about that when I’m building something like this - the kind of thinking that goes into making a system that handles conditions you can’t predict in advance, instead of reacting to them after the fact.

throttle goes live week 2. first real test under live market conditions.

we’ll see.

-AK

Related

real-time greeks aggregation: knowing your portfolio delta/gamma at sub-second speed
2:15am wednesday. still processing this week. the q1 factor attribution post from sunday was cathartic but it also made me confront something i’d been papering over: i was flying blind on real-time greeks for most of march. not completely blind — i had position-level greeks from IB’s TWS feed. but aggregating them into a coherent portfolio view? that was a manual spreadsheet thing i’d run every few hours.
march vol spike: when the risk engine earns its keep
2:30am friday. rough week in the books. march has been a whole thing. tariff headlines dropping every 48 hours, VIX spiking then partially recovering, nobody knows what SPX does next. january was decent (+2.1%), february went against me (-1.3%). march hasn’t been great either. week ending today, i’m down about $2.3k for the five sessions. month’s probably closing around -1%.
q2 week 1: health scoring live, colo nic split, first numbers
2:15am friday. Q2 week 1 is done. walked in from the kitchen, A. fell asleep at her desk again — laptop open, ambient music still running. grabbed a blanket from the couch and put it over her. then came back and pulled up the weekly numbers.
q1 close: final numbers, colo benchmarks, and q2 setup
friday night. Q1 officially in the books. did the math earlier while A. was cooking. she noticed i went quiet and just left me to it. that’s one of the things i didn’t expect about being married — how well she reads when to give space. anyway.
execution quality audit: q1 slippage cost me more than i thought
2:30am wednesday. april 1st. no this is not a joke post. been staring at execution data for the last three hours and i have a headache. Q1 closed basically flat — detailed numbers in the march wrap. but flat is flat, and when i dug into why flat, the answer wasn’t strategy failure. it was execution bleed.
strategy health scoring: detecting algo decay before it wrecks your Q2
2:45am monday. first trading day of Q2. Q1 is officially in the rearview — closed at basically flat, full numbers are in friday’s post. the weekend was heavy. not going into it right now. but Q2 starts regardless, and the algos don’t wait for you to process.