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