Initial TradeBot implementation
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import websockets
|
||||
|
||||
from crypto_spot_bot.bybit import BybitClient, Instrument, websocket_subscribe_message
|
||||
from crypto_spot_bot.config import Settings
|
||||
from crypto_spot_bot.indicators import add_indicators
|
||||
from crypto_spot_bot.models import Candle, Ticker, utc_now
|
||||
from crypto_spot_bot.storage import Storage
|
||||
|
||||
|
||||
POPULAR_FALLBACK = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "DOGEUSDT", "LTCUSDT"]
|
||||
|
||||
|
||||
def _float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
class MarketData:
|
||||
def __init__(self, settings: Settings, client: BybitClient, storage: Storage):
|
||||
self.settings = settings
|
||||
self.client = client
|
||||
self.storage = storage
|
||||
self.symbols: list[str] = []
|
||||
self.instruments: dict[str, Instrument] = {}
|
||||
self.tickers: dict[str, Ticker] = {}
|
||||
self.candles: dict[str, list[Candle]] = {}
|
||||
self.orderbook_top: dict[str, tuple[float, float]] = {}
|
||||
self.patterns: dict[str, dict[str, Any]] = {}
|
||||
self.forecasts: dict[str, dict[str, Any]] = {}
|
||||
self.last_rest_refresh_at: datetime | None = None
|
||||
self.last_ws_message_at: datetime | None = None
|
||||
self.ws_connected = False
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
async def bootstrap(self) -> None:
|
||||
self.instruments = await asyncio.to_thread(self.client.instruments)
|
||||
if self.settings.symbols:
|
||||
self.symbols = [
|
||||
symbol
|
||||
for symbol in self.settings.symbols
|
||||
if symbol in self.instruments and self.instruments[symbol].quote_coin == "USDT"
|
||||
]
|
||||
elif self.settings.auto_select_symbols:
|
||||
self.symbols = await asyncio.to_thread(
|
||||
self.client.popular_spot_symbols, self.settings.top_symbols_count
|
||||
)
|
||||
if not self.symbols:
|
||||
self.symbols = [
|
||||
symbol
|
||||
for symbol in POPULAR_FALLBACK[: self.settings.top_symbols_count]
|
||||
if symbol in self.instruments
|
||||
]
|
||||
self.storage.event("Торговые пары: " + ", ".join(self.symbols))
|
||||
await asyncio.to_thread(self.refresh_rest)
|
||||
|
||||
def refresh_rest(self) -> None:
|
||||
ticker_map = {ticker.symbol: ticker for ticker in self.client.spot_tickers()}
|
||||
for symbol in self.symbols:
|
||||
ticker = ticker_map.get(symbol)
|
||||
if ticker:
|
||||
self.tickers[symbol] = ticker
|
||||
try:
|
||||
candles = self.client.klines(
|
||||
symbol=symbol,
|
||||
interval=self.settings.base_interval,
|
||||
limit=self.settings.kline_limit,
|
||||
)
|
||||
add_indicators(candles)
|
||||
self.candles[symbol] = candles
|
||||
bid, ask = self.client.orderbook_top(symbol)
|
||||
self.orderbook_top[symbol] = (bid, ask)
|
||||
if symbol in self.tickers:
|
||||
current = self.tickers[symbol]
|
||||
self.tickers[symbol] = Ticker(
|
||||
symbol=current.symbol,
|
||||
last_price=current.last_price,
|
||||
bid=bid or current.bid,
|
||||
ask=ask or current.ask,
|
||||
turnover_24h=current.turnover_24h,
|
||||
volume_24h=current.volume_24h,
|
||||
change_24h=current.change_24h,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.storage.event(f"{symbol}: ошибка обновления REST данных: {exc}", "ERROR")
|
||||
self.last_rest_refresh_at = utc_now()
|
||||
|
||||
async def websocket_loop(self) -> None:
|
||||
if not self.settings.websocket_enabled:
|
||||
return
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
async with websockets.connect(self.settings.websocket_url, ping_interval=20) as ws:
|
||||
self.ws_connected = True
|
||||
await ws.send(websocket_subscribe_message(self.symbols))
|
||||
self.storage.event("Поток данных Bybit подключен")
|
||||
async for raw in ws:
|
||||
self.last_ws_message_at = utc_now()
|
||||
self._handle_ws_message(raw)
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.ws_connected = False
|
||||
self.storage.event(f"Поток данных Bybit отключен: {exc}", "WARN")
|
||||
await asyncio.sleep(5)
|
||||
self.ws_connected = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
def reset_stop(self) -> None:
|
||||
if self._stop_event.is_set():
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
def _handle_ws_message(self, raw: str) -> None:
|
||||
try:
|
||||
message = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
topic = str(message.get("topic", ""))
|
||||
data = message.get("data")
|
||||
if topic.startswith("tickers.") and isinstance(data, dict):
|
||||
self._handle_ticker(topic.split(".", 1)[1], data)
|
||||
elif topic.startswith("kline.") and isinstance(data, list):
|
||||
parts = topic.split(".")
|
||||
if len(parts) >= 3:
|
||||
self._handle_kline(parts[2], data)
|
||||
elif topic.startswith("orderbook.") and isinstance(data, dict):
|
||||
parts = topic.split(".")
|
||||
if len(parts) >= 3:
|
||||
self._handle_orderbook(parts[2], data)
|
||||
|
||||
def _handle_ticker(self, symbol: str, data: dict[str, Any]) -> None:
|
||||
current = self.tickers.get(symbol)
|
||||
last_price = _float(data.get("lastPrice"), current.last_price if current else 0.0)
|
||||
if last_price <= 0:
|
||||
return
|
||||
self.tickers[symbol] = Ticker(
|
||||
symbol=symbol,
|
||||
last_price=last_price,
|
||||
bid=_float(data.get("bid1Price"), current.bid if current else 0.0),
|
||||
ask=_float(data.get("ask1Price"), current.ask if current else 0.0),
|
||||
turnover_24h=_float(data.get("turnover24h"), current.turnover_24h if current else 0.0),
|
||||
volume_24h=_float(data.get("volume24h"), current.volume_24h if current else 0.0),
|
||||
change_24h=_float(data.get("price24hPcnt")) * 100
|
||||
if data.get("price24hPcnt") is not None
|
||||
else (current.change_24h if current else 0.0),
|
||||
)
|
||||
|
||||
def _handle_kline(self, symbol: str, rows: list[dict[str, Any]]) -> None:
|
||||
existing = self.candles.get(symbol, [])
|
||||
by_timestamp = {candle.timestamp: candle for candle in existing}
|
||||
for row in rows:
|
||||
start = int(row.get("start", 0))
|
||||
if start <= 0:
|
||||
continue
|
||||
by_timestamp[start] = Candle(
|
||||
timestamp=start,
|
||||
open=_float(row.get("open")),
|
||||
high=_float(row.get("high")),
|
||||
low=_float(row.get("low")),
|
||||
close=_float(row.get("close")),
|
||||
volume=_float(row.get("volume")),
|
||||
turnover=_float(row.get("turnover")),
|
||||
)
|
||||
candles = sorted(by_timestamp.values(), key=lambda item: item.timestamp)
|
||||
candles = candles[-self.settings.kline_limit :]
|
||||
add_indicators(candles)
|
||||
self.candles[symbol] = candles
|
||||
|
||||
def _handle_orderbook(self, symbol: str, data: dict[str, Any]) -> None:
|
||||
bids = data.get("b") or []
|
||||
asks = data.get("a") or []
|
||||
bid = _float(bids[0][0]) if bids else 0.0
|
||||
ask = _float(asks[0][0]) if asks else 0.0
|
||||
if bid > 0 and ask > 0:
|
||||
self.orderbook_top[symbol] = (bid, ask)
|
||||
current = self.tickers.get(symbol)
|
||||
if current:
|
||||
self.tickers[symbol] = Ticker(
|
||||
symbol=symbol,
|
||||
last_price=current.last_price,
|
||||
bid=bid,
|
||||
ask=ask,
|
||||
turnover_24h=current.turnover_24h,
|
||||
volume_24h=current.volume_24h,
|
||||
change_24h=current.change_24h,
|
||||
)
|
||||
|
||||
def prices(self) -> dict[str, float]:
|
||||
return {symbol: ticker.last_price for symbol, ticker in self.tickers.items()}
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
return {
|
||||
"symbols": self.symbols,
|
||||
"ws_connected": self.ws_connected,
|
||||
"last_rest_refresh_at": self.last_rest_refresh_at.isoformat()
|
||||
if self.last_rest_refresh_at
|
||||
else None,
|
||||
"last_ws_message_at": self.last_ws_message_at.isoformat()
|
||||
if self.last_ws_message_at
|
||||
else None,
|
||||
"markets": [
|
||||
{
|
||||
"ticker": self.tickers[symbol].as_dict() if symbol in self.tickers else None,
|
||||
"candles": [candle.as_dict() for candle in self.candles.get(symbol, [])[-120:]],
|
||||
"pattern": self.patterns.get(symbol),
|
||||
"forecast": self.forecasts.get(symbol),
|
||||
"instrument": asdict(self.instruments[symbol]) if symbol in self.instruments else None,
|
||||
}
|
||||
for symbol in self.symbols
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user