0206ef7cbb
Real-time orderflow trading system with absorption, initiative, sweep, exhaustion, and divergence detection. Features volume profile framing, state machine trade lifecycle, MT5 + Bybit feeds, FastAPI dashboard, and Telegram alerts for 30+ instruments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
209 lines
7.4 KiB
Python
209 lines
7.4 KiB
Python
"""
|
|
Orderbook Tracker
|
|
Maintains real-time L2 orderbook state and detects structural features:
|
|
- Thin levels (low liquidity → sweep risk)
|
|
- Book imbalance (bid vs ask depth)
|
|
- Path of least resistance
|
|
- Levels being consumed (for sweep detection)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from collections import deque
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
from orderflow_system.data.models import OrderbookSnapshot, OrderbookLevel
|
|
|
|
|
|
@dataclass
|
|
class LevelConsumption:
|
|
"""Tracks consumption of orderbook levels for sweep detection."""
|
|
timestamp_ms: int
|
|
price: float
|
|
side: str # 'bid' or 'ask'
|
|
prev_quantity: float
|
|
consumed_quantity: float
|
|
|
|
|
|
@dataclass
|
|
class BookState:
|
|
"""Analyzed state of the orderbook at a point in time."""
|
|
timestamp_ms: int = 0
|
|
imbalance_ratio: float = 0.0 # +1 = all bids, -1 = all asks
|
|
bid_depth_5: float = 0.0 # Total qty in top 5 bid levels
|
|
ask_depth_5: float = 0.0 # Total qty in top 5 ask levels
|
|
bid_depth_10: float = 0.0
|
|
ask_depth_10: float = 0.0
|
|
thin_bids: list[float] = field(default_factory=list) # Thin bid prices
|
|
thin_asks: list[float] = field(default_factory=list) # Thin ask prices
|
|
path_of_least_resistance: str = "neutral" # 'up', 'down', 'neutral'
|
|
best_bid: float = 0.0
|
|
best_ask: float = 0.0
|
|
spread: float = 0.0
|
|
|
|
|
|
class OrderbookTracker:
|
|
"""
|
|
Tracks orderbook state changes for sweep and liquidity analysis.
|
|
|
|
From Fabio's teaching:
|
|
- "Path of least resistance": the side with less passive liquidity is
|
|
easier for aggressive orders to pierce through
|
|
- Thin levels → potential for book sweeps
|
|
- Tracking level consumption reveals how aggressive orders eat the book
|
|
"""
|
|
|
|
def __init__(self, thin_threshold: float = 10.0, max_consumption_history: int = 200):
|
|
self.thin_threshold = thin_threshold
|
|
self._prev_snapshot: Optional[OrderbookSnapshot] = None
|
|
self._consumption_history: deque[LevelConsumption] = deque(
|
|
maxlen=max_consumption_history
|
|
)
|
|
self._book_state_history: list[BookState] = []
|
|
self._max_history = 200
|
|
|
|
@property
|
|
def latest_snapshot(self) -> Optional[OrderbookSnapshot]:
|
|
"""Return the most recent orderbook snapshot."""
|
|
return self._prev_snapshot
|
|
|
|
def update(self, snapshot: OrderbookSnapshot) -> BookState:
|
|
"""Process a new orderbook snapshot and return analyzed state."""
|
|
# Detect consumed levels if we have a previous snapshot
|
|
if self._prev_snapshot is not None:
|
|
self._detect_consumptions(self._prev_snapshot, snapshot)
|
|
|
|
state = self._analyze(snapshot)
|
|
|
|
self._prev_snapshot = snapshot
|
|
self._book_state_history.append(state)
|
|
if len(self._book_state_history) > self._max_history:
|
|
self._book_state_history = self._book_state_history[-self._max_history:]
|
|
|
|
return state
|
|
|
|
def _analyze(self, snap: OrderbookSnapshot) -> BookState:
|
|
"""Analyze current orderbook structure."""
|
|
bid_5 = snap.bid_depth(5)
|
|
ask_5 = snap.ask_depth(5)
|
|
bid_10 = snap.bid_depth(10)
|
|
ask_10 = snap.ask_depth(10)
|
|
|
|
# Thin level detection
|
|
thin_bids = [
|
|
b.price for b in snap.bids[:20]
|
|
if b.quantity < self.thin_threshold
|
|
]
|
|
thin_asks = [
|
|
a.price for a in snap.asks[:20]
|
|
if a.quantity < self.thin_threshold
|
|
]
|
|
|
|
# Path of least resistance
|
|
total = bid_10 + ask_10
|
|
if total > 0:
|
|
ratio = (bid_10 - ask_10) / total
|
|
else:
|
|
ratio = 0.0
|
|
|
|
if ratio > 0.15:
|
|
polr = "up" # More bids than asks → harder to go down → easier up
|
|
elif ratio < -0.15:
|
|
polr = "down" # More asks → easier down
|
|
else:
|
|
polr = "neutral"
|
|
|
|
return BookState(
|
|
timestamp_ms=snap.timestamp_ms,
|
|
imbalance_ratio=snap.imbalance_ratio(5),
|
|
bid_depth_5=bid_5,
|
|
ask_depth_5=ask_5,
|
|
bid_depth_10=bid_10,
|
|
ask_depth_10=ask_10,
|
|
thin_bids=thin_bids,
|
|
thin_asks=thin_asks,
|
|
path_of_least_resistance=polr,
|
|
best_bid=snap.best_bid or 0.0,
|
|
best_ask=snap.best_ask or 0.0,
|
|
spread=snap.spread or 0.0,
|
|
)
|
|
|
|
def _detect_consumptions(
|
|
self, prev: OrderbookSnapshot, curr: OrderbookSnapshot
|
|
):
|
|
"""
|
|
Detect which book levels were consumed between snapshots.
|
|
If a bid/ask level existed before and now has less or zero quantity,
|
|
it was consumed by aggressive orders.
|
|
"""
|
|
ts = curr.timestamp_ms
|
|
|
|
# Check consumed asks (eaten by aggressive buyers going UP)
|
|
prev_asks = {a.price: a.quantity for a in prev.asks[:30]}
|
|
curr_asks = {a.price: a.quantity for a in curr.asks[:30]}
|
|
|
|
for price, prev_qty in prev_asks.items():
|
|
curr_qty = curr_asks.get(price, 0.0)
|
|
consumed = prev_qty - curr_qty
|
|
if consumed > prev_qty * 0.5 and consumed > 1.0:
|
|
self._consumption_history.append(LevelConsumption(
|
|
timestamp_ms=ts,
|
|
price=price,
|
|
side="ask",
|
|
prev_quantity=prev_qty,
|
|
consumed_quantity=consumed,
|
|
))
|
|
|
|
# Check consumed bids (eaten by aggressive sellers going DOWN)
|
|
prev_bids = {b.price: b.quantity for b in prev.bids[:30]}
|
|
curr_bids = {b.price: b.quantity for b in curr.bids[:30]}
|
|
|
|
for price, prev_qty in prev_bids.items():
|
|
curr_qty = curr_bids.get(price, 0.0)
|
|
consumed = prev_qty - curr_qty
|
|
if consumed > prev_qty * 0.5 and consumed > 1.0:
|
|
self._consumption_history.append(LevelConsumption(
|
|
timestamp_ms=ts,
|
|
price=price,
|
|
side="bid",
|
|
prev_quantity=prev_qty,
|
|
consumed_quantity=consumed,
|
|
))
|
|
|
|
def get_recent_consumptions(
|
|
self, time_window_ms: int = 5000, side: Optional[str] = None
|
|
) -> list[LevelConsumption]:
|
|
"""
|
|
Get recent level consumptions within a time window.
|
|
Used by sweep detector to count how many levels were eaten recently.
|
|
"""
|
|
now = int(time.time() * 1000)
|
|
cutoff = now - time_window_ms
|
|
result = [
|
|
c for c in self._consumption_history
|
|
if c.timestamp_ms >= cutoff
|
|
]
|
|
if side:
|
|
result = [c for c in result if c.side == side]
|
|
return result
|
|
|
|
def count_swept_levels(
|
|
self, time_window_ms: int = 3000, side: Optional[str] = None
|
|
) -> int:
|
|
"""Count distinct price levels consumed within time window."""
|
|
consumptions = self.get_recent_consumptions(time_window_ms, side)
|
|
return len(set(c.price for c in consumptions))
|
|
|
|
def total_consumed_volume(
|
|
self, time_window_ms: int = 3000, side: Optional[str] = None
|
|
) -> float:
|
|
"""Total volume consumed from the book within time window."""
|
|
consumptions = self.get_recent_consumptions(time_window_ms, side)
|
|
return sum(c.consumed_quantity for c in consumptions)
|
|
|
|
@property
|
|
def latest_state(self) -> Optional[BookState]:
|
|
return self._book_state_history[-1] if self._book_state_history else None
|