2:15 AM. saturday.
april closed today. before I get into what I actually built this week I’ll do the quick numbers.
april final: +$15,800. minor revision down from the +$16,500 estimate I had wednesday — a few iron condor legs settled a tick or two against on friday’s close, plus a small NQ position gave back $430 into the bell. nothing significant. still a clean month.
YTD: +$17,200 on a ~$1.18M starting base. call it +1.46%.
three posts about april is enough. here’s what I spent the week building.
the gap I’ve been ignoring #
every signal the system generates passes through a filter stack before it routes to execution. IV rank minimums. regime gates. spread quality checks. event risk throttle from the tariff chaos in week one.
those filters are static. hard rules I wrote over the past 18 months based on post-mortems and gut feel. they catch obvious garbage — signals firing in low IV environments, with wide spreads, during event windows. what they can’t do is rank the signals that clear all the filters. they’re binary pass/fail. a signal scoring 0.01 above the IV rank threshold gets treated identically to a signal in a perfect high-IV environment.
there’s predictive structure in those features. I know this because I’ve done post-mortems on the fills. signals with IV rank in the 75th percentile outperform signals in the 45th percentile on average, even though both clear the filter. same pattern shows up for bid-ask spread, strategy recent win rate, IV richness vs realized vol.
so the question I finally sat down to answer: can I train a binary classifier on historical fills that scores new signals on expected quality before they hit execution? not predicting P&L — that’s not tractable. predicting whether this signal will likely end up in the top or bottom quartile of outcomes for this strategy.
even modest AUC (0.65–0.70) is useful if it’s stable. filtering the bottom 15–20% of expected-quality signals without cutting many good ones improves the P&L distribution without reducing frequency too much.
features at signal time #
tuesday. most of the day. pulling features from TimescaleDB, Redis (ThetaData IV snapshots), and IBKR fill logs, then aligning timestamps. ThetaData snapshots and IBKR fills have slightly different latency, so I had to be careful about which values were genuinely available at the moment the signal fired vs. what I’d be inadvertently forward-filling.
features that made the final cut after dropping near-zero correlation with outcomes:
from typing import TypedDict
class SignalFeatures(TypedDict):
# IV environment at signal time
iv_rank_30d: float # 0-100 percentile rank vs trailing 30 days
iv_percentile_252d: float # annual horizon for comparison
iv_vs_rv_ratio: float # implied vol / realized vol ratio — >1.0 means IV rich
# Market state
vix_spot: float # VIX level at signal time
vix_term_ratio: float # VIX3M / VIX1M (term structure slope)
spy_vol_vs_20d_avg: float # today's realized vol as ratio vs 20-day average
# Signal specifics
dte: int # days to expiration
delta_short_strike: float # delta of the short leg
bid_ask_pct: float # spread as % of mid (execution quality proxy)
time_of_day_sin: float # cyclical encoding of hour-of-day (sin component)
time_of_day_cos: float # cyclical encoding of hour-of-day (cos component)
# Strategy momentum
strategy_7d_win_rate: float # this strategy's win rate over last 7 days
strategy_14d_pnl_zscore: float # z-score of strategy P&L vs historical mean
# Contract characteristics
open_interest_ratio: float # OI at this strike / total OI at expiry
moneyness: float # (strike - spot) / spot for the short leg
quick note on the cyclical time encoding. if you encode hour-of-day as a raw integer, a tree model will treat 23:00 and 01:00 as maximally distant. that’s geometrically wrong — they’re adjacent in trading session structure. sin/cos wraps the dimension correctly. I’ve seen discussions of this in ML-for-quant threads but it’s surprisingly rare in practice for people running intraday algo features.
model: LightGBM with time-series cross-validation #
three reasonable choices for a tabular signal quality problem: XGBoost, LightGBM, or sklearn ensemble methods. went with LightGBM — faster on tabular data at this size, handles mixed types natively, doesn’t need feature scaling, and the built-in feature importance output is usable for monitoring without adding SHAP overhead.
label definition: top quartile of realized P&L within the same (strategy_id, expiration) cohort = 1, bottom quartile = 0. middle 50% dropped from training. cleaner binary signal than win/loss, which is too noisy given the per-strategy fill counts.
import lightgbm as lgb
import numpy as np
import pandas as pd
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import roc_auc_score
from typing import Tuple, List
FEATURE_COLS = list(SignalFeatures.__annotations__.keys())
def prepare_quality_labels(
fills_df: pd.DataFrame,
min_cohort_size: int = 40,
) -> pd.DataFrame:
"""
Label top/bottom quartile within (strategy_id, expiration) cohorts.
Drop middle 50% — cleaner binary signal.
Args:
fills_df: DataFrame with columns: strategy_id, expiration,
realized_pnl, signal_timestamp, + all FEATURE_COLS
min_cohort_size: Skip cohorts with fewer fills (insufficient stats)
Returns:
Labeled DataFrame sorted by signal_timestamp, with 'quality_label' column.
"""
labeled: List[pd.DataFrame] = []
for (strategy, expiry), group in fills_df.groupby(["strategy_id", "expiration"]):
if len(group) < min_cohort_size:
continue
q25 = group["realized_pnl"].quantile(0.25)
q75 = group["realized_pnl"].quantile(0.75)
top_q = group[group["realized_pnl"] >= q75].copy()
top_q["quality_label"] = 1
bot_q = group[group["realized_pnl"] <= q25].copy()
bot_q["quality_label"] = 0
labeled.extend([top_q, bot_q])
if not labeled:
raise ValueError("no cohorts met min_cohort_size — check data or lower threshold")
return pd.concat(labeled).sort_values("signal_timestamp").reset_index(drop=True)
def train_quality_classifier(
X: pd.DataFrame,
y: pd.Series,
n_splits: int = 5,
) -> Tuple[lgb.Booster, float]:
"""
Binary quality classifier with TimeSeriesSplit cross-validation.
TimeSeriesSplit is non-negotiable here. Standard k-fold bleeds
future fills into training folds and gives optimistically wrong OOF scores.
gap=200 adds a buffer of 200 observations between train/val to reduce
leakage from strategy autocorrelation.
Returns:
(best_model, mean_oof_auc) where best_model = highest-AUC fold
"""
tscv = TimeSeriesSplit(n_splits=n_splits, gap=200)
params = {
"objective": "binary",
"metric": "auc",
"num_leaves": 31,
"learning_rate": 0.05,
"feature_fraction": 0.8,
"bagging_fraction": 0.8,
"bagging_freq": 5,
"min_child_samples": 20,
"reg_alpha": 0.1,
"reg_lambda": 0.1,
"verbose": -1,
}
fold_aucs: List[float] = []
fold_models: List[lgb.Booster] = []
for fold_idx, (train_idx, val_idx) in enumerate(tscv.split(X)):
X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
dtrain = lgb.Dataset(X_train, label=y_train)
dval = lgb.Dataset(X_val, label=y_val, reference=dtrain)
model = lgb.train(
params,
dtrain,
num_boost_round=600,
valid_sets=[dval],
callbacks=[
lgb.early_stopping(stopping_rounds=50, verbose=False),
lgb.log_evaluation(period=-1),
],
)
val_preds = model.predict(X_val)
auc = roc_auc_score(y_val, val_preds)
fold_aucs.append(auc)
fold_models.append(model)
mean_auc = float(np.mean(fold_aucs))
best_idx = int(np.argmax(fold_aucs))
return fold_models[best_idx], mean_auc
OOF AUC across 5 folds: 0.672 (range 0.658–0.689, std 0.011).
not a crystal ball. wasn’t trying to build one. 0.67 is useful if it’s stable across the next few months.
roc curve (5-fold OOF) #
5-fold OOF. each fold trained on earlier data, validated on later data — no lookahead. diagonal is random baseline.
filtering threshold and holdout validation #
chose a gate threshold of 0.35 based on holdout analysis (last 3 months of fills withheld from training entirely).
at 0.35 on the holdout set:
- 18.3% of signals filtered out
- filtered signals: average realized P&L = -$285 (they genuinely underperformed the cohort)
- passed signals: average realized P&L moved from +$124 (unfiltered baseline) to +$178
- precision at threshold: 0.71 — 71% of signals passed at ≥0.35 ended up in top quartile
not statistically definitive at 3 months of holdout. but directionally correct across all three strategy types. good enough to deploy with a monitoring window.
feature importance #
LightGBM gain-based importance. higher = more total information gain from splits on this feature across all trees.
the top results make sense:
strategy_7d_win_rate is the strongest predictor by a wide margin. if this strategy has been on a cold streak recently, current signals from it are lower quality on average. I don’t know yet whether that’s regime-driven edge decay, adverse market microstructure, or just mean reversion noise in small samples — but the signal is real. this was the biggest surprise to me.
iv_vs_rv_ratio (IV richness vs realized vol): when IV is elevated relative to recent realized, premium-selling positions have more cushion. IV that’s 1.4x realized gives you a wider margin before the position starts hurting. makes obvious sense in hindsight.
bid_ask_pct: wider spreads mean worse execution quality at fill time and lower net credit captured. the model found this more predictive than I expected. slippage analysis I did last month showed options execution quality has a larger impact on realized P&L than I was accounting for — this feature is picking that up.
dte and delta_short_strike: structural position characteristics. not as informative as the dynamic features but the model uses them.
been reading through some of the algo trading journals on NexusFi — a few people have documented pre-execution signal scoring approaches using regime filters and conditional probability tables. nobody specifically running LightGBM on this but the underlying idea isn’t new. the execution here is different: continuous feature space, trained on actual fills rather than hypothetical entries.
production deployment: quality gate in redis #
inference wrapper. model serialized to Redis at training time, loaded at startup, refreshed every 24h.
import redis
import pickle
import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
GATE_THRESHOLD = 0.35
@dataclass
class GateResult:
score: float
decision: str # "pass" | "filter" | "error"
threshold: float
features_available: int
class SignalQualityGate:
"""
Production inference wrapper for the LightGBM quality classifier.
Design principle: gate errors open. if inference fails for any reason,
signal passes through unchanged. i don't want the classifier blocking
execution when Redis is slow or model bytes are corrupt.
Model refreshed from Redis every 24h via background task.
"""
MODEL_REDIS_KEY = "quality_gate:lgbm_v1"
def __init__(self, redis_client: redis.Redis) -> None:
self.redis = redis_client
self.model: Any = None
self._load_model()
def _load_model(self) -> None:
raw = self.redis.get(self.MODEL_REDIS_KEY)
if raw is None:
logger.error("quality gate model missing from Redis — gate will error-open")
return
self.model = pickle.loads(raw)
logger.info("quality gate model loaded (%d bytes)", len(raw))
def evaluate(self, features: dict) -> GateResult:
if self.model is None:
return GateResult(
score=0.5, decision="error",
threshold=GATE_THRESHOLD, features_available=0
)
try:
vec = [features.get(k, 0.0) for k in FEATURE_COLS]
n_available = sum(1 for k in FEATURE_COLS if k in features)
score = float(self.model.predict([vec])[0])
decision = "pass" if score >= GATE_THRESHOLD else "filter"
return GateResult(
score=round(score, 4),
decision=decision,
threshold=GATE_THRESHOLD,
features_available=n_available,
)
except Exception as exc:
logger.exception("quality gate inference failed: %s", exc)
return GateResult(
score=0.5, decision="error",
threshold=GATE_THRESHOLD, features_available=0
)
error-open is the only design that makes sense here. classifier breaking during a live session shouldn’t interrupt execution — log the failure, let the signal pass, fix the issue async.
timescaledb: continuous aggregates for feature refresh #
the two strategy momentum features need fresh values at signal evaluation time without requiring a full table scan. continuous aggregates on TimescaleDB handle this automatically.
-- 7-day rolling strategy metrics
-- refreshed every 30 minutes by TimescaleDB background worker
CREATE MATERIALIZED VIEW quality_gate_strategy_metrics
WITH (timescaledb.continuous) AS
SELECT
strategy_id,
time_bucket('1 day', signal_timestamp) AS bucket,
COUNT(*) FILTER (WHERE realized_pnl > 0) * 1.0
/ NULLIF(COUNT(*), 0) AS win_rate_7d,
AVG(realized_pnl) AS avg_pnl,
STDDEV(realized_pnl) AS std_pnl,
COUNT(*) AS fill_count
FROM trade_fills
GROUP BY strategy_id, time_bucket('1 day', signal_timestamp)
WITH NO DATA;
SELECT add_continuous_aggregate_policy(
'quality_gate_strategy_metrics',
start_offset => INTERVAL '14 days',
end_offset => INTERVAL '30 minutes',
schedule_interval => INTERVAL '30 minutes'
);
-- feature computation at signal time (fast JOIN against pre-computed aggregate)
WITH recent_14d AS (
SELECT
strategy_id,
SUM(avg_pnl * fill_count) / NULLIF(SUM(fill_count), 0) AS rolling_mean,
SQRT(SUM(std_pnl * std_pnl * fill_count) / NULLIF(SUM(fill_count), 0)) AS rolling_std
FROM quality_gate_strategy_metrics
WHERE bucket >= CURRENT_DATE - INTERVAL '14 days'
GROUP BY strategy_id
),
today_metrics AS (
SELECT strategy_id, win_rate_7d, avg_pnl
FROM quality_gate_strategy_metrics
WHERE bucket = CURRENT_DATE
)
SELECT
t.strategy_id,
t.win_rate_7d AS strategy_7d_win_rate,
CASE
WHEN r.rolling_std > 0
THEN (t.avg_pnl - r.rolling_mean) / r.rolling_std
ELSE 0.0
END AS strategy_14d_pnl_zscore
FROM today_metrics t
JOIN recent_14d r USING (strategy_id);
the background worker refreshes every 30 minutes. at signal evaluation time this is a single JOIN — no sequential scan on trade_fills. latency is under 5ms on the current table size.
monitoring plan #
60-day trial window. metrics tracked in TimescaleDB, displayed in Grafana:
- filtered_rate (daily): should stay 15–20%. if it drifts above 30%, model is misfiring on a new regime and needs retraining or removal
- score_distribution (daily histogram): watch for distribution shift — leftward drift means the model is getting pessimistic about overall signal quality, possible regime change
- realized P&L delta: filtered vs passed signals’ average P&L. if that gap collapses to near-zero, gate has lost its edge
- per-strategy filter rate: if one strategy gets filtered >40% of the time persistently, something structural is happening to that strategy’s edge
will write up results at 60 days. if the gate doesn’t hold up I’ll pull it and document why.
A. fell asleep around 11. she had a long week — rough client review thursday that ran way over, then spent most of friday firefighting a database migration. she powered through it but was clearly drained by the time we ate. apartment’s quiet now, the way I like it at 2 AM.
may is open. april was fine. classifier’s deployed.
one of those weird moments I get sometimes — may 1, late at night, quiet apartment. dad used to say may was his favorite month. good weather before it gets too hot, baseball season finding its legs. stupid thing to remember at 2 AM but there it is.
-AK