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