Files
TradeBot/crypto_spot_bot/bybit.py
T

257 lines
8.6 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 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",
}