Skip to main content

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.

that’s not good enough when VIX spikes 10 points in 72 hours.

so i built a proper greeks aggregation engine. here’s the full thing.

why you need this
#

most retail-adjacent traders think about individual position greeks. delta on this trade, vega on that spread. fine for small books, but once you’re running 15-20 simultaneous options positions across multiple underlyings, the individual view lies to you.

the view that counts is portfolio-level greek exposure. net delta tells you how much directional risk you’re carrying right now. net gamma tells you how fast that delta changes if the market moves. net vega tells you your vol expansion risk. and net theta tells you how much you’re collecting per day for running that risk.

the problem i had in mid-march: i was running elevated short gamma without knowing it. each individual position looked fine. but when i finally looked at the aggregated portfolio picture, my combined short gamma was 40% higher than my target. that’s the thing that got uncomfortable when the vol spike hit — not any single position, but the aggregate.

you can’t manage what you can’t measure, and you can’t measure it if it’s a 15-minute manual process.

architecture overview
#

three components:

  1. position store — TimescaleDB. positions table with current quantities, strikes, expiries, option types. refreshed from IB/Tastyworks API every 30 seconds.

  2. market data cache — Redis. real-time bid/ask/IV per underlying, updated from IB TWS stream via my market data bridge. sub-10ms to read from Chicago colo.

  3. greeks aggregator — Python async loop. runs every second. pulls positions + market data, calculates BSM greeks for every option, aggregates to portfolio level, stores back to TimescaleDB, publishes to Redis pub/sub for Grafana.

total latency from market move to updated portfolio greeks: ~200ms from Chicago. that’s acceptable for risk monitoring purposes.

the code
#

full GreeksAggregator class:

"""
GreeksAggregator: Real-time portfolio Greeks calculation and aggregation
Reads positions from TimescaleDB, market data from Redis, calculates BSM
Greeks per position, aggregates to portfolio level, publishes updates.
"""

import asyncio
import json
import time
from dataclasses import dataclass, field, asdict
from typing import Dict, List, Optional, Tuple
import numpy as np
import redis.asyncio as aioredis
import asyncpg
from scipy.stats import norm


# --- Data Structures ---

@dataclass
class PositionGreeks:
    """Greeks for a single position"""
    symbol: str
    position_type: str      # 'option', 'future', 'equity', 'crypto'
    quantity: float

    # Option inputs
    underlying_price: float = 0.0
    strike: float = 0.0
    expiry_days: float = 0.0
    implied_vol: float = 0.0
    option_type: str = ""   # 'call' or 'put'

    # Computed Greeks (quantity-adjusted)
    delta: float = 0.0
    gamma: float = 0.0
    theta: float = 0.0
    vega: float = 0.0

    # Dollar-normalized Greeks
    dollar_delta: float = 0.0
    dollar_gamma: float = 0.0   # $ PnL per 1% move in underlying
    dollar_vega: float = 0.0    # $ PnL per 1 vol point


@dataclass
class PortfolioGreeks:
    """Aggregated portfolio-level Greeks"""
    timestamp: float = 0.0

    # Net Greeks
    net_delta_normalized: float = 0.0   # fraction of account per 1% S move
    net_gamma_dollar: float = 0.0       # $ PnL change per 1% S move change
    net_theta_daily: float = 0.0        # $ daily theta (positive = collecting)
    net_vega_dollar: float = 0.0        # $ PnL per 1 vol point

    # By strategy bucket
    options_delta: float = 0.0
    futures_delta: float = 0.0
    crypto_delta: float = 0.0
    options_vega: float = 0.0

    # Risk ratios
    gamma_theta_ratio: float = 0.0      # tail risk per unit of theta earned
    vega_per_million: float = 0.0       # normalized vol exposure

    # Scenario analysis
    one_sigma_daily_pnl: float = 0.0    # estimated PnL for 1-sigma daily move
    two_sigma_daily_pnl: float = 0.0    # estimated PnL for 2-sigma daily move


# --- BSM Calculator ---

