Files
DinQuant/backend_api_python/app/services/live_trading/bybit.py
T
Dinger 05f07ee544 refactor deployment config and exchange integrations
Simplify runtime configuration and remove legacy database and settings surface so new installs are easier to operate.
Refresh deployment assets, docs, and order execution behavior to keep the packaged app aligned with the current backend.

Made-with: Cursor
2026-03-21 18:32:04 +08:00

521 lines
21 KiB
Python

"""
Bybit (direct REST) client for spot / linear perpetual orders (v5).
Signing (v5):
- X-BAPI-SIGN = hex(hmac_sha256(secret, timestamp + api_key + recv_window + payload))
- payload:
- GET: query string (sorted, urlencoded)
- POST: raw body string
"""
from __future__ import annotations
import hashlib
import hmac
import time
from decimal import Decimal, ROUND_DOWN
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
from app.services.live_trading.symbols import to_bybit_symbol
class BybitClient(BaseRestClient):
_DEFAULT_BROKER_REFERER = "Ri001020"
def __init__(
self,
*,
api_key: str,
secret_key: str,
base_url: str = "https://api.bybit.com",
timeout_sec: float = 15.0,
category: str = "linear", # "linear" (USDT perpetual) or "spot"
recv_window_ms: int = 5000,
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.category = (category or "linear").strip().lower()
self.broker_referer = self._DEFAULT_BROKER_REFERER
if self.category not in ("linear", "spot"):
self.category = "linear"
try:
self.recv_window_ms = int(recv_window_ms or 5000)
except Exception:
self.recv_window_ms = 5000
if self.recv_window_ms <= 0:
self.recv_window_ms = 5000
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing Bybit api_key/secret_key")
# Best-effort cache for linear instrument metadata (qty step, min qty, etc.)
# Key: f"{category}:{symbol}" -> (fetched_at_ts, info_dict)
self._inst_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._inst_cache_ttl_sec = 300.0
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
return Decimal(str(x))
except Exception:
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal, max_decimals: int = 18, strict_precision: Optional[int] = None) -> str:
"""
Convert Decimal to string with controlled precision.
Bybit requires quantities to match qtyStep precision.
Args:
d: Decimal value to format
max_decimals: Maximum decimal places (fallback if strict_precision not provided)
strict_precision: If provided, strictly limit to this many decimal places
"""
try:
if d == 0:
return "0"
normalized = d.normalize()
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
q = Decimal("1").scaleb(-prec)
quantized = normalized.quantize(q, rounding=ROUND_DOWN)
s = format(quantized, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
try:
f = float(d)
if f == 0:
return "0"
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
if step is None:
return value
if value <= 0:
return Decimal("0")
try:
st = Decimal(step)
except Exception:
st = Decimal("0")
if st <= 0:
return value
try:
n = (value / st).to_integral_value(rounding=ROUND_DOWN)
return n * st
except Exception:
return Decimal("0")
def _sign(self, prehash: str) -> str:
return hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).hexdigest()
@staticmethod
def _resolve_position_idx(pos_side: str) -> Optional[int]:
ps = str(pos_side or "").strip().lower()
if ps == "long":
return 1
if ps == "short":
return 2
return None
def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]:
headers = {
"X-BAPI-API-KEY": self.api_key,
"X-BAPI-SIGN": sign,
"X-BAPI-TIMESTAMP": ts_ms,
"X-BAPI-RECV-WINDOW": str(self.recv_window_ms),
"X-BAPI-SIGN-TYPE": "2",
"Content-Type": "application/json",
}
if self.broker_referer:
headers["Referer"] = self.broker_referer
return headers
def _signed_request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
m = str(method or "GET").upper()
ts_ms = str(int(time.time() * 1000))
body_str = self._json_dumps(json_body) if json_body is not None else ""
qs = ""
if params:
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
qs = urlencode(sorted(norm.items()), doseq=True)
payload = qs if m == "GET" else body_str
prehash = f"{ts_ms}{self.api_key}{self.recv_window_ms}{payload}"
sign = self._sign(prehash)
code, data, text = self._request(
m,
path,
params=params if (m == "GET" and params) else (params or None),
data=body_str if body_str else None,
headers=self._headers(ts_ms, sign),
)
if code >= 400:
raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}")
if isinstance(data, dict):
rc = data.get("retCode")
if rc not in (0, "0", None, ""):
raise LiveTradingError(f"Bybit error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
if code >= 400:
raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}")
if isinstance(data, dict):
rc = data.get("retCode")
if rc not in (0, "0", None, ""):
raise LiveTradingError(f"Bybit error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def ping(self) -> bool:
try:
data = self._public_request("GET", "/v5/market/time")
return isinstance(data, dict) and (data.get("retCode") in (0, "0", None, ""))
except Exception:
return False
def get_wallet_balance(self, *, account_type: str = "UNIFIED") -> Dict[str, Any]:
return self._signed_request("GET", "/v5/account/wallet-balance", params={"accountType": str(account_type or "UNIFIED")})
def get_instrument_info(self, *, category: str, symbol: str) -> Dict[str, Any]:
cat = str(category or self.category or "linear").strip().lower()
sym = to_bybit_symbol(symbol)
if not sym:
return {}
key = f"{cat}:{sym}"
now = time.time()
cached = self._inst_cache.get(key)
if cached:
ts, obj = cached
if obj and (now - float(ts or 0.0)) <= float(self._inst_cache_ttl_sec or 300.0):
return obj
raw = self._public_request("GET", "/v5/market/instruments-info", params={"category": cat, "symbol": sym})
lst = (((raw.get("result") or {}).get("list")) if isinstance(raw, dict) else None) or []
first: Dict[str, Any] = lst[0] if isinstance(lst, list) and lst else {}
if isinstance(first, dict) and first:
self._inst_cache[key] = (now, first)
return first if isinstance(first, dict) else {}
def _normalize_qty(self, *, symbol: str, qty: float) -> Tuple[Decimal, Optional[int]]:
q = self._to_dec(qty)
if q <= 0:
return (Decimal("0"), None)
sym = to_bybit_symbol(symbol)
try:
info = self.get_instrument_info(category=self.category, symbol=sym) or {}
except Exception:
info = {}
lot = (info.get("lotSizeFilter") if isinstance(info, dict) else None) or {}
step = self._to_dec((lot or {}).get("qtyStep") or "0")
mn = self._to_dec((lot or {}).get("minOrderQty") or "0")
if step > 0:
q = self._floor_to_step(q, step)
# Infer precision from qtyStep
qty_precision = None
if step > 0:
try:
step_normalized = step.normalize()
step_str = str(step_normalized)
if '.' in step_str:
decimal_part = step_str.split('.')[1]
qty_precision = len(decimal_part)
if qty_precision < 0:
qty_precision = 0
if qty_precision > 18:
qty_precision = 18
else:
qty_precision = 0
except Exception:
pass
if mn > 0 and q < mn:
return (Decimal("0"), qty_precision)
return (q, qty_precision)
def _normalize_price(self, *, symbol: str, price: float) -> Tuple[Decimal, Optional[int]]:
p = self._to_dec(price)
if p <= 0:
return (Decimal("0"), None)
sym = to_bybit_symbol(symbol)
try:
info = self.get_instrument_info(category=self.category, symbol=sym) or {}
except Exception:
info = {}
pf = (info.get("priceFilter") if isinstance(info, dict) else None) or {}
tick = self._to_dec((pf or {}).get("tickSize") or "0")
if tick > 0:
p = self._floor_to_step(p, tick)
price_precision = None
if tick > 0:
try:
tick_normalized = tick.normalize()
tick_str = str(tick_normalized)
if "." in tick_str:
price_precision = len(tick_str.split(".")[1])
if price_precision < 0:
price_precision = 0
if price_precision > 18:
price_precision = 18
else:
price_precision = 0
except Exception:
pass
return (p, price_precision)
def place_market_order(
self,
*,
symbol: str,
side: str,
qty: float,
reduce_only: bool = False,
pos_side: str = "",
client_order_id: Optional[str] = None,
) -> LiveOrderResult:
sym = to_bybit_symbol(symbol)
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
q_req = float(qty or 0.0)
q_dec, qty_precision = self._normalize_qty(symbol=symbol, qty=q_req)
if float(q_dec or 0) <= 0:
raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}")
body: Dict[str, Any] = {
"category": self.category,
"symbol": sym,
"side": "Buy" if sd == "buy" else "Sell",
"orderType": "Market",
"qty": self._dec_str(q_dec, strict_precision=qty_precision),
"timeInForce": "IOC",
}
if self.category == "spot":
body["marketUnit"] = "baseCoin"
pos_idx = self._resolve_position_idx(pos_side) if self.category == "linear" else None
if pos_idx is not None:
body["positionIdx"] = pos_idx
if reduce_only and self.category == "linear":
body["reduceOnly"] = True
if client_order_id:
body["orderLinkId"] = str(client_order_id)
raw = self._signed_request("POST", "/v5/order/create", json_body=body)
res = (raw.get("result") or {}) if isinstance(raw, dict) else {}
oid = str(res.get("orderId") or res.get("orderLinkId") or "")
return LiveOrderResult(exchange_id="bybit", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
def place_limit_order(
self,
*,
symbol: str,
side: str,
qty: float,
price: float,
reduce_only: bool = False,
pos_side: str = "",
client_order_id: Optional[str] = None,
) -> LiveOrderResult:
sym = to_bybit_symbol(symbol)
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
q_req = float(qty or 0.0)
px_req = float(price or 0.0)
if q_req <= 0 or px_req <= 0:
raise LiveTradingError("Invalid qty/price")
q_dec, qty_precision = self._normalize_qty(symbol=symbol, qty=q_req)
px_dec, price_precision = self._normalize_price(symbol=symbol, price=px_req)
if float(q_dec or 0) <= 0:
raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}")
if float(px_dec or 0) <= 0:
raise LiveTradingError(f"Invalid price (below tick/min): requested={px_req}")
body: Dict[str, Any] = {
"category": self.category,
"symbol": sym,
"side": "Buy" if sd == "buy" else "Sell",
"orderType": "Limit",
"qty": self._dec_str(q_dec, strict_precision=qty_precision),
"price": self._dec_str(px_dec, strict_precision=price_precision),
"timeInForce": "GTC",
}
pos_idx = self._resolve_position_idx(pos_side) if self.category == "linear" else None
if pos_idx is not None:
body["positionIdx"] = pos_idx
if reduce_only and self.category == "linear":
body["reduceOnly"] = True
if client_order_id:
body["orderLinkId"] = str(client_order_id)
raw = self._signed_request("POST", "/v5/order/create", json_body=body)
res = (raw.get("result") or {}) if isinstance(raw, dict) else {}
oid = str(res.get("orderId") or res.get("orderLinkId") or "")
return LiveOrderResult(exchange_id="bybit", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
sym = to_bybit_symbol(symbol)
body: Dict[str, Any] = {"category": self.category, "symbol": sym}
if order_id:
body["orderId"] = str(order_id)
elif client_order_id:
body["orderLinkId"] = str(client_order_id)
else:
raise LiveTradingError("Bybit cancel_order requires order_id or client_order_id")
return self._signed_request("POST", "/v5/order/cancel", json_body=body)
def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
sym = to_bybit_symbol(symbol)
params: Dict[str, Any] = {"category": self.category, "symbol": sym}
if order_id:
params["orderId"] = str(order_id)
elif client_order_id:
params["orderLinkId"] = str(client_order_id)
else:
raise LiveTradingError("Bybit get_order requires order_id or client_order_id")
raw = self._signed_request("GET", "/v5/order/realtime", params=params)
lst = (((raw.get("result") or {}).get("list")) if isinstance(raw, dict) else None) or []
first: Dict[str, Any] = lst[0] if isinstance(lst, list) and lst else {}
return first if isinstance(first, dict) else {}
def wait_for_fill(
self,
*,
symbol: str,
order_id: str = "",
client_order_id: str = "",
max_wait_sec: float = 3.0,
poll_interval_sec: float = 0.5,
) -> Dict[str, Any]:
end_ts = time.time() + float(max_wait_sec or 0.0)
last: Dict[str, Any] = {}
while True:
try:
last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or ""))
except Exception:
last = last or {}
status = str(last.get("orderStatus") or last.get("order_status") or "")
try:
filled = float(last.get("cumExecQty") or 0.0)
except Exception:
filled = 0.0
avg_price = 0.0
try:
avg_price = float(last.get("avgPrice") or 0.0)
except Exception:
avg_price = 0.0
# Extract fee from cumExecFee (Bybit API field for cumulative execution fee)
fee = 0.0
fee_ccy = ""
fee_detail = last.get("cumFeeDetail") if isinstance(last, dict) else None
if isinstance(fee_detail, dict) and fee_detail:
total_fee = 0.0
fee_keys = []
for k, v in fee_detail.items():
try:
fv = abs(float(v or 0.0))
except Exception:
fv = 0.0
if fv > 0:
total_fee += fv
fee_keys.append(str(k))
fee = total_fee
if len(fee_keys) == 1:
fee_ccy = fee_keys[0]
if fee <= 0:
try:
fee = abs(float(last.get("cumExecFee") or 0.0))
except Exception:
fee = 0.0
if fee > 0 and self.category == "linear":
fee_ccy = "USDT"
if filled > 0 and avg_price > 0:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
if status.lower() in ("filled", "cancelled", "canceled", "rejected"):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
if time.time() >= end_ts:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
time.sleep(float(poll_interval_sec or 0.5))
def get_positions(self) -> Dict[str, Any]:
if self.category != "linear":
raise LiveTradingError("Bybit positions are only supported for linear category in this client")
return self._signed_request("GET", "/v5/position/list", params={"category": "linear"})
def set_leverage(self, *, symbol: str, leverage: float) -> bool:
if self.category != "linear":
return False
sym = to_bybit_symbol(symbol)
try:
lv = int(float(leverage or 1.0))
except Exception:
lv = 1
if lv < 1:
lv = 1
# Bybit leverage caps vary per symbol; keep best-effort.
body = {"category": "linear", "symbol": sym, "buyLeverage": str(lv), "sellLeverage": str(lv)}
try:
resp = self._signed_request("POST", "/v5/position/set-leverage", json_body=body)
ok = isinstance(resp, dict) and (resp.get("retCode") in (0, "0", None, ""))
return bool(ok)
except Exception:
return False