Skip to main content

real-time portfolio Greeks: aggregating delta, gamma, theta, vega at scale

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:

  1. gamma concentration was too high in SPX going into the week. need to space strikes wider when VIX is >22
  2. theta was actually fine — daily decay kept doing its job even through the turbulence
  3. 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

Related

options contract lifecycle: building the roll engine and pin risk detector
2:30 AM wednesday. A. left the kitchen light on when she went to bed, which means she had a late session too. checked on her around midnight — still at her desk, headphones on, coding something for a client. now she’s asleep and I’m at mine.
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.
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.
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.
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%.
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.