class BSMCalculator:
    """Black-Scholes-Merton Greeks — vectorized for batch processing"""

    @staticmethod
    def _d1_d2(S: float, K: float, T: float, r: float, sigma: float) -> Tuple[float, float]:
        if T <= 1e-6 or sigma <= 1e-6 or S <= 0 or K <= 0:
            return 0.0, 0.0
        d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
        d2 = d1 - sigma * np.sqrt(T)
        return d1, d2

    @staticmethod
    def delta(S, K, T, r, sigma, option_type: str) -> float:
        d1, _ = BSMCalculator._d1_d2(S, K, T, r, sigma)
        return norm.cdf(d1) if option_type == 'call' else norm.cdf(d1) - 1.0

    @staticmethod
    def gamma(S, K, T, r, sigma) -> float:
        if T <= 1e-6 or sigma <= 1e-6 or S <= 0:
            return 0.0
        d1, _ = BSMCalculator._d1_d2(S, K, T, r, sigma)
        return norm.pdf(d1) / (S * sigma * np.sqrt(T))

    @staticmethod
    def theta(S, K, T, r, sigma, option_type: str) -> float:
        if T <= 1e-6:
            return 0.0
        d1, d2 = BSMCalculator._d1_d2(S, K, T, r, sigma)
        base = -(S * norm.pdf(d1) * sigma) / (2 * np.sqrt(T))
        if option_type == 'call':
            return (base - r * K * np.exp(-r * T) * norm.cdf(d2)) / 365
        return (base + r * K * np.exp(-r * T) * norm.cdf(-d2)) / 365

    @staticmethod
    def vega(S, K, T, r, sigma) -> float:
        """Vega per 1 volatility point (1%)"""
        if T <= 1e-6:
            return 0.0
        d1, _ = BSMCalculator._d1_d2(S, K, T, r, sigma)
        return S * norm.pdf(d1) * np.sqrt(T) / 100


# --- Main Aggregator ---

