from __future__ import annotations import hashlib import hmac import json import time from dataclasses import dataclass from typing import Any from urllib.parse import urlencode import requests from crypto_spot_bot.config import Settings from crypto_spot_bot.models import Candle, Ticker class BybitError(RuntimeError): pass def _float(value: Any, default: float = 0.0) -> float: try: return float(value) except (TypeError, ValueError): return default @dataclass(slots=True) class Instrument: symbol: str base_coin: str quote_coin: str status: str tick_size: float qty_step: float min_order_qty: float min_notional_value: float class BybitClient: def __init__(self, settings: Settings): self.settings = settings self.session = requests.Session() def public_get(self, path: str, params: dict[str, Any]) -> dict[str, Any]: response = self.session.get( f"{self.settings.rest_base_url}{path}", params=params, timeout=12, ) response.raise_for_status() return self._unwrap(response.json()) def private_post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) headers = self._headers(body) response = self.session.post( f"{self.settings.rest_base_url}{path}", data=body.encode("utf-8"), headers=headers, timeout=15, ) response.raise_for_status() return self._unwrap(response.json()) def private_get(self, path: str, params: dict[str, Any]) -> dict[str, Any]: query_params = sorted((key, value) for key, value in params.items() if value is not None) query = urlencode(query_params) headers = self._headers(query) response = self.session.get( f"{self.settings.rest_base_url}{path}", params=query_params, headers=headers, timeout=15, ) response.raise_for_status() return self._unwrap(response.json()) def _headers(self, payload: str) -> dict[str, str]: timestamp = str(int(time.time() * 1000)) recv_window = "5000" sign_payload = timestamp + self.settings.bybit_api_key + recv_window + payload signature = hmac.new( self.settings.bybit_api_secret.encode("utf-8"), sign_payload.encode("utf-8"), hashlib.sha256, ).hexdigest() return { "X-BAPI-API-KEY": self.settings.bybit_api_key, "X-BAPI-TIMESTAMP": timestamp, "X-BAPI-RECV-WINDOW": recv_window, "X-BAPI-SIGN": signature, "Content-Type": "application/json", } @staticmethod def _unwrap(data: dict[str, Any]) -> dict[str, Any]: if int(data.get("retCode", -1)) != 0: raise BybitError(f"{data.get('retCode')}: {data.get('retMsg')}") result = data.get("result") if not isinstance(result, dict): raise BybitError("Bybit returned unexpected result payload") return result def spot_tickers(self) -> list[Ticker]: result = self.public_get("/v5/market/tickers", {"category": "spot"}) tickers: list[Ticker] = [] for row in result.get("list", []): symbol = str(row.get("symbol", "")).upper() last = _float(row.get("lastPrice")) if not symbol or last <= 0: continue tickers.append( Ticker( symbol=symbol, last_price=last, bid=_float(row.get("bid1Price")), ask=_float(row.get("ask1Price")), turnover_24h=_float(row.get("turnover24h")), volume_24h=_float(row.get("volume24h")), change_24h=_float(row.get("price24hPcnt")) * 100, ) ) return tickers def instruments(self) -> dict[str, Instrument]: result = self.public_get("/v5/market/instruments-info", {"category": "spot"}) instruments: dict[str, Instrument] = {} for row in result.get("list", []): lot = row.get("lotSizeFilter") or {} price = row.get("priceFilter") or {} symbol = str(row.get("symbol", "")).upper() if not symbol: continue instruments[symbol] = Instrument( symbol=symbol, base_coin=str(row.get("baseCoin", "")), quote_coin=str(row.get("quoteCoin", "")), status=str(row.get("status", "")), tick_size=_float(price.get("tickSize")), qty_step=_float(lot.get("qtyStep"), _float(lot.get("basePrecision"))), min_order_qty=_float(lot.get("minOrderQty")), min_notional_value=_float(lot.get("minNotionalValue"), _float(lot.get("minOrderAmt"))), ) return instruments def popular_spot_symbols(self, limit: int) -> list[str]: instruments = self.instruments() rows = [] for ticker in self.spot_tickers(): info = instruments.get(ticker.symbol) if ( info and info.quote_coin == "USDT" and info.status == "Trading" and ticker.turnover_24h > 0 and not _looks_like_leveraged_token(info.base_coin) and not _looks_like_stablecoin(info.base_coin) ): rows.append((ticker.turnover_24h, ticker.symbol)) rows.sort(reverse=True) return [symbol for _, symbol in rows[:limit]] def klines(self, symbol: str, interval: str, limit: int) -> list[Candle]: result = self.public_get( "/v5/market/kline", {"category": "spot", "symbol": symbol, "interval": interval, "limit": limit}, ) candles = [] for row in result.get("list", []): if len(row) < 7: continue candles.append( Candle( timestamp=int(row[0]), open=_float(row[1]), high=_float(row[2]), low=_float(row[3]), close=_float(row[4]), volume=_float(row[5]), turnover=_float(row[6]), ) ) candles.sort(key=lambda item: item.timestamp) return candles def orderbook_top(self, symbol: str) -> tuple[float, float]: result = self.public_get( "/v5/market/orderbook", {"category": "spot", "symbol": symbol, "limit": 1}, ) bids = result.get("b") or [] asks = result.get("a") or [] bid = _float(bids[0][0]) if bids else 0.0 ask = _float(asks[0][0]) if asks else 0.0 return bid, ask def place_spot_market_order( self, symbol: str, side: str, qty: float, market_unit: str, order_link_id: str, ) -> dict[str, Any]: payload = { "category": "spot", "symbol": symbol, "side": side, "orderType": "Market", "qty": f"{qty:.8f}".rstrip("0").rstrip("."), "timeInForce": "IOC", "isLeverage": 0, "orderFilter": "Order", "marketUnit": market_unit, "orderLinkId": order_link_id, } return self.private_post("/v5/order/create", payload) def wallet_balance(self, account_type: str = "UNIFIED", coin: str | None = None) -> dict[str, Any]: return self.private_get( "/v5/account/wallet-balance", {"accountType": account_type, "coin": coin}, ) def realtime_orders(self, *, category: str = "spot", open_only: int = 0, limit: int = 50) -> dict[str, Any]: return self.private_get( "/v5/order/realtime", {"category": category, "openOnly": open_only, "limit": max(1, min(limit, 50))}, ) def websocket_subscribe_message(symbols: list[str], interval: str = "1") -> str: args: list[str] = [] for symbol in symbols: args.extend([f"tickers.{symbol}", f"kline.{interval}.{symbol}", f"orderbook.1.{symbol}"]) return json.dumps({"op": "subscribe", "args": args}) def _looks_like_leveraged_token(base_coin: str) -> bool: upper = base_coin.upper() return upper.endswith(("3L", "3S", "2L", "2S", "5L", "5S", "UP", "DOWN")) def _looks_like_stablecoin(base_coin: str) -> bool: return base_coin.upper() in { "USDC", "USDT", "DAI", "TUSD", "FDUSD", "USDE", "USDD", "PYUSD", "USD1", }