Skip to main content

april done. built a signal quality gate with LightGBM.

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

Related

signal quality scoring: building a market-aware trade gate
2:15 AM wednesday. apartment quiet. A. went to bed around midnight — she had a client deadline today so it was a long one. checked the colo heartbeat before sitting down to write this. normal. algos running clean for the first time since last monday.
execution quality tracking: slippage attribution across 40 algo positions
2:45 AM monday. A. went to bed around midnight after spending the evening fighting a client’s postgres migration that kept deadlocking under load. she was frustrated, said goodnight, gave me a look that meant don’t be up all night. I said I wouldn’t be.
april theta harvest: weekly closed clean, colo queue backed up, thursday hit different
2:30 AM. friday night. A. made chicken marsala — she does it maybe once a month and I forget every time how good it is. ate around 7, she went back to her desk, lights off in the bedroom by midnight. apartment’s quiet. been staring at P&L since 11.
replaying the yen carry unwind: validating sqs against a real vol event
2:15 AM monday. system’s been clean since the websocket IV fix went live friday. heartbeat healthy, colo latency normal, no stale data flags. spent most of sunday going deep on something i’ve been meaning to do since the tariff postmortem.
fixing the stale iv problem: thetadata websocket streaming for real-time greeks
2:30 AM friday. been at this since 9 PM. promised myself two weeks ago, right in the middle of the tariff chaos, that i’d actually fix the IV rank staleness issue. the signal quality scoring work was the band-aid — a composite gate that tells the system “this signal isn’t reliable right now.” it worked. it’s in production. but the underlying problem was unchanged: during the spike, my IV rank was being computed from options data that was 10-14 minutes old. the signal wasn’t wrong, technically. it was just answering a question about a market that no longer existed.
tariff week post-mortem: what the data actually showed
2:30 AM monday. week one of what i’m calling “the post-tariff-chaos era” starts in a few hours. last week was one of those that splits into a clear before and after. monday and tuesday felt like freefall — VIX went from 20 to 32 in about 36 hours, SPX dropped hard, options spreads blew out 3-4x, and my event risk throttle (which I built the week prior and wrote about here) was earning every line of code it took to build. then wednesday happened. whoever made the tariff pause call did it at 1:07 PM eastern and watching the S&P rip 8% in ninety minutes while running algorithms was… a lot.