Skip to main content

execution quality audit: q1 slippage cost me more than i thought

2:30am wednesday. april 1st. no this is not a joke post.

been staring at execution data for the last three hours and i have a headache. Q1 closed basically flat — detailed numbers in the march wrap. but flat is flat, and when i dug into why flat, the answer wasn’t strategy failure. it was execution bleed.

slippage is the cost nobody talks about honestly. you see backtests showing 1.8 sharpe and you think you’re good. live trading shows 1.2. that delta isn’t always the strategy being wrong — a lot of it is execution quality. how fast you fill, how wide the spread at execution time, how often your limit misses and you have to chase.

did a full Q1 execution audit this week. ugly data. sharing it.


the audit methodology
#

pulled every live fill from Q1 across all three legs of my setup (options via IB/Tastyworks, futures via TradeStation chicago colo, crypto via Kraken/Coinbase). compared actual fill prices against:

  1. mid-price at signal time
  2. NBBO at order submission
  3. theoretical execution from backtest engine

three different slippage measures because they tell you different things. mid-price slippage tells you about your strategy’s inherent execution footprint. NBBO slippage tells you about spread costs and how often you’re crossing. backtest vs live delta tells you whether your backtesting assumptions are realistic.

import pandas as pd
import numpy as np
from dataclasses import dataclass
from typing import Optional
import asyncio

@dataclass
class FillRecord:
    strategy: str
    instrument: str
    signal_time: pd.Timestamp
    order_submit_time: pd.Timestamp
    fill_time: pd.Timestamp
    signal_mid: float
    nbbo_bid: float
    nbbo_ask: float
    fill_price: float
    side: str  # 'buy' or 'sell'
    quantity: float
    notional: float

    @property
    def mid_slippage_bps(self) -> float:
        """Slippage vs mid-price at signal time, in basis points"""
        if self.side == 'buy':
            slip = (self.fill_price - self.signal_mid) / self.signal_mid
        else:
            slip = (self.signal_mid - self.fill_price) / self.signal_mid
        return slip * 10000

    @property
    def spread_cost_bps(self) -> float:
        """Half-spread cost at execution time"""
        spread = self.nbbo_ask - self.nbbo_bid
        half_spread = spread / 2
        return (half_spread / self.signal_mid) * 10000

    @property
    def signal_to_fill_ms(self) -> float:
        """Total latency from signal to fill"""
        return (self.fill_time - self.signal_time).total_seconds() * 1000


class ExecutionQualityAnalyzer:
    def __init__(self, fills: list[FillRecord]):
        self.fills = fills
        self.df = self._to_dataframe()

    def _to_dataframe(self) -> pd.DataFrame:
        records = []
        for f in self.fills:
            records.append({
                'strategy': f.strategy,
                'instrument': f.instrument,
                'fill_time': f.fill_time,
                'mid_slip_bps': f.mid_slippage_bps,
                'spread_bps': f.spread_cost_bps,
                'latency_ms': f.signal_to_fill_ms,
                'notional': f.notional,
                'hour_of_day': f.fill_time.hour,
                'day_of_week': f.fill_time.dayofweek
            })
        return pd.DataFrame(records)

    def summary_by_strategy(self) -> pd.DataFrame:
        return self.df.groupby('strategy').agg(
            fills=('mid_slip_bps', 'count'),
            mean_slip_bps=('mid_slip_bps', 'mean'),
            median_slip_bps=('mid_slip_bps', 'median'),
            p95_slip_bps=('mid_slip_bps', lambda x: np.percentile(x, 95)),
            mean_spread_bps=('spread_bps', 'mean'),
            mean_latency_ms=('latency_ms', 'mean'),
            total_notional=('notional', 'sum')
        ).round(3)

    def dollar_cost_estimate(self) -> dict:
        """Estimate total dollar cost of slippage vs backtest assumptions"""
        costs = {}
        for strat, group in self.df.groupby('strategy'):
            # backtest assumed 1bps slippage; actual was higher
            actual_mean_bps = group['mid_slip_bps'].mean()
            backtest_assumed_bps = 1.0
            excess_bps = max(0, actual_mean_bps - backtest_assumed_bps)
            total_notional = group['notional'].sum()
            costs[strat] = {
                'actual_bps': round(actual_mean_bps, 2),
                'assumed_bps': backtest_assumed_bps,
                'excess_bps': round(excess_bps, 2),
                'estimated_cost': round((excess_bps / 10000) * total_notional, 2)
            }
        return costs

    def execution_quality_by_hour(self) -> pd.DataFrame:
        return self.df.groupby('hour_of_day').agg(
            mean_slip_bps=('mid_slip_bps', 'mean'),
            fills=('mid_slip_bps', 'count'),
            mean_latency_ms=('latency_ms', 'mean')
        ).reset_index()

