Files
TradeBot/crypto_spot_bot/bybit.py
T

412 lines
14 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 = 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(".")