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 requests.adapters import HTTPAdapter from urllib3.util.retry import Retry 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() retry = Retry( total=3, connect=3, read=3, status=3, backoff_factor=0.4, status_forcelist=(429, 500, 502, 503, 504), allowed_methods=frozenset({"GET"}), respect_retry_after_header=True, ) self.session.mount("https://", HTTPAdapter(max_retries=retry)) 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": _decimal_text(qty), "timeInForce": "IOC", "isLeverage": 0, "orderFilter": "Order", "marketUnit": market_unit, "orderLinkId": order_link_id, } slippage_percent = max(0.01, min(10.0, self.settings.slippage_rate * 100.0)) payload["slippageToleranceType"] = "Percent" payload["slippageTolerance"] = f"{slippage_percent:.2f}" return self.private_post("/v5/order/create", payload) def place_spot_protective_stop( self, *, symbol: str, qty: float, trigger_price: float, order_link_id: str, ) -> dict[str, Any]: payload = { "category": "spot", "symbol": symbol, "side": "Sell", "orderType": "Market", "qty": _decimal_text(qty), "triggerPrice": _decimal_text(trigger_price), "timeInForce": "IOC", "isLeverage": 0, "orderFilter": "tpslOrder", "marketUnit": "baseCoin", "orderLinkId": order_link_id, } return self.private_post("/v5/order/create", payload) def cancel_spot_order( self, *, symbol: str, order_id: str | None = None, order_link_id: str | None = None, order_filter: str = "Order", ) -> dict[str, Any]: if not order_id and not order_link_id: raise ValueError("order_id or order_link_id is required") payload: dict[str, Any] = { "category": "spot", "symbol": symbol, "orderFilter": order_filter, } if order_id: payload["orderId"] = order_id if order_link_id: payload["orderLinkId"] = order_link_id return self.private_post("/v5/order/cancel", 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, symbol: str | None = None, order_id: str | None = None, order_link_id: str | None = None, order_filter: str | None = None, ) -> dict[str, Any]: return self.private_get( "/v5/order/realtime", { "category": category, "openOnly": open_only, "limit": max(1, min(limit, 50)), "symbol": symbol, "orderId": order_id, "orderLinkId": order_link_id, "orderFilter": order_filter, }, ) def order_history( self, *, symbol: str | None = None, order_id: str | None = None, order_link_id: str | None = None, limit: int = 50, ) -> dict[str, Any]: return self.private_get( "/v5/order/history", { "category": "spot", "symbol": symbol, "orderId": order_id, "orderLinkId": order_link_id, "limit": max(1, min(limit, 50)), }, ) def executions( self, *, symbol: str | None = None, order_id: str | None = None, order_link_id: str | None = None, limit: int = 100, ) -> dict[str, Any]: return self.private_get( "/v5/execution/list", { "category": "spot", "symbol": symbol, "orderId": order_id, "orderLinkId": order_link_id, "limit": max(1, min(limit, 100)), }, ) def wait_for_spot_order( self, *, order_id: str, symbol: str, timeout_seconds: float, poll_seconds: float = 0.5, ) -> dict[str, Any]: deadline = time.monotonic() + max(1.0, timeout_seconds) latest: dict[str, Any] = {} terminal = { "Filled", "Cancelled", "Rejected", "PartiallyFilledCanceled", "PartillyFilledCancelled", "Deactivated", } while time.monotonic() < deadline: realtime = self.realtime_orders(symbol=symbol, order_id=order_id, open_only=1, limit=1) rows = realtime.get("list") if isinstance(realtime.get("list"), list) else [] if rows and isinstance(rows[0], dict): latest = rows[0] if str(latest.get("orderStatus", "")) in terminal: break time.sleep(max(0.1, poll_seconds)) if not latest or str(latest.get("orderStatus", "")) not in terminal: history = self.order_history(symbol=symbol, order_id=order_id, limit=1) rows = history.get("list") if isinstance(history.get("list"), list) else [] if rows and isinstance(rows[0], dict): latest = rows[0] execution_result = self.executions(symbol=symbol, order_id=order_id) executions = execution_result.get("list") if isinstance(execution_result.get("list"), list) else [] return {"order": latest, "executions": executions} 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", } def _decimal_text(value: float) -> str: return f"{value:.12f}".rstrip("0").rstrip(".")