434 lines
14 KiB
Python
434 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 = 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]:
|
|
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(".")
|