class GreeksAggregator:
    """
    Async portfolio Greeks aggregator.

    Runs every second. Pulls live positions from TimescaleDB (30s cache),
    reads market data from Redis L1 cache (updated from IB/Binance feeds),
    calculates BSM Greeks per option, aggregates to portfolio level,
    stores snapshot in TimescaleDB, publishes delta to Redis pub/sub.
    """

    RISK_FREE_RATE = 0.053          # Fed funds rate, March 2026
    DELTA_ALERT_THRESHOLD = 0.15    # Alert if net delta exceeds ±15%
    GAMMA_THETA_ALERT = 5.0         # Alert if gamma/theta ratio exceeds 5x

    def __init__(self, pg_dsn: str, redis_url: str, account_size: float):
        self.pg_dsn = pg_dsn
        self.redis_url = redis_url
        self.account_size = account_size
        self.bsm = BSMCalculator()

        self._redis: Optional[aioredis.Redis] = None
        self._pg: Optional[asyncpg.Connection] = None
        self._positions_cache: List[Dict] = []
        self._last_position_refresh: float = 0.0

    async def _init_connections(self):
        if not self._redis:
            self._redis = await aioredis.from_url(
                self.redis_url, decode_responses=True, socket_timeout=0.1
            )
        if not self._pg:
            self._pg = await asyncpg.connect(self.pg_dsn)

    async def _refresh_positions(self):
        """Refresh from TimescaleDB — expensive, cache for 30s"""
        now = time.time()
        if now - self._last_position_refresh < 30:
            return

        rows = await self._pg.fetch("""
            SELECT symbol, position_type, quantity, strike,
                   EXTRACT(EPOCH FROM expiry_ts) as expiry_unix,
                   option_type, underlying_symbol, contract_multiplier
            FROM positions
            WHERE quantity != 0 AND account_id = $1
            ORDER BY position_type, underlying_symbol, symbol
        """, "main_account")

        self._positions_cache = [dict(r) for r in rows]
        self._last_position_refresh = now

    async def _get_market_data(self, symbol: str) -> Dict:
        """Sub-ms Redis read from Chicago colo — critical path"""
        data = await self._redis.hgetall(f"market:{symbol}")
        return {k: float(v) for k, v in data.items()} if data else {}

    async def _calc_position_greeks(self, pos: Dict) -> PositionGreeks:
        """Calculate Greeks for a single position"""
        underlying = pos.get('underlying_symbol') or pos['symbol']
        mkt = await self._get_market_data(underlying)

        g = PositionGreeks(
            symbol=pos['symbol'],
            position_type=pos['position_type'],
            quantity=pos['quantity'],
        )

        if not mkt:
            return g  # can't compute without market data

        S = mkt.get('mid', mkt.get('last', 0.0))
        qty = pos['quantity']
        mult = pos.get('contract_multiplier') or 1

        if pos['position_type'] == 'option':
            K = pos['strike']
            now_unix = time.time()
            T = max(0.0, (pos['expiry_unix'] - now_unix) / (365 * 86400))
            sigma = mkt.get('iv', 0.18)
            otype = pos['option_type']
            r = self.RISK_FREE_RATE

            per_share = {
                'delta': self.bsm.delta(S, K, T, r, sigma, otype),
                'gamma': self.bsm.gamma(S, K, T, r, sigma),
                'theta': self.bsm.theta(S, K, T, r, sigma, otype),
                'vega': self.bsm.vega(S, K, T, r, sigma),
            }

            g.delta = per_share['delta'] * qty * mult
            g.gamma = per_share['gamma'] * qty * mult
            g.theta = per_share['theta'] * qty * mult
            g.vega = per_share['vega'] * qty * mult

            g.dollar_delta = g.delta * S
            g.dollar_gamma = g.gamma * S * S * 0.01  # $ per 1% move
            g.dollar_vega = g.vega

        elif pos['position_type'] == 'future':
            g.delta = qty * mult
            g.dollar_delta = g.delta * S
            # futures: zero gamma, zero vega

        else:  # equity, crypto, spot
            g.delta = qty
            g.dollar_delta = qty * S

        return g

    async def aggregate(self) -> PortfolioGreeks:
        """Main aggregation — runs every second"""
        await self._init_connections()
        await self._refresh_positions()

        all_greeks = await asyncio.gather(
            *[self._calc_position_greeks(p) for p in self._positions_cache]
        )

        portfolio = PortfolioGreeks(timestamp=time.time())

        for g in all_greeks:
            portfolio.net_gamma_dollar += g.dollar_gamma
            portfolio.net_theta_daily += g.theta
            portfolio.net_vega_dollar += g.dollar_vega

            if g.position_type == 'option':
                portfolio.options_delta += g.dollar_delta
                portfolio.options_vega += g.dollar_vega
            elif g.position_type == 'future':
                portfolio.futures_delta += g.dollar_delta
            else:
                portfolio.crypto_delta += g.dollar_delta

        # Net dollar delta = sum of all
        total_dollar_delta = (
            portfolio.options_delta + portfolio.futures_delta + portfolio.crypto_delta
        )
        portfolio.net_delta_normalized = total_dollar_delta / self.account_size

        # Risk ratios
        if portfolio.net_theta_daily > 0:
            portfolio.gamma_theta_ratio = (
                abs(portfolio.net_gamma_dollar) / portfolio.net_theta_daily
            )

        portfolio.vega_per_million = portfolio.net_vega_dollar / (self.account_size / 1e6)

        # Scenario: assume 15% annual vol on SPX, daily sigma = 15%/sqrt(252)
        daily_sigma = 0.15 / np.sqrt(252)
        acct = self.account_size

        portfolio.one_sigma_daily_pnl = (
            portfolio.net_delta_normalized * acct * daily_sigma
            + 0.5 * portfolio.net_gamma_dollar * daily_sigma**2
        )
        portfolio.two_sigma_daily_pnl = (
            portfolio.net_delta_normalized * acct * 2 * daily_sigma
            + 0.5 * portfolio.net_gamma_dollar * (2 * daily_sigma) ** 2
        )

        # Fire alerts
        if abs(portfolio.net_delta_normalized) > self.DELTA_ALERT_THRESHOLD:
            print(f"ALERT: net delta {portfolio.net_delta_normalized:.3f} exceeded ±{self.DELTA_ALERT_THRESHOLD}")
        if portfolio.gamma_theta_ratio > self.GAMMA_THETA_ALERT:
            print(f"ALERT: gamma/theta ratio {portfolio.gamma_theta_ratio:.1f}x — elevated tail risk")

        # Publish update
        await self._redis.publish(
            "portfolio:greeks:live", json.dumps(asdict(portfolio))
        )

        # Persist snapshot
        await self._pg.execute("""
            INSERT INTO portfolio_greeks_history
              (time, net_delta_norm, net_gamma_dollar, net_theta_daily,
               net_vega_dollar, options_delta, futures_delta, crypto_delta,
               gamma_theta_ratio, one_sigma_pnl, two_sigma_pnl)
            VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
        """,
            portfolio.timestamp, portfolio.net_delta_normalized,
            portfolio.net_gamma_dollar, portfolio.net_theta_daily,
            portfolio.net_vega_dollar, portfolio.options_delta,
            portfolio.futures_delta, portfolio.crypto_delta,
            portfolio.gamma_theta_ratio, portfolio.one_sigma_daily_pnl,
            portfolio.two_sigma_daily_pnl
        )

        return portfolio


