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.
flat isn’t a disaster. given what q1 threw at systematic premium selling — elevated vol, directional trending, tariff headline risk every 48 hours — flat is actually fine. but “fine” isn’t good enough. this post is about figuring out whether the result was structurally correct or just managed luck.
the attribution framework #
ran the full q1 analysis this weekend. the framework decomposes realized P&L by strategy into four components:
- theta component — daily theta collected × days held = expected carry from premium decay
- delta drift — actual P&L minus theta projection = directional movement contribution (negative when market moves against short positions)
- vega component — vol expansion/contraction impact on marked value during holding period
- slippage drag — execution quality vs mid-market at entry and exit legs
code that runs against the TimescaleDB trade history:
from dataclasses import dataclass, field
from typing import Optional
import pandas as pd
import numpy as np
from datetime import datetime, timezone
@dataclass
class TradeRecord:
trade_id: str
strategy_family: str
symbol: str
entry_ts: datetime
close_ts: Optional[datetime]
# Greeks at entry (per-contract, dollar-weighted)
entry_theta: float
entry_delta: float
entry_vega: float
entry_iv: float
# Trade economics
premium_collected: float # positive = credit received
realized_pnl: float
days_held: int
close_reason: str # 'target', 'stop', 'expiry', 'risk_engine'
# Calculated attribution fields
_theta_carry: float = field(init=False)
_delta_drift: float = field(init=False)
_slippage_est: float = field(init=False)
def __post_init__(self):
self._theta_carry = self.entry_theta * self.days_held
self._delta_drift = self.realized_pnl - self._theta_carry
# rough slippage: ~1.8% of gross premium for two-legged structures
self._slippage_est = -abs(self.premium_collected) * 0.018
@property
def theta_carry(self) -> float:
return self._theta_carry
@property
def delta_drift(self) -> float:
return self._delta_drift
@property
def slippage_estimate(self) -> float:
return self._slippage_est
class Q1AttributionAnalyzer:
def __init__(self, trades: list[TradeRecord]):
self.df = pd.DataFrame([{
'trade_id': t.trade_id,
'strategy': t.strategy_family,
'entry_date': pd.Timestamp(t.entry_ts),
'month': pd.Timestamp(t.entry_ts).month,
'days_held': t.days_held,
'premium': t.premium_collected,
'realized_pnl': t.realized_pnl,
'theta_carry': t.theta_carry,
'delta_drift': t.delta_drift,
'slippage': t.slippage_estimate,
'close_reason': t.close_reason,
'entry_iv': t.entry_iv,
} for t in trades])
def monthly_attribution(self) -> pd.DataFrame:
grouped = self.df.groupby('month').agg(
trade_count=('trade_id', 'count'),
total_premium=('premium', 'sum'),
theta_carry=('theta_carry', 'sum'),
delta_drift=('delta_drift', 'sum'),
slippage=('slippage', 'sum'),
realized_pnl=('realized_pnl', 'sum'),
win_rate=('realized_pnl', lambda x: (x > 0).mean()),
avg_iv_at_entry=('entry_iv', 'mean'),
).round(2)
return grouped
def regime_breakdown(self, vix_series: pd.Series) -> pd.DataFrame:
"""Classify trade entries by VIX regime and compare performance."""
vix_lookup = vix_series.to_dict()
self.df['entry_vix'] = self.df['entry_date'].dt.date.map(vix_lookup)
self.df['regime'] = pd.cut(
self.df['entry_vix'],
bins=[0, 18, 25, float('inf')],
labels=['low_vol', 'medium_vol', 'high_vol']
)
return self.df.groupby('regime', observed=True).agg(
count=('trade_id', 'count'),
avg_pnl=('realized_pnl', 'mean'),
win_rate=('realized_pnl', lambda x: (x > 0).mean()),
avg_theta_carry=('theta_carry', 'mean'),
avg_delta_drift=('delta_drift', 'mean'),
).round(3)
def close_reason_breakdown(self) -> pd.DataFrame:
"""How trades closed: target hit, stopped out, expiry, risk engine."""
return self.df.groupby('close_reason').agg(
count=('trade_id', 'count'),
avg_pnl=('realized_pnl', 'mean'),
win_rate=('realized_pnl', lambda x: (x > 0).mean()),
).round(2)
output from the q1 run:
monthly attribution ($):
trades theta_carry delta_drift slippage realized_pnl win_rate avg_iv
jan 31 +21,400 +5,900 -1,800 +25,500 72.6% 17.2
feb 27 +16,200 -30,600 -1,500 -15,900 44.4% 21.8
mar 25 +13,800 -23,100 -1,100 -10,400 40.0% 24.1
theta carry is consistently positive every month. the premium selling edge is real and it doesn’t disappear. what flips the sign on P&L entirely is delta drift.
january: VIX averaging 17.2, market range-bound — delta drift was additive. february: market picked a direction and stayed with it, VIX climbed to 21.8 average. march: tariff headlines dropped every other day, vol spiked twice, VIX averaged 24.1. both bad months, delta drift wiped out multiple weeks of theta accumulation in days.
the regime picture #
same analysis split by vol regime at entry:
| regime | trades | avg_pnl | win_rate | avg_theta | avg_delta_drift |
|---|---|---|---|---|---|
| low_vol (VIX<18) | 38 | +$823 | 76% | +$692 | +$190 |
| medium_vol (18-25) | 41 | -$127 | 49% | +$601 | -$681 |
| high_vol (VIX>25) | 4 | -$2,104 | 25% | +$589 | -$2,636 |
low vol: theta works cleanly, delta drift is mild or additive. short gamma in a range-bound market harvests edge without the compounding directional problem.
medium vol: basically coin flip P&L. theta is there but delta drift consistently cancels it. this is the regime i spent most of the quarter trading in — 41 of 83 entries.
high vol: small sample (4 trades), net negative, delta drift is the entire problem. the risk engine cutoff during the march spike (wrote about that friday) saved me from way more exposure here — without the automated pause, that 4-trade sample would’ve been 12 trades.
the options premium selling thread on NexusFi has years of documented discussion on exactly this failure mode. regime selection for systematic options isn’t optional — it’s the whole game. you can have a perfect theta-harvesting model and still lose consistently if you’re entering in the wrong environment.
q1 cumulative p&l by strategy #
options book peaked in week 3 and gave it back over the next 7 weeks. crypto tracked equities risk-off when the cross-asset correlation spiked in early march — the correlation trigger reduced crypto book size 25% automatically, which helped. futures were quiet all quarter, small positive contribution. the problem was entirely the options book walking into a regime shift with full size.
factor attribution waterfall #
this chart is the clearest summary of q1. theta is roughly stable each month — $13-21k depending on trading days and deployed notional. the swing is entirely delta drift: additive in january, destructive in february and march.
what i’m building for q2 #
the fix is a vol regime filter on entries. stop taking full-size short-gamma positions when:
- VIX is trending upward over a 5-day lookback (not just elevated, but rising)
- implied vs realized vol spread is compressing (market pricing vol at close to realized = paying less edge per unit of risk)
- cross-asset SPX/BTC correlation is elevated above 0.65 and holding
in those conditions: reduce new position size by 50-60%, buy wings on short strangles to turn them into condors, and wait for the regime to stabilize before rebuilding full size.
the backtesting work starts this week. going to run q4 2025 and q1 2026 against the hypothetical filtered approach and see how the numbers change. if the improvement is material — lower delta drift without proportionally hurting theta carry — live deploy for april.
infrastructure note #
the attribution query runs against the chicago colo TimescaleDB instance. 83 trades with full greek snapshots, regime labels, and close metadata: 380ms query time. same query against the local san diego box is 1.1 seconds. both are fast enough for weekend analysis. chicago is source of truth because that’s where live execution writes.
one q1 data point worth noting: chicago server uptime was 100% through all the vol spikes. wednesday’s VIX move to 28, the tariff headline drops, nothing. the machine didn’t blink. colocation is an operational cost i’ve never regretted.
A. made dinner tonight and left me alone to run numbers all day. we’ve both been in our own heads this weekend — she had something going on with her own project, i had this. we move around the apartment on days like this without getting in each other’s way. comfortable silence after a rough week.
saw a photo on my phone earlier from when my dad was messing around with old network switches in his home office, trying to get a homelab running. he never got it stable, kept running into VLAN config problems. he would’ve figured it out eventually. the chicago server rack would’ve absolutely blown his mind.
q2 starts tomorrow. let’s see if the filter actually fixes it.
-AK