Skip to main content

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.

models are right. fills are wrong. edge is getting eaten somewhere between signal generation and execution.

found it tonight.

signal decay is real and i ignored it
#

every trading signal has a shelf life.

generate a signal at time T. execute at time T+100ms. you’re working with stale information.

the alpha in your signal decays the moment you generate it.

how fast it decays depends on:

  • market type (crypto decays faster than equities)
  • strategy type (momentum signals decay faster than mean reversion)
  • time of day (high vol periods = faster decay)
  • competition (more algos competing = faster decay)

i’ve been running momentum strategies on crypto without properly accounting for this.

result: i’m entering positions 50-200ms late depending on which server executes. by then, the signal has partially decayed.

math says i should capture X alpha. fills say i’m capturing 0.73X.

26% of my edge was just… evaporating.

measuring signal decay
#

ran the analysis on three months of fill data.

for each trade: timestamp signal generation, timestamp fill, calculate time elapsed. then measure actual return vs expected return at signal time.

signal decay curve for my crypto momentum strategies. at 78ms (home server median), capturing 76% of expected alpha. at 19ms (chicago colo median), capturing 94%. that 18% gap is what’s been eating my march returns. shaded band is 95% confidence interval across 2,400 trades.

brutal to look at.

at 78ms latency (home server median), i’m capturing 76% of expected alpha.

the other 24% evaporated before my fill came back.

at 19ms (chicago colo), i capture 94%.

that’s 18 percentage points of alpha recovery just from being 59ms faster.

on a $1.2M book running these strategies, that’s real money.

where the latency actually comes from
#

broke it down by component:

home server (san diego):

  • signal computation: 2-4ms (python, local)
  • redis lookup for market state: 1-3ms
  • order construction + validation: 1-2ms
  • API call over internet: 45-80ms (this is the killer)
  • order routing at exchange: 5-15ms
  • total: 54-104ms

chicago colo:

  • signal computation: 2-4ms (same code)
  • redis lookup: 1-3ms (replicated instance)
  • order construction: 1-2ms
  • network to exchange: 4-8ms (dedicated fiber)
  • order routing: 5-12ms
  • total: 13-29ms

internet RTT from san diego to coinbase datacenter (chicago): 45-80ms.

that one hop kills everything.

the colo sits 30 feet (metaphorically) from the matching engine. fiber, not internet. latency that’s an order of magnitude better.

latency distribution
#

this is what the actual fill latency distribution looks like across 2,400 trades:

home server peaks around 78ms median with a meaningful tail above 150ms (bad internet moments). chicago colo is a tight distribution centered on 19ms. those 150ms+ home fills are executing against stale signals with 40% alpha already gone.

home server: median 78ms, p95 is 148ms.

chicago colo: median 19ms, p95 is 34ms.

the tail is what gets you. those 148ms fills on the home server are executing into signals that have lost 40%+ of their alpha. you’re basically noise trading at that point.

the tracking code
#

built a latency measurement system that wraps every execution:

import asyncio
import time
import redis
import logging
import statistics
from dataclasses import dataclass, field
from typing import Optional, List, Dict

logger = logging.getLogger(__name__)


@dataclass
class SignalEvent:
    signal_id: str
    symbol: str
    strategy: str
    signal_type: str        # 'entry' | 'exit'
    expected_price: float
    expected_alpha: float   # expected return at signal time
    generated_at: float     # unix timestamp ms precision


@dataclass
class FillEvent:
    signal_id: str
    fill_price: float
    fill_qty: float
    filled_at: float        # unix timestamp ms precision
    venue: str              # 'home_san_diego' | 'colo_chicago'


@dataclass
class LatencyRecord:
    signal: SignalEvent
    fill: FillEvent
    signal_to_fill_ms: float
    price_slippage_bps: float
    alpha_captured: float   # fraction of expected alpha actually captured
    edge_lost_pct: float


