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:
-
position store — TimescaleDB. positions table with current quantities, strikes, expiries, option types. refreshed from IB/Tastyworks API every 30 seconds.
-
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.
-
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:
-
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.
-
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.
-
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