async def run_aggregator(account_size: float = 1_194_000.0):
    agg = GreeksAggregator(
        pg_dsn="postgresql://trading:xxx@colo-db:5432/tradingdb",
        redis_url="redis://colo-cache:6379",
        account_size=account_size,
    )
    while True:
        t0 = time.monotonic()
        greeks = await agg.aggregate()
        elapsed_ms = (time.monotonic() - t0) * 1000
        # target: under 200ms total cycle
        await asyncio.sleep(max(0, 1.0 - (time.monotonic() - t0)))


if __name__ == "__main__":
    asyncio.run(run_aggregator())

okay. 220 lines. that’s the whole thing. BSM calculator, position-level Greeks, async portfolio aggregation, TimescaleDB persistence, Redis pub/sub publish, scenario analysis, and alert thresholds.

live portfolio greeks: last 4 weeks
#

here’s what the aggregated data actually looked like through the march vol spike. green is net delta (normalized to account fraction), orange is gamma exposure (scaled). the vol spike week is shaded.

a few things jump out:

the delta flip during the spike. march 18-19, when SPX dropped 2.4% and VIX was at 28, net delta went to -0.14. that’s because short puts that were OTM suddenly had meaningful negative delta as they moved toward ATM. i knew this intellectually but seeing it in real-time on the dashboard was different — it’s the moment you realize “i need to hedge this now, not at EOD.”

gamma was already elevated before the spike. look at early march — gamma exposure was climbing as i’d been adding positions. by march 16 it was already at the high end of my comfort zone. the spike just revealed that i was already overweight on that exposure.

vega tracking is the least actionable in real-time but the most useful for position sizing decisions the next day. when vega spikes negative during high vol, it means adding new short options is tempting (better premiums) but the portfolio already has elevated vega sensitivity — adding more vol exposure is compounding risk, not harvesting opportunity.

timescaledb schema
#

the persistence layer isn’t complicated:

-- TimescaleDB hypertable for Greeks snapshots
CREATE TABLE portfolio_greeks_history (
    time                 BIGINT NOT NULL,       -- unix ms
    net_delta_norm       DECIMAL(10, 6),        -- fraction of account
    net_gamma_dollar     DECIMAL(12, 2),        -- $ per 1% move
    net_theta_daily      DECIMAL(10, 2),        -- daily $ theta
    net_vega_dollar      DECIMAL(10, 2),        -- $ per vol point
    options_delta        DECIMAL(12, 2),
    futures_delta        DECIMAL(12, 2),
    crypto_delta         DECIMAL(12, 2),
    gamma_theta_ratio    DECIMAL(8, 4),
    one_sigma_pnl        DECIMAL(12, 2),
    two_sigma_pnl        DECIMAL(12, 2)
);

SELECT create_hypertable('portfolio_greeks_history', 'time',
    chunk_time_interval => 604800000);  -- 7-day chunks in ms

-- Fast recent lookups
CREATE INDEX ON portfolio_greeks_history (time DESC);

-- Daily aggregates for Grafana
CREATE MATERIALIZED VIEW greeks_daily_summary
WITH (timescaledb.continuous) AS
SELECT
    time_bucket(86400000, time) AS day,
    AVG(net_delta_norm)      AS avg_delta,
    MIN(net_gamma_dollar)    AS min_gamma,   -- most negative = most exposed
    AVG(net_theta_daily)     AS avg_theta,
    AVG(gamma_theta_ratio)   AS avg_gamma_theta_ratio
FROM portfolio_greeks_history
GROUP BY day;

storing at 1-second granularity means about 23k rows per trading day, ~115k rows per week. hypertable handles this no problem. i query the materialized view for the Grafana panels to keep dashboards fast — the underlying 1s data is there when i need to debug a specific period.

