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>
210 lines
7.3 KiB
Python
210 lines
7.3 KiB
Python
"""
|
|
Bybit WebSocket data feed — connects to public aggTrade + orderbook depth streams.
|
|
Free, no API key needed for public data.
|
|
Provides tick-by-tick trades with aggressor side and L2 orderbook updates.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from typing import Callable, Optional
|
|
|
|
import websockets
|
|
|
|
from orderflow_system.data.models import Tick, Side, OrderbookSnapshot, OrderbookLevel
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BYBIT_WS_URL = "wss://stream.bybit.com/v5/public/linear"
|
|
|
|
|
|
class BybitFeed:
|
|
"""
|
|
Real-time data feed from Bybit perpetual futures.
|
|
Subscribes to:
|
|
- publicTrade.<symbol> → Tick data with aggressor side
|
|
- orderbook.50.<symbol> → 50-level L2 orderbook snapshots + deltas
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
symbols: list[str],
|
|
on_tick: Optional[Callable] = None,
|
|
on_orderbook: Optional[Callable] = None,
|
|
):
|
|
self.symbols = symbols
|
|
self.on_tick = on_tick
|
|
self.on_orderbook = on_orderbook
|
|
self._ws = None
|
|
self._running = False
|
|
self._orderbooks: dict[str, OrderbookSnapshot] = {}
|
|
self._tick_buffer: dict[str, list[Tick]] = {s: [] for s in symbols}
|
|
self._reconnect_delay = 1.0
|
|
|
|
async def start(self):
|
|
"""Connect and begin receiving data."""
|
|
self._running = True
|
|
while self._running:
|
|
try:
|
|
await self._connect_and_listen()
|
|
except (
|
|
websockets.ConnectionClosed,
|
|
ConnectionRefusedError,
|
|
OSError,
|
|
) as e:
|
|
logger.warning(f"WebSocket disconnected: {e}. Reconnecting in {self._reconnect_delay}s...")
|
|
await asyncio.sleep(self._reconnect_delay)
|
|
self._reconnect_delay = min(self._reconnect_delay * 2, 30.0)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in feed: {e}", exc_info=True)
|
|
await asyncio.sleep(5.0)
|
|
|
|
async def stop(self):
|
|
self._running = False
|
|
if self._ws:
|
|
await self._ws.close()
|
|
|
|
async def _connect_and_listen(self):
|
|
async with websockets.connect(BYBIT_WS_URL, ping_interval=20) as ws:
|
|
self._ws = ws
|
|
self._reconnect_delay = 1.0
|
|
logger.info(f"Connected to Bybit WebSocket")
|
|
|
|
# Subscribe to trades + orderbook for each symbol
|
|
subscribe_args = []
|
|
for sym in self.symbols:
|
|
subscribe_args.append(f"publicTrade.{sym}")
|
|
subscribe_args.append(f"orderbook.50.{sym}")
|
|
|
|
subscribe_msg = {
|
|
"op": "subscribe",
|
|
"args": subscribe_args,
|
|
}
|
|
await ws.send(json.dumps(subscribe_msg))
|
|
logger.info(f"Subscribed to: {subscribe_args}")
|
|
|
|
async for raw_msg in ws:
|
|
if not self._running:
|
|
break
|
|
try:
|
|
msg = json.loads(raw_msg)
|
|
await self._handle_message(msg)
|
|
except json.JSONDecodeError:
|
|
logger.warning(f"Invalid JSON: {raw_msg[:100]}")
|
|
except Exception as e:
|
|
logger.error(f"Error handling message: {e}", exc_info=True)
|
|
|
|
async def _handle_message(self, msg: dict):
|
|
topic = msg.get("topic", "")
|
|
|
|
if topic.startswith("publicTrade."):
|
|
await self._handle_trades(msg)
|
|
elif topic.startswith("orderbook."):
|
|
await self._handle_orderbook(msg)
|
|
|
|
async def _handle_trades(self, msg: dict):
|
|
"""
|
|
Parse Bybit public trade messages.
|
|
Each trade has: price, size, side (Buy/Sell), timestamp.
|
|
The 'side' from Bybit = the TAKER side = the aggressor.
|
|
"""
|
|
data_list = msg.get("data", [])
|
|
symbol = msg.get("topic", "").replace("publicTrade.", "")
|
|
|
|
for trade in data_list:
|
|
side_str = trade.get("S", "")
|
|
tick = Tick(
|
|
timestamp_ms=trade.get("T", int(time.time() * 1000)),
|
|
price=float(trade.get("p", 0)),
|
|
size=float(trade.get("v", 0)),
|
|
side=Side.BUY if side_str == "Buy" else Side.SELL,
|
|
trade_id=trade.get("i", ""),
|
|
)
|
|
|
|
self._tick_buffer[symbol].append(tick)
|
|
|
|
if self.on_tick:
|
|
await self.on_tick(symbol, tick)
|
|
|
|
async def _handle_orderbook(self, msg: dict):
|
|
"""
|
|
Parse Bybit orderbook messages.
|
|
Type 'snapshot' = full book replacement.
|
|
Type 'delta' = incremental update.
|
|
"""
|
|
data = msg.get("data", {})
|
|
msg_type = msg.get("type", "")
|
|
topic = msg.get("topic", "")
|
|
symbol = topic.split(".")[-1] if "." in topic else ""
|
|
ts = data.get("u", int(time.time() * 1000))
|
|
|
|
if msg_type == "snapshot":
|
|
bids = [
|
|
OrderbookLevel(price=float(b[0]), quantity=float(b[1]))
|
|
for b in data.get("b", [])
|
|
]
|
|
asks = [
|
|
OrderbookLevel(price=float(a[0]), quantity=float(a[1]))
|
|
for a in data.get("a", [])
|
|
]
|
|
self._orderbooks[symbol] = OrderbookSnapshot(
|
|
timestamp_ms=ts,
|
|
bids=sorted(bids, key=lambda x: -x.price),
|
|
asks=sorted(asks, key=lambda x: x.price),
|
|
)
|
|
elif msg_type == "delta":
|
|
book = self._orderbooks.get(symbol)
|
|
if book is None:
|
|
return
|
|
self._apply_delta(book, data)
|
|
book.timestamp_ms = ts
|
|
|
|
if symbol in self._orderbooks and self.on_orderbook:
|
|
await self.on_orderbook(symbol, self._orderbooks[symbol])
|
|
|
|
def _apply_delta(self, book: OrderbookSnapshot, data: dict):
|
|
"""Apply incremental orderbook updates."""
|
|
# Update bids
|
|
for b in data.get("b", []):
|
|
price, qty = float(b[0]), float(b[1])
|
|
if qty == 0:
|
|
book.bids = [lv for lv in book.bids if lv.price != price]
|
|
else:
|
|
found = False
|
|
for lv in book.bids:
|
|
if lv.price == price:
|
|
lv.quantity = qty
|
|
found = True
|
|
break
|
|
if not found:
|
|
book.bids.append(OrderbookLevel(price=price, quantity=qty))
|
|
book.bids.sort(key=lambda x: -x.price)
|
|
|
|
# Update asks
|
|
for a in data.get("a", []):
|
|
price, qty = float(a[0]), float(a[1])
|
|
if qty == 0:
|
|
book.asks = [lv for lv in book.asks if lv.price != price]
|
|
else:
|
|
found = False
|
|
for lv in book.asks:
|
|
if lv.price == price:
|
|
lv.quantity = qty
|
|
found = True
|
|
break
|
|
if not found:
|
|
book.asks.append(OrderbookLevel(price=price, quantity=qty))
|
|
book.asks.sort(key=lambda x: x.price)
|
|
|
|
def get_orderbook(self, symbol: str) -> Optional[OrderbookSnapshot]:
|
|
return self._orderbooks.get(symbol)
|
|
|
|
def flush_tick_buffer(self, symbol: str) -> list[Tick]:
|
|
"""Return and clear buffered ticks for batch DB insert."""
|
|
ticks = self._tick_buffer.get(symbol, [])
|
|
self._tick_buffer[symbol] = []
|
|
return ticks
|