Skip to content

Commit d5db8cb

Browse files
Merge pull request #158 from FurkanEdizkan/feat/feed-latency
feat(latency): price-feed latency measurement
2 parents cc6980c + fc98b17 commit d5db8cb

12 files changed

Lines changed: 621 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""add feed_latency table for price-feed latency rollups
2+
3+
Revision ID: 0021
4+
Revises: 0020
5+
Create Date: 2026-06-10 00:00:00.000000
6+
"""
7+
8+
from collections.abc import Sequence
9+
10+
import sqlalchemy as sa
11+
import sqlmodel
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '0021'
16+
down_revision: str | None = '0020'
17+
branch_labels: Sequence[str] | None = None
18+
depends_on: Sequence[str] | None = None
19+
20+
21+
def upgrade() -> None:
22+
op.create_table(
23+
'feed_latency',
24+
sa.Column('id', sa.Integer(), nullable=False),
25+
sa.Column('market', sqlmodel.sql.sqltypes.AutoString(length=8), nullable=False),
26+
sa.Column('symbol', sqlmodel.sql.sqltypes.AutoString(length=24), nullable=False),
27+
sa.Column('kind', sqlmodel.sql.sqltypes.AutoString(length=8), nullable=False),
28+
sa.Column('samples', sa.Integer(), nullable=False),
29+
sa.Column('avg_ms', sa.Numeric(precision=12, scale=2), nullable=False),
30+
sa.Column('p95_ms', sa.Numeric(precision=12, scale=2), nullable=False),
31+
sa.Column('max_ms', sa.Numeric(precision=12, scale=2), nullable=False),
32+
sa.Column('recorded_at', sa.DateTime(), nullable=False),
33+
sa.PrimaryKeyConstraint('id'),
34+
)
35+
op.create_index(op.f('ix_feed_latency_market'), 'feed_latency', ['market'], unique=False)
36+
op.create_index(op.f('ix_feed_latency_symbol'), 'feed_latency', ['symbol'], unique=False)
37+
op.create_index(
38+
op.f('ix_feed_latency_recorded_at'), 'feed_latency', ['recorded_at'], unique=False
39+
)
40+
41+
42+
def downgrade() -> None:
43+
op.drop_index(op.f('ix_feed_latency_recorded_at'), table_name='feed_latency')
44+
op.drop_index(op.f('ix_feed_latency_symbol'), table_name='feed_latency')
45+
op.drop_index(op.f('ix_feed_latency_market'), table_name='feed_latency')
46+
op.drop_table('feed_latency')

engine/api/market.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
WebSocket,
2020
WebSocketDisconnect,
2121
)
22+
from pydantic import BaseModel
2223

24+
from appsettings.store import get_latency_warn_ms
2325
from auth.deps import CurrentUser, SessionDep
2426
from auth.security import decode_token
2527
from exchange.client import (
@@ -30,6 +32,7 @@
3032
Ticker,
3133
)
3234
from marketdata.cache import refresh_venue_candles
35+
from marketdata.latency import registry as latency_registry
3336
from marketdata.models import Candle
3437
from marketdata.stream import StreamManager
3538
from trading.venue_router import VenueRouter
@@ -153,3 +156,37 @@ async def market_stream(websocket: WebSocket, token: str = Query(...)) -> None:
153156
await asyncio.sleep(1.0)
154157
except WebSocketDisconnect:
155158
pass
159+
160+
161+
class LatencyStatsRead(BaseModel):
162+
"""Rolling-window latency for one (market, symbol, kind)."""
163+
164+
market: str
165+
symbol: str
166+
kind: str # ws (stream event time) | rest (candle round-trip)
167+
current_ms: float
168+
p50_ms: float
169+
p95_ms: float
170+
max_ms: float
171+
samples: int
172+
degraded: bool
173+
174+
175+
class LatencyRead(BaseModel):
176+
"""Price-feed latency — per-feed stats plus the overall degraded flag."""
177+
178+
warn_ms: int
179+
degraded: bool
180+
feeds: list[LatencyStatsRead]
181+
182+
183+
@router.get("/latency", response_model=LatencyRead)
184+
def feed_latency(_: CurrentUser, session: SessionDep) -> LatencyRead:
185+
"""How delayed the price data is, per feed (ws event time + REST RTT)."""
186+
warn_ms = get_latency_warn_ms(session)
187+
stats = latency_registry.stats(warn_ms=float(warn_ms))
188+
return LatencyRead(
189+
warn_ms=warn_ms,
190+
degraded=any(s.degraded for s in stats),
191+
feeds=[LatencyStatsRead(**s.__dict__) for s in stats],
192+
)

engine/appsettings/store.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,22 @@ def get_news_interval_hours(session: Session) -> int | None:
364364
def set_news_interval_hours(session: Session, hours: int | None) -> None:
365365
"""Set the news refresh interval (None/0 restores the daily schedule)."""
366366
set_setting(session, _NEWS_INTERVAL, str(hours) if hours else "")
367+
368+
369+
# -- feed latency ---------------------------------------------------------------
370+
371+
_LATENCY_WARN_MS = "latency_warn_ms"
372+
373+
374+
def get_latency_warn_ms(session: Session) -> int:
375+
"""Feed latency above this (ms) marks the feed degraded (default 2000)."""
376+
raw = get_setting(session, _LATENCY_WARN_MS)
377+
try:
378+
return max(100, int(raw)) if raw else 2000
379+
except ValueError:
380+
return 2000
381+
382+
383+
def set_latency_warn_ms(session: Session, warn_ms: int) -> None:
384+
"""Set the degraded-feed warning threshold in milliseconds."""
385+
set_setting(session, _LATENCY_WARN_MS, str(max(100, warn_ms)))

engine/marketdata/cache.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
"""
66

