Files
TradeBot/crypto_spot_bot/bybit.py
T

440 lines
15 KiB
Python

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 = self._build_session()
@staticmethod
def _build_session() -> requests.Session:
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,
)
session.mount("https://", HTTPAdapter(max_retries=retry))
return session
def _reset_session(self) -> None:
self.session.close()
self.session = self._build_session()
def public_get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
response = None
for attempt in range(3):
try:
response = self.session.get(
f"{self.settings.rest_base_url}{path}",
params=params,
timeout=12,
)
break
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
if attempt >= 2:
raise
# A failed TLS session can remain poisoned in urllib3's pool.
# Recreate the pool before retrying instead of reusing it.
self._reset_session()
time.sleep(0.5 * (2**attempt))
if response is None: # pragma: no cover - loop either returns or raises.
raise BybitError("Bybit public request produced no response")
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]:
bid, _bid_size, ask, _ask_size = self.orderbook_level_one(symbol)
return bid, ask
def orderbook_level_one(self, symbol: str) -> tuple[float, float, 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
bid_size = _float(bids[0][1]) if bids and len(bids[0]) > 1 else 0.0
ask = _float(asks[0][0]) if asks else 0.0
ask_size = _float(asks[0][1]) if asks and len(asks[0]) > 1 else 0.0
return bid, bid_size, ask, ask_size
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(".")