greeks by strategy: current snapshot
#

chart tells the obvious story: the futures hedge is doing its job on delta — options have negative delta from short puts, futures offset it to bring net delta close to zero. crypto is basically uncorrelated, small delta noise.

but vega? that’s entirely concentrated in the options book. futures don’t have vega. crypto spot doesn’t have vega. if you want to hedge vega, you’re buying vol on SPX or you’re reducing options size. there’s no free lunch here.

the gamma/theta ratio right now is sitting at 2.8x. my target is under 3x. during the march spike peak it was 6.4x — that’s the number that should have been blinking red on my dashboard a week earlier if i’d had this thing running.

the chicago colo piece
#

the 200ms end-to-end latency i mentioned earlier is entirely a function of the colo setup. the aggregation loop talks to two services: TimescaleDB (for position refresh) and Redis (for real-time market data). both live in the chicago datacenter.

from chicago → chicago, Redis read latency is 0.3ms. TimescaleDB position query takes about 12ms (full position list refresh every 30s). the BSM calculations for 18 concurrent options positions take about 8ms on a single thread — vectorized NumPy handles the math efficiently.

from san diego, trying to do this same loop against remote services? you’re looking at 60-80ms network round-trip just to get market data, before any compute. at 1-second update frequency that’s fine mathematically, but the psychological difference between “my greeks are 80ms stale” and “my greeks are 8ms stale” matters a lot during fast-moving markets. when VIX is at 28 and SPX is printing a 0.3% candle every minute, you want the freshest possible view.

the colo is not cheap. but this is exactly the use case it exists for. i track it as a cost of capital on the infrastructure P&L sheet. running the greeks aggregator is one of the clearer justifications for that spend.

where this gets used
#

the output feeds three downstream systems:

  1. grafana dashboard — the main thing i stare at during live trading hours. real-time delta/gamma/vega panel, gamma-theta ratio alert widget, 2-sigma scenario bar.

  2. risk engine — if net delta exceeds ±15% or gamma-theta ratio exceeds 5x, the risk engine starts shrinking options position sizes on new entries. not closing existing — just pausing new accumulation.

  3. position sizing for new trades — before opening any new options position, i check the current vega and gamma contribution against portfolio limits. if portfolio vega is already -$400/vol point, i don’t open a straddle that adds another -$120. i wait for existing positions to decay.

had this running during the march spike, i would’ve seen the gamma buildup over the week of march 9-12 and probably trimmed 15-20% of the options book before the vol event. that’s not hindsight — the signal was there, i just wasn’t aggregating it fast enough to act on it.

brief wrap
#

q1 is done. posted about it sunday. now rebuilding the infrastructure gaps that got exposed. this was the big one.

A. asked what i was working on at midnight last thursday when i was debugging the aggregator. i told her “basically a real-time view of how screwed my portfolio is at any given moment.” she thought about it and said “that’s either very smart or very neurotic.” probably both.

there’s something about late-night debugging sessions, coffee going cold, the kind of total focus where nothing else exists. dad used to say that kind of work is a form of prayer — you’re not thinking about yourself at all. just the problem. just the code. just the thing you’re trying to build. i get it now in a way i didn’t at 19. the work itself is the point sometimes.

gonna cross-post the BSM piece to r/algotrading tomorrow — curious if anyone’s found a better approximation for IV from market data when the IB feed lags during high-vol events. the schema’s going up as a gist too.

-AK

Related

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 factor attribution: theta is the edge, delta drift is the problem
q1 is in the books. three months, roughly flat performance, and a clear pattern in the trade data that tells me exactly what needs to change for q2. jan: +2.1%. feb: -1.3%. march: -0.9% (locked at friday close). quarter: -0.13% net. account moved from $1.196M to about $1.194M. call it flat with a slight downside tilt.
redis timeseries - cutting latency from 45ms to 8ms
just finished a redis optimization project. latency went from 45ms to 8ms. here’s how. the problem # market data pipeline was bottlenecking at redis.
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.
redis caching optimization - 40% latency reduction for market data
optimized redis caching during honeymoon downtime review. 40% latency improvement. the problem # before optimization: market data fetch: 180ms avg
data pipeline - real-time market data with python and redis
real-time data = critical for algo trading. redis = in-memory cache for speed. python pipeline implementation. the latency problem # pulling data every request: