Skip to main content

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%.

not catastrophic. but not what i want.

the only reason it wasn’t a lot worse is the risk engine caught exposure limits before they became a real problem. writing this up while the session is still fresh in my head.

what the week actually looked like
#

mid-week tariff news hit fast. wednesday morning, SPX dropped 2.1% in a single session. VIX went from 21 to 28 in under 48 hours. not the biggest spike i’ve seen, but the velocity was sharp - the kind of move where algo execution gets sloppy if you’re not watching the right things.

for an options premium-selling book, that environment is complicated.

high VIX means better premiums on new trades. selling strangles on SPX when implied vol is elevated is theoretically attractive - you’re collecting more edge per unit of risk. the math says “yes please.”

except: when vol spikes suddenly and the market moves with the spike, you’re not just collecting better premiums. you’re short gamma into directional momentum. delta exposure on your short puts compounds against you as the market sells off. and if margin requirements simultaneously expand (which they always do in vol spikes), you can find yourself forced to reduce positions at exactly the worst moment - when spreads are widest and slippage is worst.

this is the real problem with premium selling in vol events. everyone talks about theta. nobody talks about the gamma / margin / liquidity interaction when everything moves at once.

the risk engine is what separates “uncomfortable week” from “draw down 8% in 72 hours.”

the limit structure
#

built this out over most of 2024. learned the hard way that running checks every 15 minutes wasn’t enough. the thing runs every 60 seconds now against live portfolio state pulled from IBKR and Tastyworks APIs simultaneously.

limits that triggered this week:

net delta limit: portfolio delta (across all positions, not just the options book) bounded at ±$25k equivalent per $100k of capital. wednesday morning when SPX was in freefall, my short puts were getting long delta fast. hit 91% of limit by 10am. new options trades paused automatically.

vega cap: total portfolio vega bounded by a formula tied to account NAV. this one didn’t breach but got close. when vol spikes and you’re already long vega from existing positions, it prevents you from doubling down on volatility exposure.

margin buffer: available margin must stay above 35% of NAV. if it drops to that floor, nothing new opens. keeps forced liquidation off the table.

cross-asset correlation spike: when rolling 5-day correlation between SPX and BTC exceeds 0.70, the system auto-reduces crypto book size by 25%. both going risk-off together is a specific setup i don’t want full exposure through.

the correlation trigger fired monday morning. BTC dropped 4% in sympathy with equities, trigger hit, reduction orders went out. cost me some upside when crypto recovered thursday but that’s the deal - limits exist for the bad scenarios, not the good ones.

class RiskMonitor:
    def __init__(self, account_manager, portfolio_state):
        self.account = account_manager
        self.portfolio = portfolio_state
        self.limits = self._load_limits()

    def run_checks(self) -> RiskStatus:
        status = RiskStatus(timestamp=datetime.now(UTC))
        nav = self.account.get_nav()

        # Delta check
        net_delta = self.portfolio.get_net_delta_dollars()
        delta_limit = nav * 0.25
        status.delta_utilization = abs(net_delta) / delta_limit
        if status.delta_utilization > 0.90:
            status.add_breach('delta', net_delta, delta_limit)
            self._pause_new_trades(reason='delta_limit', duration_minutes=30)

        # Margin buffer
        available_margin = self.account.get_available_margin()
        margin_pct = available_margin / nav
        status.margin_utilization = 1.0 - margin_pct
        if margin_pct < 0.35:
            status.add_breach('margin', margin_pct, 0.35)
            self._pause_new_trades(reason='margin_floor', duration_minutes=60)

        # Cross-asset correlation
        corr = self.portfolio.get_rolling_correlation('SPX', 'BTC', window='5D')
        if corr > 0.70:
            status.add_breach('correlation', corr, 0.70)
            self._trigger_crypto_reduce()

        return status

    def _trigger_crypto_reduce(self):
        """Reduce crypto book 25% when cross-asset correlation spikes"""
        current_size = self.portfolio.get_book_notional('crypto')
        target_size = current_size * 0.75
        self.account.queue_reduce_order(
            book='crypto',
            target_notional=target_size,
            reason='correlation_spike',
            urgency='moderate'
        )

    def _pause_new_trades(self, reason: str, duration_minutes: int):
        expiry = datetime.now(UTC) + timedelta(minutes=duration_minutes)
        self.portfolio.set_trade_pause(reason=reason, expires_at=expiry)

i’ve found there’s a good community thread on NexusFi where algo traders have been journaling systematic approaches to risk for years - the mindset of treating limits as features, not constraints, clicked for me reading through that. hard limits make discretionary overrides impossible, which is the point.

wednesday morning is where it got real. delta utilization hit 91%, breach threshold crossed, new options trades auto-paused for 30 minutes. SPX bounced slightly, exposure normalized, trades resumed. i missed a couple entries during that window. probably missed $800 in potential premium. would have lost significantly more if the move had continued and i’d kept piling on.