77
from datetime import UTC, datetime, timedelta
8+
from time import perf_counter
89

910
from sqlmodel import Session, select
1011

1112
from exchange.client import BinanceClient, Market
1213
from marketdata.freshness import interval_seconds
14+
from marketdata.latency import registry as latency_registry
1315
from marketdata.models import Candle
1416
from venues.base import Venue
1517

@@ -139,7 +141,12 @@ def refresh_venue_candles(
139141
close time, so it is derived from the open time plus the interval length.
140142
`market` selects the venue sub-market and is also the cache key.
141143
"""
144+
started = perf_counter()
142145
fetched = venue.candles(symbol, interval, limit, market=market.value)
146+
# REST round-trip latency — how long fresh data takes to ask for.
147+
latency_registry.record(
148+
market.value, symbol, "rest", (perf_counter() - started) * 1000
149+
)
143150
existing = _existing_open_times(
144151
session, market=market, symbol=symbol, interval=interval
145152
)

engine/marketdata/latency.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Price-feed latency — how delayed the data we trade on actually is.
2+
3+
Two signals feed one in-memory registry, with no DB writes on the hot path:
4+
5+
- *Feed latency*: Binance stream payloads carry the exchange event time
6+
(`E`, ms). On receipt the hub records `local_clock − event_time`.
7+
- *REST round-trip*: each candle refresh records how long the HTTP call took.
8+
9+
Per `(market, symbol, kind)` a rolling window of recent samples backs the
10+
current / p50 / p95 / max stats the API serves. A 1-minute rollup row is
11+
persisted by the engine tick so history survives restarts. The freshness
12+
module's hard freeze is unchanged — latency adds the softer "degraded"
13+
signal (default threshold 2 s) that warns before staleness trips.
14+
"""
15+
16+
import logging
17+
import threading
18+
from collections import deque
19+
from dataclasses import dataclass
20+
from datetime import UTC, datetime
21+
from decimal import Decimal
22+
23+
from sqlmodel import Field, Session, SQLModel
24+
25+
log = logging.getLogger("capital.marketdata.latency")
26+
27+
_AMT = {"max_digits": 12, "decimal_places": 2}
28+
29+
#: Feed latency above this is *degraded* — surfaced, but trading continues.
30+
DEFAULT_WARN_MS = 2000.0
31+
32+
_WINDOW = 500 # samples kept per key for the rolling stats
33+
34+
35+
class FeedLatency(SQLModel, table=True):
36+
"""One persisted minute-rollup of feed latency for one key."""
37+
38+
__tablename__ = "feed_latency"
39+
40+
id: int | None = Field(default=None, primary_key=True)
41+
market: str = Field(max_length=8, index=True)
42+
symbol: str = Field(max_length=24, index=True)
43+
kind: str = Field(max_length=8) # ws | rest
44+
samples: int = Field(default=0)
45+
avg_ms: Decimal = Field(default=Decimal(0), **_AMT)
46+
p95_ms: Decimal = Field(default=Decimal(0), **_AMT)
47+
max_ms: Decimal = Field(default=Decimal(0), **_AMT)
48+
recorded_at: datetime = Field(index=True)
49+
50+
51+
@dataclass
52+
class LatencyStats:
53+
"""The rolling-window view for one `(market, symbol, kind)`."""
54+
55+
market: str
56+
symbol: str
57+
kind: str
58+
current_ms: float
59+
p50_ms: float
60+
p95_ms: float
61+
max_ms: float
62+
samples: int
63+
degraded: bool
64+
65+
66+
def _percentile(ordered: list[float], q: float) -> float:
67+
if not ordered:
68+
return 0.0
69+
idx = min(len(ordered) - 1, max(0, round(q * (len(ordered) - 1))))
70+
return ordered[idx]
71+
72+
73+
class LatencyRegistry:
74+
"""Thread-safe in-memory latency samples (stream + engine threads write)."""
75+
76+
def __init__(self, *, window: int = _WINDOW) -> None:
77+
self._window = window
78+
self._lock = threading.Lock()
79+
self._samples: dict[tuple[str, str, str], deque[float]] = {}
80+
# Samples since the last rollup flush, per key.
81+
self._pending: dict[tuple[str, str, str], list[float]] = {}
82+
83+
def record(self, market: str, symbol: str, kind: str, latency_ms: float) -> None:
84+
"""Record one sample. Negative values (clock skew) clamp to zero."""
85+
value = max(0.0, float(latency_ms))
86+
key = (market, symbol, kind)
87+
with self._lock:
88+
window = self._samples.get(key)
89+
if window is None:
90+
window = self._samples[key] = deque(maxlen=self._window)
91+
window.append(value)
92+
self._pending.setdefault(key, []).append(value)
93+
94+
def stats(self, *, warn_ms: float = DEFAULT_WARN_MS) -> list[LatencyStats]:
95+
"""Rolling-window stats for every key seen so far."""
96+
with self._lock:
97+
items = [(key, list(window)) for key, window in self._samples.items()]
98+
out: list[LatencyStats] = []
99+
for (market, symbol, kind), values in sorted(items):
100+
ordered = sorted(values)
101+
current = values[-1] if values else 0.0
102+
p95 = _percentile(ordered, 0.95)
103+
out.append(
104+
LatencyStats(
105+
market=market,
106+
symbol=symbol,
107+
kind=kind,
108+
current_ms=round(current, 1),
109+
p50_ms=round(_percentile(ordered, 0.50), 1),
110+
p95_ms=round(p95, 1),
111+
max_ms=round(ordered[-1] if ordered else 0.0, 1),
112+
samples=len(values),
113+
degraded=current > warn_ms or p95 > warn_ms,
114+
)
115+
)
116+
return out
117+
118+
def degraded(self, *, warn_ms: float = DEFAULT_WARN_MS) -> bool:
119+
"""Whether any feed is currently above the warn threshold."""
120+
return any(s.degraded for s in self.stats(warn_ms=warn_ms))
121+
122+
def flush_rollups(self, session: Session) -> int:
123+
"""Persist (and clear) the pending samples as one rollup row per key."""
124+
with self._lock:
125+
pending, self._pending = self._pending, {}
126+
now = datetime.now(UTC).replace(tzinfo=None)
127+
written = 0
128+
for (market, symbol, kind), values in pending.items():
129+
if not values:
130+
continue
131+
ordered = sorted(values)
132+
session.add(
133+
FeedLatency(
134+
market=market,
135+
symbol=symbol,
136+
kind=kind,
137+
samples=len(values),
138+
avg_ms=Decimal(str(round(sum(values) / len(values), 2))),
139+
p95_ms=Decimal(str(round(_percentile(ordered, 0.95), 2))),
140+
max_ms=Decimal(str(round(ordered[-1], 2))),
141+
recorded_at=now,
142+
)
143+
)
144+
written += 1
145+
if written:
146+
session.commit()
147+
return written
148+
149+
150+
#: Process-wide registry — the stream hubs and candle refresh write into it.
151+
registry = LatencyRegistry()
152+
153+
154+
def record_event_time(
155+
market: str, symbol: str, event_time_ms: object, *, now_ms: float | None = None
156+
) -> None:
157+
"""Record feed latency from an exchange event timestamp (ms since epoch)."""
158+
try:
159+
event_ms = float(event_time_ms) # type: ignore[arg-type]
160+
except (TypeError, ValueError):
161+
return
162+
if event_ms <= 0:
163+
return
164+
local = now_ms if now_ms is not None else datetime.now(UTC).timestamp() * 1000
165+
registry.record(market, symbol, "ws", local - event_ms)

engine/marketdata/stream.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import websockets
1818

1919
from exchange.client import Market, Ticker
20+
from marketdata.latency import record_event_time
2021

2122
log = logging.getLogger("capital.marketdata.stream")
2223

@@ -104,6 +105,8 @@ def handle(self, raw: str | bytes) -> None:
104105
symbol = it.get("s")
105106
if symbol is None:
106107
continue
108+
# Feed latency: `E` is the exchange event time in ms.
109+
record_event_time(self.market.value, symbol, it.get("E"))
107110
# 24hrTicker compact keys: c=last, P=change%, q=quote volume.
108111
self._snapshot[symbol] = Ticker(
109112
symbol=symbol,

0 commit comments

Comments
 (0)