this analyzer runs nightly on the previous day’s fills. the .dollar_cost_estimate() method was the one that made me want to close my laptop and go to bed.


the actual numbers
#

here’s the slippage breakdown by strategy for Q1 2026:

strategy fills mean slip (bps) backtest assumed (bps) estimated excess cost
SPX premium selling 312 3.8 1.0 ~$14,200
ES momentum 847 2.1 1.0 ~$8,900
BTC/ETH momentum 1,203 4.7 2.0 ~$19,400
sector ETF vol plays 189 2.3 1.5 ~$3,100

total estimated excess slippage vs backtest: ~$45,600

for a flat quarter, that’s… most of the loss. my strategies made money on paper. slippage ate it.

crypto was the worst. 4.7bps mean slippage on BTC/ETH when i was assuming 2.0bps in backtests. the problem is partially spread (crypto spreads widen massively during volatility), partially latency (my crypto execution goes through Coinbase Advanced API with ~180ms round trip from san diego vs ~12ms for ES from chicago colo).


slippage distribution: SPX vs crypto
#

that fat right tail on the crypto violin is the problem. most fills are fine. but those outlier fills — 15-20bps slippage events — happen when vol spikes and the spread explodes. my algo keeps submitting because the signal is strong, but the fill quality collapses.

for SPX it’s more symmetric. the occasional bad fill is usually around major macro events (CPI, FOMC). manageable.


time-of-day matters way more than i expected
#

this was the other finding. i already knew open and close were messier. i didn’t realize how much the intraday pattern affected my specific strategies.

open (9-10am ET) and close (3:30-4pm ET) are obvious — wider spreads, more volatility, worse fills. that’s expected. what surprised me was the overnight crypto execution quality. crypto is supposed to trade 24/7 with consistent liquidity, but after midnight ET, fill quality degrades noticeably. less depth, wider spreads. the 2-4am ET window is particularly bad for me, which is exactly when a lot of my crypto signals fire because that’s when asian session gets active.

i need to either:

  1. filter crypto signals during that window (reduce fill count but improve quality)
  2. split larger crypto orders more aggressively (less market impact per order)
  3. add spread-quality filter to the crypto execution layer (only take signal if current spread < X bps)

going with option 3 first. already coded it up. testing this week.


the infrastructure piece: chicago still paying off but unevenly
#

my chicago colo server handles all ES/NQ futures execution plus routes time-sensitive options orders through a dedicated IB connection. average latency from colo to exchange: 8-12ms. my san diego home setup: 45-80ms depending on time of day.

for ES momentum the difference is measurable. when i ran the same strategy through home vs colo during overlap periods, colo fills averaged 0.8bps slippage vs 2.4bps from home. that 1.6bps difference on $8.9M quarterly notional was worth holding onto the colo lease.

crypto is the problem child because it can’t go through chicago — coinbase and kraken don’t have chicago presence, and even if they did, the san diego → coinbase network path is what it is. this is structural. the spread filter is my only real lever here.

been poking around the r/algotrading thread on execution quality for systematic traders to see if others are seeing similar degradation in crypto execution. lot of people dealing with the same problem. some are using FIX connections directly to exchanges but that’s serious infrastructure commitment for the fill improvement you get.

