feat: advanced microstructure — sequencer latency, dealer GEX, tick regime, triangular arb
4 new modules with 20 tests: #22 Sequencer Latency Detector (live/monitors/sequencer_latency.py): Detects stale-state windows between WebSocket and REST API. - WebSocket vs REST timestamp delta tracking - Transport latency percentiles (p50, p99) - Liquidation-triggered stale-state detection - Stale asset identification for cross-margin arbitrage #25 Dealer GEX (microstructure/dealer_gex.py): Dealer Gamma Exposure estimation via Black-Scholes. - Per-strike gamma × OI × spot² GEX computation - Pin level detection (strikes where dealers are long gamma) - Net GEX aggregation - Signal: fade_breakout (long gamma pinning) vs ride_momentum (short gamma amplification) - nearest_pin() for distance-to-magnet calculation #30 Tick-Size Regime Exploitation (microstructure/tick_regime.py): Detects when asset price approaches tick-size boundaries. - Hyperliquid tick schedule (BTC 0.1/0.5, ETH 0.01/0.05, SOL 0.001/0.005) - Boundary approach detection with configurable threshold - Linear trend estimation for expected bars-to-cross - Signal: widen_quotes or tighten_quotes with urgency classification - Per-coin state tracking #33 Triangular Latency Arb (live/monitors/triangular_arb.py): Cross-venue A→B→C triangular arbitrage detection. - Internal triangular: BTC-USDT → ETH-BTC → ETH-USDT - Cross-venue: price discrepancy across slow/fast venues - Latency gap detection between venue pairs - Implied cross-rate computation vs direct quote - Minimum spread threshold gating
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Sequencer latency & stale-state arbitrage detector.
|
||||
|
||||
Hyperliquid uses a centralized sequencer for off-chain order matching.
|
||||
There is a microsecond-to-millisecond delta between:
|
||||
1. WebSocket trade print (fastest)
|
||||
2. REST API state (slower)
|
||||
3. Cross-margin collateral recalculation (slowest)
|
||||
|
||||
When a liquidation hits one asset, cross-margin collateral drops for ALL
|
||||
assets in that portfolio — but the sequencer may not have updated margin
|
||||
limits on OTHER order books yet. This creates a stale-state window.
|
||||
|
||||
Strategy: detect liquidations on BTC, then hit ETH book before margins update.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class SequencerLatencyDetector:
|
||||
"""Detect and exploit sequencer state-update latency.
|
||||
|
||||
Tracks WebSocket vs REST timestamps to measure the gap,
|
||||
and identifies stale-state windows after liquidation events.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
window_seconds: float = 60.0,
|
||||
stale_threshold_ms: float = 50.0, # >50ms REST lag = stale
|
||||
max_history: int = 1000,
|
||||
):
|
||||
self._window = window_seconds
|
||||
self._stale_threshold = stale_threshold_ms
|
||||
self._max_history = max_history
|
||||
|
||||
# Latency tracking per channel per coin
|
||||
self._ws_timestamps: dict[str, dict[str, deque[float]]] = {}
|
||||
self._rest_timestamps: dict[str, dict[str, deque[float]]] = {}
|
||||
self._latency_measurements: deque = deque(maxlen=max_history)
|
||||
|
||||
# Liquidation event log
|
||||
self._liquidations: deque = deque(maxlen=500)
|
||||
|
||||
# Sequencer state
|
||||
self._stale_window_active: bool = False
|
||||
self._stale_assets: list[str] = []
|
||||
self._stale_start: float = 0.0
|
||||
|
||||
# ── Data feed ────────────────────────────────────────────
|
||||
|
||||
def record_ws_event(self, coin: str, channel: str, exchange_ts_ms: int):
|
||||
"""Record WebSocket event timestamp (fastest)."""
|
||||
local_ts = time.time() * 1000
|
||||
c = coin.upper()
|
||||
ch = channel
|
||||
|
||||
if c not in self._ws_timestamps:
|
||||
self._ws_timestamps[c] = {}
|
||||
self._ws_timestamps[c].setdefault(ch, deque(maxlen=self._max_history)).append(exchange_ts_ms)
|
||||
|
||||
# Measure latency: how much time between exchange and local receipt
|
||||
lat = local_ts - exchange_ts_ms
|
||||
self._latency_measurements.append({
|
||||
"time": time.time(),
|
||||
"coin": c,
|
||||
"channel": ch,
|
||||
"latency_ms": round(lat, 2),
|
||||
"direction": "ws",
|
||||
})
|
||||
|
||||
def record_rest_event(self, coin: str, endpoint: str, exchange_ts_ms: int):
|
||||
"""Record REST API timestamp (slower)."""
|
||||
c = coin.upper()
|
||||
if c not in self._rest_timestamps:
|
||||
self._rest_timestamps[c] = {}
|
||||
self._rest_timestamps[c].setdefault(endpoint, deque(maxlen=self._max_history)).append(exchange_ts_ms)
|
||||
|
||||
def record_liquidation(self, coin: str, size: float, price: float, timestamp_ms: int):
|
||||
"""Record a liquidation event — triggers stale-state analysis."""
|
||||
self._liquidations.append({
|
||||
"time": time.time(),
|
||||
"coin": coin.upper(),
|
||||
"size": size,
|
||||
"price": price,
|
||||
"exchange_ts": timestamp_ms,
|
||||
})
|
||||
|
||||
# ── Analysis ─────────────────────────────────────────────
|
||||
|
||||
def ws_rest_latency(self, coin: str, channel: str = "l2book") -> dict | None:
|
||||
"""Measure latency between WebSocket and REST for a coin/channel.
|
||||
|
||||
Returns median latency (ms), count of measurements, and stale flag.
|
||||
"""
|
||||
ws = list(self._ws_timestamps.get(coin.upper(), {}).get(channel, []))
|
||||
rest = list(self._rest_timestamps.get(coin.upper(), {}).get(channel, []))
|
||||
if not ws or not rest:
|
||||
return None
|
||||
|
||||
# Compare latest timestamps — if WS is newer than REST by > threshold
|
||||
ws_latest = ws[-1]
|
||||
rest_latest = rest[-1]
|
||||
delta = rest_latest - ws_latest
|
||||
|
||||
return {
|
||||
"coin": coin.upper(),
|
||||
"channel": channel,
|
||||
"ws_latest_ms": ws_latest,
|
||||
"rest_latest_ms": rest_latest,
|
||||
"delta_ms": round(delta, 2),
|
||||
"is_stale": delta > self._stale_threshold,
|
||||
"measurements": min(len(ws), len(rest)),
|
||||
}
|
||||
|
||||
def avg_transport_latency(self) -> dict:
|
||||
"""Average WebSocket transport latency (exchange → local).
|
||||
|
||||
Returns p50, p99, max, and sample count.
|
||||
"""
|
||||
lats = [m["latency_ms"] for m in self._latency_measurements]
|
||||
if not lats:
|
||||
return {"p50_ms": 0, "p99_ms": 0, "max_ms": 0, "count": 0}
|
||||
|
||||
sorted_lats = sorted(lats)
|
||||
n = len(sorted_lats)
|
||||
return {
|
||||
"p50_ms": round(sorted_lats[int(n * 0.50)], 2),
|
||||
"p99_ms": round(sorted_lats[int(n * 0.99)], 2),
|
||||
"max_ms": round(max(lats), 2),
|
||||
"count": n,
|
||||
}
|
||||
|
||||
def recent_liquidations(self) -> list[dict]:
|
||||
"""Liquidation events in the last window_seconds."""
|
||||
cutoff = time.time() - self._window
|
||||
return [l for l in self._liquidations if l["time"] >= cutoff]
|
||||
|
||||
def stale_state_signal(self) -> dict:
|
||||
"""Detect if a stale-state window is active after liquidation.
|
||||
|
||||
If a liquidation just occurred and REST lag is > threshold:
|
||||
- Mark affected assets as stale
|
||||
- Signal which other assets may have outdated margin limits
|
||||
"""
|
||||
recent_liqs = self.recent_liquidations()
|
||||
if not recent_liqs:
|
||||
return {"stale_window_active": False, "signal": "none"}
|
||||
|
||||
latest_liq = recent_liqs[-1]
|
||||
age_ms = (time.time() - latest_liq["time"]) * 1000
|
||||
|
||||
# Check if any asset has REST lag exceeding threshold
|
||||
stale_assets = []
|
||||
for coin, channels in self._ws_timestamps.items():
|
||||
for channel in channels:
|
||||
lat = self.ws_rest_latency(coin, channel)
|
||||
if lat and lat["is_stale"]:
|
||||
stale_assets.append(lat)
|
||||
|
||||
is_stale = len(stale_assets) > 0 and age_ms < 1000 # within 1s of liquidation
|
||||
|
||||
return {
|
||||
"stale_window_active": is_stale,
|
||||
"signal": "stale_state_detected" if is_stale else "none",
|
||||
"liquidation_coin": latest_liq["coin"],
|
||||
"liquidation_age_ms": round(age_ms, 2),
|
||||
"stale_assets": [s["coin"] for s in stale_assets],
|
||||
"recommended_action": "check_margin_limits_on_STALE_ASSETS" if is_stale else "none",
|
||||
}
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"avg_latency": self.avg_transport_latency(),
|
||||
"recent_liquidations": len(self.recent_liquidations()),
|
||||
"stale_state": self.stale_state_signal(),
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Cross-exchange triangular latency arbitrage detector.
|
||||
|
||||
Latency arb isn't just A vs B. It's A → B → C across three venues.
|
||||
If BTC/USDT is slow to update on Venue 1, but Venue 2 is fast on
|
||||
ETH/BTC and Venue 3 is fast on ETH/USDT, the slow leg creates a
|
||||
triangular arbitrage opportunity.
|
||||
|
||||
Path: Buy BTC/USDT on slow venue → Sell ETH/BTC on fast venue → Sell ETH/USDT on fast venue
|
||||
|
||||
Strategy: monitor 3-venue latency simultaneously, detect when one leg
|
||||
lags, compute implied arbitrage spread, and signal execution-ready
|
||||
opportunities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class TriangularLatencyArb:
|
||||
"""Detect triangular arbitrage from cross-venue latency discrepancies.
|
||||
|
||||
Monitors up to 3 venues with configurable latency estimates.
|
||||
When one venue lags on a specific pair, the triangle becomes profitable.
|
||||
|
||||
Usage:
|
||||
arb = TriangularLatencyArb()
|
||||
arb.update_price("hl", "BTC-USDT", 64500, latency_ms=5)
|
||||
arb.update_price("hl", "ETH-BTC", 0.049, latency_ms=8)
|
||||
arb.update_price("hl", "ETH-USDT", 3160, latency_ms=6)
|
||||
opportunities = arb.detect()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_spread_bps: float = 0.5, # minimum bps profit to signal
|
||||
max_latency_diff_ms: float = 200.0, # max venue latency gap to consider
|
||||
window: int = 50,
|
||||
):
|
||||
self._min_spread = min_spread_bps
|
||||
self._max_latency = max_latency_diff_ms
|
||||
self._window = window
|
||||
|
||||
# venue → pair → (price, latency_ms, timestamp)
|
||||
self._prices: dict[str, dict[str, deque[tuple[float, float, float]]]] = {}
|
||||
self._latencies: dict[str, float] = {} # venue → avg latency
|
||||
|
||||
# ── Data feed ────────────────────────────────────────────
|
||||
|
||||
def update_price(self, venue: str, pair: str, price: float, latency_ms: float = 0):
|
||||
"""Record a price snapshot from a venue with its latency."""
|
||||
v = venue.lower()
|
||||
p = pair.upper().replace("/", "-")
|
||||
|
||||
self._prices.setdefault(v, {}).setdefault(
|
||||
p, deque(maxlen=self._window)
|
||||
).append((price, latency_ms, latency_ms)) # (price, latency, timestamp_simple)
|
||||
|
||||
# Update venue latency estimate (EMA)
|
||||
old_lat = self._latencies.get(v, latency_ms)
|
||||
self._latencies[v] = old_lat * 0.9 + latency_ms * 0.1
|
||||
|
||||
def get_price(self, venue: str, pair: str) -> float | None:
|
||||
"""Get latest price from a venue."""
|
||||
dq = self._prices.get(venue.lower(), {}).get(pair.upper().replace("/", "-"))
|
||||
return dq[-1][0] if dq else None
|
||||
|
||||
def get_latency(self, venue: str) -> float:
|
||||
return self._latencies.get(venue.lower(), 0)
|
||||
|
||||
# ── Arbitrage detection ──────────────────────────────────
|
||||
|
||||
def detect(self) -> dict:
|
||||
"""Detect triangular arbitrage opportunities across venues.
|
||||
|
||||
Triangle: BTC-USDT → ETH-BTC → ETH-USDT
|
||||
If any leg is on a slower venue, implied profit exists.
|
||||
|
||||
Returns list of opportunities with profit, confidence, and execution plan.
|
||||
"""
|
||||
opportunities = []
|
||||
pairs = ["BTC-USDT", "ETH-BTC", "ETH-USDT"]
|
||||
venues = list(self._prices.keys())
|
||||
|
||||
if len(venues) < 1:
|
||||
return {"opportunities": [], "venue_count": len(venues)}
|
||||
|
||||
# Find latency gaps between venues
|
||||
if len(venues) >= 2:
|
||||
lat_gaps = self._latency_gaps()
|
||||
else:
|
||||
lat_gaps = {}
|
||||
|
||||
# For each venue, compute implied cross-rate vs direct
|
||||
for v in venues:
|
||||
btc_usdt = self.get_price(v, "BTC-USDT")
|
||||
eth_btc = self.get_price(v, "ETH-BTC")
|
||||
eth_usdt = self.get_price(v, "ETH-USDT")
|
||||
|
||||
if not (btc_usdt and eth_btc and eth_usdt):
|
||||
continue
|
||||
|
||||
# Implied ETH-USDT from triangle: BTC-USDT × ETH-BTC
|
||||
implied_eth = btc_usdt * eth_btc
|
||||
implied_bps = (eth_usdt - implied_eth) / implied_eth * 10000
|
||||
|
||||
if abs(implied_bps) > self._min_spread:
|
||||
opportunities.append({
|
||||
"venue": v,
|
||||
"type": "internal_triangular",
|
||||
"btc_usdt": btc_usdt,
|
||||
"eth_btc": eth_btc,
|
||||
"eth_usdt": eth_usdt,
|
||||
"implied_eth_usdt": round(implied_eth, 2),
|
||||
"spread_bps": round(implied_bps, 2),
|
||||
"direction": "sell_eth" if implied_bps > 0 else "buy_eth",
|
||||
"latency_ms": round(self._latencies.get(v, 0), 2),
|
||||
})
|
||||
|
||||
# Cross-venue: check if one venue's slow leg creates arb
|
||||
if len(venues) >= 2:
|
||||
for i, v1 in enumerate(venues):
|
||||
for v2 in venues[i + 1:]:
|
||||
opp = self._cross_venue_arb(v1, v2, pairs)
|
||||
if opp:
|
||||
opportunities.append(opp)
|
||||
|
||||
return {
|
||||
"opportunities": opportunities[:10],
|
||||
"venue_count": len(venues),
|
||||
"latency_gaps": lat_gaps,
|
||||
"best_opportunity": max(opportunities, key=lambda o: abs(o["spread_bps"])) if opportunities else None,
|
||||
}
|
||||
|
||||
def _cross_venue_arb(self, v1: str, v2: str, pairs: list[str]) -> dict | None:
|
||||
"""Check if buying on slow venue, selling on fast venue is profitable."""
|
||||
best_opp = None
|
||||
best_bps = 0
|
||||
|
||||
for pair in pairs:
|
||||
p1 = self.get_price(v1, pair)
|
||||
p2 = self.get_price(v2, pair)
|
||||
if not p1 or not p2:
|
||||
continue
|
||||
|
||||
spread_bps = abs(p2 - p1) / p1 * 10000
|
||||
lat_diff = abs(self.get_latency(v1) - self.get_latency(v2))
|
||||
|
||||
if spread_bps > self._min_spread and lat_diff > 10:
|
||||
opp = {
|
||||
"type": "cross_venue",
|
||||
"pair": pair,
|
||||
"slow_venue": v1 if self.get_latency(v1) > self.get_latency(v2) else v2,
|
||||
"fast_venue": v1 if self.get_latency(v1) < self.get_latency(v2) else v2,
|
||||
"buy_at": round(min(p1, p2), 2),
|
||||
"sell_at": round(max(p1, p2), 2),
|
||||
"spread_bps": round(spread_bps, 2),
|
||||
"latency_diff_ms": round(lat_diff, 2),
|
||||
}
|
||||
if spread_bps > best_bps:
|
||||
best_bps = spread_bps
|
||||
best_opp = opp
|
||||
|
||||
return best_opp
|
||||
|
||||
def _latency_gaps(self) -> dict:
|
||||
"""Compute latency gaps between all venue pairs."""
|
||||
venues = list(self._latencies.keys())
|
||||
gaps = {}
|
||||
for i, v1 in enumerate(venues):
|
||||
for v2 in venues[i + 1:]:
|
||||
diff = abs(self._latencies.get(v1, 0) - self._latencies.get(v2, 0))
|
||||
key = f"{v1}_{v2}"
|
||||
gaps[key] = round(diff, 2)
|
||||
return gaps
|
||||
|
||||
def signal(self) -> dict:
|
||||
"""Generate trading signal for cross-venue triangular arb."""
|
||||
result = self.detect()
|
||||
opps = result.get("opportunities", [])
|
||||
|
||||
if not opps:
|
||||
return {"action": "none", "reason": "no_opportunity"}
|
||||
|
||||
best = opps[0]
|
||||
if best["type"] == "cross_venue" and abs(best["spread_bps"]) > self._min_spread * 2:
|
||||
return {
|
||||
"action": "arbitrage",
|
||||
"type": "cross_venue",
|
||||
"details": best,
|
||||
"confidence": min(1.0, abs(best["spread_bps"]) / (self._min_spread * 5)),
|
||||
}
|
||||
elif best["type"] == "internal_triangular" and abs(best["spread_bps"]) > self._min_spread * 3:
|
||||
return {
|
||||
"action": "arbitrage",
|
||||
"type": "triangular",
|
||||
"details": best,
|
||||
"confidence": min(1.0, abs(best["spread_bps"]) / (self._min_spread * 5)),
|
||||
}
|
||||
|
||||
return {"action": "monitor", "reason": "spread_too_small", "best_bps": round(best["spread_bps"], 2)}
|
||||
|
||||
def summary(self) -> dict:
|
||||
opps = self.detect()
|
||||
return {
|
||||
"venues": list(self._prices.keys()),
|
||||
"latencies": self._latencies,
|
||||
"opportunities_count": len(opps.get("opportunities", [])),
|
||||
"best_opportunity": opps.get("best_opportunity"),
|
||||
"signal": self.signal(),
|
||||
}
|
||||
Reference in New Issue
Block a user