asymmetric. risk engine paid for itself in one session.

the infrastructure problem: stale data under load
#

vol spikes kill your data throughput. your risk engine gets slower exactly when you need it fastest. nobody talks about this.

normal day: ~42 options positions, quote updates every 5 minutes, risk calculations complete in under 800ms. fine.

vol spike wednesday: quotes updating every 15-20 seconds because the market is moving. add IBKR and Tastyworks both hammering me with margin recalculations. crypto feeds running hot. the 60-second risk cycle was taking 180+ seconds to complete. i was making limit decisions on data that was 3 minutes old.

found out when the stale-data flag started appearing in the monitor. 14 minutes of lag on wednesday morning - right when it mattered most.

two fixes:

materialized view for position state

instead of joining live quotes against the positions table on every cycle, pre-compute a view that refreshes every 30 seconds. the risk monitor queries the view, not the raw tables directly. dropped p99 query time from ~2200ms to ~180ms.

CREATE MATERIALIZED VIEW current_portfolio_state AS
SELECT
    p.position_id,
    p.symbol,
    p.quantity,
    p.avg_cost,
    q.last_price,
    q.iv_mark,
    q.delta,
    q.gamma,
    q.theta,
    q.vega,
    (p.quantity * q.delta * q.underlying_price) AS delta_dollars,
    (p.quantity * q.vega)                        AS total_vega,
    q.updated_at
FROM positions p
JOIN LATERAL (
    SELECT *
    FROM option_quotes oq
    WHERE oq.symbol = p.symbol
    ORDER BY oq.timestamp DESC
    LIMIT 1
) q ON true
WHERE p.status = 'open';

the TimescaleDB continuous aggregate policy handles the 30-second refresh automatically. risk engine queries the view. raw quote ingestion runs independently. no lock contention.

tiered check architecture

split risk checks into two tiers:

  • fast tier (every 60s): queries the materialized view. catches obvious breaches immediately. this is the circuit breaker layer.
  • deep tier (every 5min): full re-calculation from live quotes, fresh margin calls from broker APIs, recompute correlations from scratch. catches subtle drift that the view might miss by 30 seconds.

fast tier is the smoke detector. deep tier is the fire inspection.

stale data lag went from 14 minutes max to under 90 seconds even under wednesday’s load. good enough.

delta exposure tracked VIX almost exactly. that’s the system working. when vol spiked wednesday, delta naturally grew on the short put positions - and the engine throttled new exposure to prevent it compounding further. as VIX came back in thursday-friday, positions normalized. this is what it’s supposed to look like.

what i’m changing for april
#

the materialized view refresh rate: 30 seconds worked but only barely. changing to 15 seconds. the extra TimescaleDB load is worth the safety margin.

adding a new circuit breaker: if the refresh lag itself exceeds 120 seconds, force an immediate risk-off state regardless of what the last check showed. right now i just get a stale-data alert and keep running. that’s wrong. 120 seconds of stale data in a fast market is unacceptable, even with the fast tier running.

also reviewing the delta limit formula. $25k per $100k NAV made sense at smaller size. at $1.2M, i might want tighter - maybe $20k per $100k, which would have triggered the breach earlier wednesday and gotten me out of new exposure before the worst of the move.

reviewing it over the weekend.


been a long march. A. noticed i’ve been at the desk more than usual, quieter at breakfast - she’s been giving me space, just sets a coffee down and goes back to her corner. she’s been deep in some Python project of her own anyway. we move around each other pretty well when both of us are locked in.

sometimes late at night staring at risk logs i think about dad. he would’ve appreciated the engineering in all this - the fail-safes layered on fail-safes. probably would’ve asked a lot of questions and then said “seems like you’re building a plane while flying it.” yeah. basically.

april better be cleaner.

-AK

Related

implied vol surface in python: stop guessing what the market thinks
4:30 AM. been staring at vol surfaces for three weeks straight. finally got the pipeline clean enough to write about it. if you’re trading options without a vol surface you’re flying blind. period. everyone talks about delta and theta but the actual edge is in understanding where implied vol is mispriced relative to what it should be. that’s the surface. that’s where the money is.
adaptive stop losses - why fixed stops are leaving money on the table
2:30am wednesday. been refactoring my exit logic all week. fixed stop losses are lazy. there I said it. the problem with fixed stops # “just use a 2% stop loss.”
volatility regime detection - when to switch strategies
the market doesn’t care what strategy you’re running. it runs whatever regime it wants. your job is to detect the regime and adapt. why regime matters # every strategy has conditions where it crushes and conditions where it bleeds.
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
timescaledb optimization - 3 million rows per day
been putting this off for months. timescaledb getting slow. finally fixed it. the problem # my options flow data pipeline ingests about 3 million rows per day.
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.