also found this automated trading journal on NexusFi that’s been tracking systematic strategy performance for a while — good reality check that everyone deals with execution reality vs backtest idealization. i go there when i need perspective.


what changes in Q2
#

three concrete things i’m implementing:

1. spread quality filter on crypto execution

class CryptoExecutionFilter:
    def __init__(
        self,
        max_spread_bps: float = 8.0,
        max_latency_ms: float = 300,
        blackout_hours: list[int] = [0, 1, 2, 3]  # ET hours with historically bad fills
    ):
        self.max_spread_bps = max_spread_bps
        self.max_latency_ms = max_latency_ms
        self.blackout_hours = blackout_hours

    def should_execute(
        self,
        signal_time: pd.Timestamp,
        current_bid: float,
        current_ask: float,
        current_mid: float,
        connection_latency_ms: float
    ) -> tuple[bool, str]:
        """Returns (should_execute, reason)"""
        hour_et = signal_time.tz_convert('US/Eastern').hour
        if hour_et in self.blackout_hours:
            return False, f"blackout_hour_{hour_et}"

        spread_bps = ((current_ask - current_bid) / current_mid) * 10000
        if spread_bps > self.max_spread_bps:
            return False, f"spread_too_wide_{spread_bps:.1f}bps"

        if connection_latency_ms > self.max_latency_ms:
            return False, f"latency_too_high_{connection_latency_ms:.0f}ms"

        return True, "ok"

    def log_skip(self, signal_id: str, reason: str) -> None:
        """Log filtered signals for later analysis"""
        # track skip rate -- if skipping >30% of signals, filter is too aggressive
        pass

the skip rate tracking is important. if i filter out 40% of crypto signals to improve fill quality, i might be improving slippage but reducing notional so much that dollar P&L drops. need to find the efficient frontier.

2. time-weighted order splitting for large options orders

for SPX premium selling positions >$50k notional, i’m going to split into 3-4 tranches over 15-20 minutes instead of one large order. reduces market impact. costs some on signals that move fast but should improve average fill quality for the larger trades.

3. separate latency tracking by exchange and venue

right now my prometheus metrics track latency as one combined number. i need per-exchange latency visibility so i can see when a specific venue’s fill quality is degrading in real time, not just in post-hoc analysis.


A. had breakfast ready when i finally closed the laptop around 7am. she had one look at me and just put coffee down without saying anything. she gets it.

the numbers are what they are. Q1 was flat partly because of slippage. Q2 fixes that or it doesn’t. already know i’ll be running this same audit at the end of june.

sometimes i think about how my dad would have attacked this problem — he was obsessive about optimizing systems at his biotech startup, tracking every inefficiency. probably would have had a spreadsheet going back to day one. i just got to it three months in, which is probably three months too late.

whatever. Q2 starts now.

-AK

Related

q1 close: final numbers, colo benchmarks, and q2 setup
friday night. Q1 officially in the books. did the math earlier while A. was cooking. she noticed i went quiet and just left me to it. that’s one of the things i didn’t expect about being married — how well she reads when to give space. anyway.
strategy health scoring: detecting algo decay before it wrecks your Q2
2:45am monday. first trading day of Q2. Q1 is officially in the rearview — closed at basically flat, full numbers are in friday’s post. the weekend was heavy. not going into it right now. but Q2 starts regardless, and the algos don’t wait for you to process.
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.
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.
signal decay and execution latency - my hidden edge killer
2:15am on a monday. been staring at fill data for the past six hours. march has been rough. not catastrophically down, but underperforming where my models say i should be. january was decent (+2.1%). february was a loss (-1.3%). march was supposed to recover and it’s just… flat.
slippage models - making backtests actually realistic
been thinking about slippage modeling a lot lately. most backtest frameworks have absolute dogshit slippage assumptions - either zero (lmao) or some fixed percentage that doesn’t scale with order size or volatility.