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.data_quality import analyze_symbol_quality, market_quality_snapshot 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", "HYPEUSDT", "SOLUSDT", "XRPUSDT", "XPLUSDT", "WLDUSDT", "MNTUSDT", "HUSDT", "XAUTUSDT", "IPUSDT", "AAVEUSDT", ] 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.trend_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, ) candles = _closed_candles(candles, self.settings.base_interval) add_indicators(candles) self.candles[symbol] = candles trend_candles = self.client.klines( symbol=symbol, interval=self.settings.trend_interval, limit=self.settings.trend_kline_limit, ) trend_candles = _closed_candles(trend_candles, self.settings.trend_interval) add_indicators(trend_candles) self.trend_candles[symbol] = trend_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.settings.base_interval)) 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 if not _is_closed_kline_row(row, self.settings.base_interval): 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, "quality": market_quality_snapshot( symbols=self.symbols, candles_by_symbol=self.candles, tickers=self.tickers, interval=self.settings.base_interval, ), "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:]], "trend_candles": [candle.as_dict() for candle in self.trend_candles.get(symbol, [])[-5:]], "pattern": self.patterns.get(symbol), "forecast": self.forecasts.get(symbol), "quality": analyze_symbol_quality( symbol=symbol, candles=self.candles.get(symbol, []), ticker=self.tickers.get(symbol), interval=self.settings.base_interval, ), "instrument": asdict(self.instruments[symbol]) if symbol in self.instruments else None, } for symbol in self.symbols ], } def _closed_candles(candles: list[Candle], interval: str, now_ms: int | None = None) -> list[Candle]: interval_ms = _interval_ms(interval) if interval_ms <= 0: return candles now_ms = now_ms if now_ms is not None else int(utc_now().timestamp() * 1000) return [candle for candle in candles if candle.timestamp + interval_ms <= now_ms] def _is_closed_kline_row(row: dict[str, Any], interval: str) -> bool: confirm = row.get("confirm") if isinstance(confirm, bool): return confirm start = int(row.get("start", 0) or 0) interval_ms = _interval_ms(interval) if start <= 0 or interval_ms <= 0: return True return start + interval_ms <= int(utc_now().timestamp() * 1000) def _interval_ms(interval: str) -> int: normalized = str(interval).strip().upper() if normalized == "D": return 24 * 60 * 60 * 1000 if normalized == "W": return 7 * 24 * 60 * 60 * 1000 if normalized.isdigit(): return int(normalized) * 60 * 1000 return 0