class ExecutionLatencyTracker:
    """
    tracks signal-to-fill latency and measures alpha decay
    in real time across all execution venues.

    core insight: every ms of latency costs alpha.
    this class quantifies exactly how much edge is being left on the table.
    """

    # strategy-specific signal half-lives in ms
    # measured empirically: latency at which 50% alpha is gone
    SIGNAL_HALF_LIVES = {
        'crypto_momentum':    85,    # fast decay - competitive market
        'crypto_mean_rev':   450,    # slower - reversion persists longer
        'es_momentum':       120,    # moderate - futures HFT competition
        'nq_momentum':       130,
        'options_momentum':  350,    # slower - options flow is less reactive
        'options_vol_sell':  900,    # very slow - structural edge persists
    }

    def __init__(self, redis_client: redis.Redis, strategy_name: str):
        self.redis = redis_client
        self.strategy = strategy_name
        self.records: List[LatencyRecord] = []
        self.half_life_ms = self.SIGNAL_HALF_LIVES.get(strategy_name, 200)

    def record_signal(self, signal: SignalEvent) -> None:
        """log signal with microsecond timestamp for latency measurement"""
        key = f"signal:{signal.signal_id}"
        payload = {k: str(v) for k, v in {
            'symbol': signal.symbol,
            'strategy': signal.strategy,
            'signal_type': signal.signal_type,
            'expected_price': signal.expected_price,
            'expected_alpha': signal.expected_alpha,
            'generated_at': signal.generated_at
        }.items()}

        self.redis.hset(key, mapping=payload)
        self.redis.expire(key, 60)  # signals expire after 60s - stale if not filled

    async def record_fill(self, fill: FillEvent) -> Optional[LatencyRecord]:
        """match fill to originating signal, compute latency and alpha decay"""
        key = f"signal:{fill.signal_id}"
        raw = self.redis.hgetall(key)

        if not raw:
            logger.warning(f"signal {fill.signal_id} expired or missing - fill latency >60s")
            return None

        signal = SignalEvent(
            signal_id=fill.signal_id,
            symbol=raw[b'symbol'].decode(),
            strategy=raw[b'strategy'].decode(),
            signal_type=raw[b'signal_type'].decode(),
            expected_price=float(raw[b'expected_price']),
            expected_alpha=float(raw[b'expected_alpha']),
            generated_at=float(raw[b'generated_at'])
        )

        latency_ms = fill.filled_at - signal.generated_at
        price_slippage_bps = (
            abs(fill.fill_price - signal.expected_price) / signal.expected_price * 10000
        )

        # exponential decay model: alpha(t) = alpha_0 * 0.5^(t / half_life)
        alpha_captured = signal.expected_alpha * (0.5 ** (latency_ms / self.half_life_ms))
        edge_lost_pct = max(0, 1 - (alpha_captured / signal.expected_alpha))

        record = LatencyRecord(
            signal=signal,
            fill=fill,
            signal_to_fill_ms=latency_ms,
            price_slippage_bps=price_slippage_bps,
            alpha_captured=alpha_captured,
            edge_lost_pct=edge_lost_pct
        )

        self.records.append(record)
        await self._push_to_grafana(record)
        return record

    async def _push_to_grafana(self, record: LatencyRecord) -> None:
        """update redis timeseries for grafana dashboard"""
        ts_key = f"latency_ts:{self.strategy}"
        pipe = self.redis.pipeline()
        pipe.lpush(f"{ts_key}:latency_ms", round(record.signal_to_fill_ms, 2))
        pipe.lpush(f"{ts_key}:alpha_captured", round(record.alpha_captured, 6))
        pipe.lpush(f"{ts_key}:edge_lost", round(record.edge_lost_pct, 4))
        pipe.ltrim(f"{ts_key}:latency_ms", 0, 2999)
        pipe.ltrim(f"{ts_key}:alpha_captured", 0, 2999)
        pipe.ltrim(f"{ts_key}:edge_lost", 0, 2999)
        await pipe.execute()

    def get_stats(self) -> Dict:
        """current latency and alpha capture statistics"""
        if not self.records:
            return {'error': 'no records yet'}

        latencies = sorted(r.signal_to_fill_ms for r in self.records)
        alphas = [r.alpha_captured for r in self.records]
        total_expected = sum(r.signal.expected_alpha for r in self.records)
        total_captured = sum(r.alpha_captured for r in self.records)

        return {
            'count': len(latencies),
            'median_ms': statistics.median(latencies),
            'p95_ms': latencies[int(0.95 * len(latencies))],
            'p99_ms': latencies[int(0.99 * len(latencies))],
            'mean_alpha_captured': statistics.mean(alphas),
            'total_edge_lost_pct': round((1 - total_captured / total_expected) * 100, 1),
            'half_life_ms': self.half_life_ms
        }

    def get_venue_comparison(self) -> Dict[str, Dict]:
        """latency breakdown by execution venue"""
        venues: Dict[str, list] = {}
        for r in self.records:
            v = r.fill.venue
            if v not in venues:
                venues[v] = []
            venues[v].append(r.signal_to_fill_ms)

        result = {}
        for venue, lats in venues.items():
            s = sorted(lats)
            result[venue] = {
                'count': len(s),
                'median_ms': statistics.median(s),
                'p95_ms': s[int(0.95 * len(s))],
                'mean_alpha_captured': statistics.mean(
                    r.alpha_captured for r in self.records if r.fill.venue == venue
                )
            }
        return result

    def identify_routing_errors(self) -> List[LatencyRecord]:
        """
        find fills that should have gone to colo but didn't.
        any fill >50ms on a strategy with half_life <100ms is a routing error.
        """
        if self.half_life_ms >= 300:
            return []  # slow strategy, home is fine
        return [
            r for r in self.records
            if r.signal_to_fill_ms > 50 and r.fill.venue == 'home_san_diego'
        ]

