2:15 AM friday. couldn’t sleep after the week we just had. VIX ripped to 28 monday, calmed down midweek, then did that whipsaw thing thursday afternoon where you think it’s done but it’s absolutely not done.
my iron condors held. barely. not because I’m some genius — because the roll engine I finished tuesday did its job. but when I was watching the positions roll automatically while volatility was spiking, I realized I had no idea what my total portfolio delta was in real time. I was looking at individual position Greeks but not the aggregate. if every spread started drifting the same direction in a vol spike, I’d only know after the damage was done.
that’s a gap. a dumb one. fixed it this week.
the problem with looking at positions individually #
when you’re running 12+ simultaneous options positions across SPX, QQQ, sector ETFs, plus ES futures and crypto exposure, individual position Greeks don’t tell you shit about portfolio risk. you need the rollup.
for a premium seller, the numbers I care about:
- portfolio delta: should stay near zero. drifts too far positive or negative and my theoretical “market neutral” position isn’t actually neutral anymore
- portfolio gamma: always negative (short gamma from selling spreads). tracks how much my delta exposure changes per $1 SPX move. the more negative this gets, the more dangerous a big gap move is
- portfolio theta: my daily income. this is the whole point of premium selling
- portfolio vega: negative because I’m short vol. when VIX spikes, this number gets ugly
before this week, I had to calculate these manually by pulling data and running a pandas query. fine for EOD review, useless during an intraday vol spike.
the aggregation architecture #
here’s what I built. nothing fancy — the hard part was just making it actually real-time and not a polling nightmare.
import asyncio
import redis.asyncio as aioredis
import pandas as pd
import numpy as np
from ib_insync import IB, Option, Contract
from typing import Optional
import logging
import json
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
REDIS_KEY_PORTFOLIO_GREEKS = "portfolio:greeks:current"
REDIS_KEY_POSITIONS = "portfolio:positions:raw"
REDIS_TTL_SECONDS = 30 # refresh every 30s
class PortfolioGreeksAggregator:
"""
Pulls live position Greeks from IBKR and aggregates to portfolio level.
Writes rollup to Redis for consumption by dashboard + risk alerts.
"""
def __init__(self, ib: IB, redis_url: str = "redis://localhost:6379"):
self.ib = ib
self.redis_url = redis_url
self._redis: Optional[aioredis.Redis] = None
self._last_greeks: dict = {}
async def connect_redis(self):
self._redis = await aioredis.from_url(self.redis_url, decode_responses=True)
async def fetch_positions_with_greeks(self) -> list[dict]:
"""Get all open positions and their live Greeks from IBKR."""
positions = self.ib.positions()
results = []
for pos in positions:
if pos.position == 0:
continue
contract = pos.contract
if contract.secType not in ("OPT", "FUT"):
continue
# request market data with Greek calculation
ticker = self.ib.reqMktData(contract, "106", False, False)
await asyncio.sleep(0.1) # give IBKR a moment
greeks = ticker.modelGreeks
if greeks is None:
logger.warning(f"No Greeks for {contract.localSymbol}")
continue
multiplier = float(contract.multiplier or 100)
results.append({
"symbol": contract.localSymbol,
"sec_type": contract.secType,
"right": getattr(contract, "right", None),
"position": pos.position,
"multiplier": multiplier,
"delta": (greeks.delta or 0.0) * pos.position * multiplier,
"gamma": (greeks.gamma or 0.0) * pos.position * multiplier,
"theta": (greeks.theta or 0.0) * pos.position * multiplier,
"vega": (greeks.vega or 0.0) * pos.position * multiplier,
"iv": greeks.impliedVol or 0.0,
"underlying_price": greeks.undPrice or 0.0,
"ts": datetime.now(timezone.utc).isoformat(),
})
self.ib.cancelMktData(contract)
return results
async def compute_portfolio_rollup(self, positions: list[dict]) -> dict:
"""Sum Greeks across all positions."""
if not positions:
return {}
df = pd.DataFrame(positions)
rollup = {
"delta": round(df["delta"].sum(), 4),
"gamma": round(df["gamma"].sum(), 6),
"theta": round(df["theta"].sum(), 2),
"vega": round(df["vega"].sum(), 2),
"position_count": len(df),
"avg_iv": round(df["iv"].mean(), 4),
"ts": datetime.now(timezone.utc).isoformat(),
}
# strategy-level breakdown for dashboard
rollup["by_symbol_prefix"] = (
df.assign(prefix=df["symbol"].str[:3])
.groupby("prefix")[["delta", "gamma", "theta", "vega"]]
.sum()
.round(4)
.to_dict("index")
)
return rollup
async def write_to_redis(self, rollup: dict, positions: list[dict]):
"""Persist current Greeks to Redis with TTL."""
await self._redis.setex(
REDIS_KEY_PORTFOLIO_GREEKS,
REDIS_TTL_SECONDS,
json.dumps(rollup),
)
await self._redis.setex(
REDIS_KEY_POSITIONS,
REDIS_TTL_SECONDS,
json.dumps(positions),
)
async def run_loop(self, interval: float = 15.0):
"""Main update loop — runs every N seconds."""
await self.connect_redis()
logger.info(f"Starting Greeks aggregation loop (interval={interval}s)")
while True:
try:
positions = await self.fetch_positions_with_greeks()
rollup = await self.compute_portfolio_rollup(positions)
if rollup:
await self.write_to_redis(rollup, positions)
self._last_greeks = rollup
logger.info(
f"Greeks updated | Δ={rollup['delta']:.2f} "
f"Γ={rollup['gamma']:.5f} "
f"Θ={rollup['theta']:.2f} "
f"V={rollup['vega']:.2f}"
)
except Exception as e:
logger.error(f"Greeks aggregation error: {e}", exc_info=True)
await asyncio.sleep(interval)
runs as a background async task alongside everything else. the Grafana dashboard reads from Redis. latency from IBKR → Redis → dashboard is about 2-3 seconds, which is more than good enough for intraday risk monitoring (I’m not HFT).
storing historical Greeks in TimescaleDB #
real-time Redis cache is for the dashboard. but I also wanted historical Greek data for analysis — how did portfolio delta drift during the vol spike? was theta accelerating into expiry like it should?
import asyncpg
from datetime import datetime, timezone
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS portfolio_greeks_history (
ts TIMESTAMPTZ NOT NULL,
delta DOUBLE PRECISION,
gamma DOUBLE PRECISION,
theta DOUBLE PRECISION,
vega DOUBLE PRECISION,
position_count INTEGER,
avg_iv DOUBLE PRECISION
);
SELECT create_hypertable('portfolio_greeks_history', 'ts', if_not_exists => TRUE);
"""
async def persist_greeks_snapshot(pool: asyncpg.Pool, rollup: dict):
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO portfolio_greeks_history
(ts, delta, gamma, theta, vega, position_count, avg_iv)
VALUES ($1, $2, $3, $4, $5, $6, $7)
""",
datetime.fromisoformat(rollup["ts"]),
rollup["delta"],
rollup["gamma"],
rollup["theta"],
rollup["vega"],
rollup["position_count"],
rollup["avg_iv"],
)
writes a snapshot every 15 seconds during market hours. about 1,500 rows per day, basically nothing for TimescaleDB. the hypertable chunks it automatically.
what the data looks like #
here’s my portfolio Greeks over the past 3 weeks. you can see the monday vol spike — vega went way more negative as positions got crushed by the vol expansion, then mean-reverted as things calmed down by wednesday.
delta stayed close to zero throughout (good), theta held up even during the spike, vega took the hit when vol expanded — exactly what should happen with a short-vega book. seeing it plotted like this makes it way easier to explain to myself why the P&L looked the way it did.
Greek breakdown by strategy #
the other view I wanted: how much of each Greek is coming from which strategy bucket? useful for knowing if my options exposure is balanced or if one position is dominating the book.
SPX iron condors are doing the heavy lifting — about 60% of theta and 60% of vega exposure. which is correct. QQQ spreads are a meaningful second position. everything else is small enough to not worry about from a Greek concentration standpoint.
real-world performance during the vol spike #
april so far has been rough. week one ended down about 1.4% on the account — vol expansion hit vega hard, couple of spreads needed emergency rolls. the roll engine handled 4 of the 6 rolls automatically. the other two were near the gamma tipping point and I overrode to manual.
lessons:
- gamma concentration was too high in SPX going into the week. need to space strikes wider when VIX is >22
- theta was actually fine — daily decay kept doing its job even through the turbulence
- the Greeks dashboard paid for itself immediately — I knew within 30 seconds of the VIX spike that my delta was drifting south. rolled one spread before it would have been a much bigger problem
this is why you build the boring infrastructure stuff. not sexy, not a trading edge, just visibility that lets you make decisions with actual information instead of vibes.
I’ve been discussing this kind of real-time risk monitoring in the NexusFi options volatility thread — there’s a lot of good discussion there about managing vega exposure during vol regime shifts. worth a read if you’re running short-vol strategies.
A. made coffee before she went to bed. left the pot on and a note: “you’re going to be up until 3am anyway.” she’s not wrong.
dad would’ve appreciated the systems thinking here. he was always the one who said “build the instrumentation first, optimize second.” took me three years to actually listen.
gonna run the backtester on the wider-strikes idea over the weekend. if it holds up I’ll post the results.
-AK