the SIGNAL_HALF_LIVES dict is the critical piece.

every strategy gets an empirically measured half-life. crypto momentum at 85ms means the signal is half-dead at 85ms. options vol selling at 900ms means you can take your time.

the identify_routing_errors method flags exactly what was happening to me: fast-decay strategies executing from the wrong venue.

what changed
#

before: all strategies routing based on whatever server was available.

after: strict routing rules based on signal half-life:

ROUTING_RULES = {
    'crypto_momentum':  'colo_chicago',   # half_life=85ms, colo mandatory
    'crypto_mean_rev':  'colo_chicago',   # half_life=450ms, colo preferred
    'es_momentum':      'colo_chicago',   # half_life=120ms, colo mandatory
    'nq_momentum':      'colo_chicago',   # half_life=130ms, colo mandatory
    'options_momentum': 'home_san_diego', # half_life=350ms, home acceptable
    'options_vol_sell': 'home_san_diego', # half_life=900ms, home fine
}

routing is now enforced in the execution layer. not optional, not best-effort.

result after 10 days: alpha capture on crypto momentum went from 73% to 91%.

eighteen percentage points of recovered edge.

from fixing a routing config.

the infrastructure math
#

this is why the chicago colo ($25k hardware + $800/month) is not optional for serious crypto momentum work.

home server is legitimately good hardware. dell poweredge, 10Gbe local network, low latency within the house. but the last mile (internet RTT to exchange) is where everything falls apart.

internet RTT from san diego to chicago: 45-80ms depending on the day.

colo fiber RTT: 4-8ms.

that difference translates directly to edge. the colo pays for itself just by reducing latency on fast-decay strategies.

been comparing notes on execution quality with some algorithmic traders on NexusFi — the venue routing discussion is more nuanced than i realized. a few quants there are using co-location chains across multiple exchange datacenters for cross-venue arb. makes my chicago-only setup look basic.

next step is probably adding a second colo in the same building as coinbase advanced. for now, the single chicago node is the big improvement.

takeaways
#

if you’re running momentum algos, measure your signal decay.

don’t assume fills are arriving “fast enough.”

quantify it. track it. build the routing logic to enforce it.

three months of underperformance. found the cause in six hours of log analysis. routing fix took two days. alpha capture jumped 18 points.

measure everything.


2:15am. been digging through fill data since 10pm. the signal decay analysis explains why march was running below model expectations - crypto momentum strategies executing from home server at 78ms average latency, capturing 73% of expected alpha. routing fix is live as of yesterday. two more weeks to confirm the numbers recover.

random thought: my dad was VP of engineering at a biotech startup. obsessed with infrastructure performance problems. i don’t think much about him most days anymore but something about a 2am debugging session on latency optimization made me think of him. he would have gotten completely absorbed by this stuff. weird how that hits sometimes.

gonna sleep.

-AK

Related

chicago colocation - 67ms to 12ms latency improvement, worth the cost
moved execution server to chicago colo march 2024. 3 months data in. latency dropped 67ms → 12ms average. why chicago # CME exchange location: chicago
upgrading chicago colocation to 10gbe - latency improvements
chicago colocation server needed upgrade. 1gbe connection = bottleneck. current setup # location: chicago datacenter (equinix CH1)
exchange connectivity layer - handling binance, kraken, and coinbase in one abstraction
one of the most annoying parts of crypto algo trading is that every exchange has a different API. different auth schemes, different rate limits, different order types, different error codes. writing strategy logic for each exchange separately is a nightmare and a maintenance disaster.
redis for market data - why i ditched postgres
moved all my real-time market data from postgres to redis about 8 weeks ago. latency dropped from ~15ms to sub-millisecond. should’ve done this way earlier. the postgres problem # when i first built my algo infrastructure i stored everything in postgres because that’s what i knew. tick data, order book snapshots, greeks, everything went into timescaledb (postgres extension for time-series).
saturday slippage deep dive - where your edge goes to die
woke up at 2am couldn’t sleep. decided to run a full slippage analysis on last quarter’s trades. what i found is annoying but fixable. the invisible tax # every algo trader knows slippage exists.
redis timeseries - cutting latency from 45ms to 8ms
just finished a redis optimization project. latency went from 45ms to 8ms. here’s how. the problem # market data pipeline was bottlenecking at redis.