Files
DinQuant/backend_api_python/app/services/live_trading/bitget_spot.py
T
TIANHE f43312a858 creat
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2025-12-29 03:06:49 +08:00

355 lines
14 KiB
Python

"""
Bitget Spot (direct REST) client.
Endpoints are aligned with hummingbot constants:
- POST /api/v2/spot/trade/place-order
- POST /api/v2/spot/trade/cancel-order
- GET /api/v2/spot/trade/orderInfo
- GET /api/v2/spot/trade/fills
"""
from __future__ import annotations
import base64
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_bitget_um_symbol
class BitgetSpotClient(BaseRestClient):
def __init__(
self,
*,
api_key: str,
secret_key: str,
passphrase: str,
base_url: str = "https://api.bitget.com",
timeout_sec: float = 15.0,
channel_api_code: str = "bntva",
):
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.passphrase = (passphrase or "").strip()
self.channel_api_code = (channel_api_code or "").strip()
if not self.api_key or not self.secret_key or not self.passphrase:
raise LiveTradingError("Missing Bitget api_key/secret_key/passphrase")
# Best-effort cache for public symbol metadata used to normalize order sizes.
# Key: symbol -> (fetched_at_ts, meta_dict)
self._sym_meta_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._sym_meta_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) -> str:
try:
return format(d, "f")
except Exception:
return str(d)
@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, ts_ms: str, method: str, path: str, body: str) -> str:
prehash = f"{ts_ms}{method.upper()}{path}{body}"
mac = hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).digest()
return base64.b64encode(mac).decode("utf-8")
def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]:
h = {
"ACCESS-KEY": self.api_key,
"ACCESS-SIGN": sign,
"ACCESS-TIMESTAMP": ts_ms,
"ACCESS-PASSPHRASE": self.passphrase,
"Content-Type": "application/json",
}
if self.channel_api_code:
h["X-CHANNEL-API-CODE"] = self.channel_api_code
return h
def _signed_request(
self,
method: str,
path: str,
*,
json_body: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Bitget signature must match the exact body string sent over the wire.
"""
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)
signed_path = f"{path}?{qs}" if qs else path
sign = self._sign(ts_ms, method, signed_path, body_str)
code, data, text = self._request(
method,
path,
params=params,
data=body_str if body_str else None,
headers=self._headers(ts_ms, sign),
)
if code >= 400:
raise LiveTradingError(f"BitgetSpot HTTP {code}: {text[:500]}")
if isinstance(data, dict):
c = str(data.get("code") or "")
if c and c not in ("00000", "0"):
raise LiveTradingError(f"BitgetSpot 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"BitgetSpot HTTP {code}: {text[:500]}")
if isinstance(data, dict):
c = str(data.get("code") or "")
if c and c not in ("00000", "0"):
raise LiveTradingError(f"BitgetSpot error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def get_symbol_meta(self, *, symbol: str) -> Dict[str, Any]:
"""
Fetch spot symbol metadata (best-effort).
Endpoint (Bitget v2 spot): GET /api/v2/spot/public/symbols
"""
sym = to_bitget_um_symbol(symbol)
if not sym:
return {}
now = time.time()
cached = self._sym_meta_cache.get(sym)
if cached:
ts, obj = cached
if obj and (now - float(ts or 0.0)) <= float(self._sym_meta_cache_ttl_sec or 300.0):
return obj
raw = self._public_request("GET", "/api/v2/spot/public/symbols")
data = raw.get("data") if isinstance(raw, dict) else None
items = data if isinstance(data, list) else []
found: Dict[str, Any] = {}
for it in items:
if not isinstance(it, dict):
continue
s = str(it.get("symbol") or it.get("symbolName") or "")
if s and s.upper() == sym.upper():
found = it
break
if found:
self._sym_meta_cache[sym] = (now, found)
return found
def _normalize_base_size(self, *, symbol: str, base_size: float) -> Decimal:
"""
Normalize spot base size to lot/step constraints (best-effort).
"""
req = self._to_dec(base_size)
if req <= 0:
return Decimal("0")
meta: Dict[str, Any] = {}
try:
meta = self.get_symbol_meta(symbol=symbol) or {}
except Exception:
meta = {}
# Try common fields. If unavailable, keep as-is.
step = self._to_dec(meta.get("quantityScale") or meta.get("quantityStep") or meta.get("sizeStep") or meta.get("minTradeIncrement") or "0")
if step <= 0:
# Some endpoints expose decimals instead of step.
qd = meta.get("quantityPrecision") or meta.get("quantityPlace") or meta.get("sizePlace")
try:
places = int(qd) if qd is not None else 0
except Exception:
places = 0
if places >= 0 and places <= 18:
step = Decimal("1") / (Decimal("10") ** Decimal(str(places)))
if step > 0:
req = self._floor_to_step(req, step)
mn = self._to_dec(meta.get("minTradeAmount") or meta.get("minTradeNum") or meta.get("minQty") or meta.get("minSize") or "0")
if mn > 0 and req < mn:
return Decimal("0")
return req
def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
sym = to_bitget_um_symbol(symbol)
sd = (side or "").lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
req = float(size or 0.0)
px = float(price or 0.0)
if req <= 0 or px <= 0:
raise LiveTradingError("Invalid size/price")
sz_dec = self._normalize_base_size(symbol=symbol, base_size=req)
if float(sz_dec or 0) <= 0:
raise LiveTradingError(f"Invalid size (below step/min): requested={req}")
body: Dict[str, Any] = {
"side": sd,
"symbol": sym,
"size": self._dec_str(sz_dec),
"orderType": "limit",
"force": "gtc",
"price": str(px),
}
if client_order_id:
body["clientOid"] = str(client_order_id)
raw = self._signed_request("POST", "/api/v2/spot/trade/place-order", json_body=body)
data = raw.get("data") if isinstance(raw, dict) else None
order_id = str(data.get("orderId") or "") if isinstance(data, dict) else ""
return LiveOrderResult(exchange_id="bitget", exchange_order_id=order_id, filled=0.0, avg_price=0.0, raw=raw)
def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
"""
NOTE: Bitget spot market BUY may expect quote amount. We accept `size` as base size,
but the caller can also pass a quote-sized value if desired.
"""
sym = to_bitget_um_symbol(symbol)
sd = (side or "").lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
req = float(size or 0.0)
if req <= 0:
raise LiveTradingError("Invalid size")
# For Bitget spot market BUY, many APIs interpret size as quote amount.
# Our worker may pass quote-sized value for BUY; do not quantize it as base size.
if sd == "sell":
sz_dec = self._normalize_base_size(symbol=symbol, base_size=req)
if float(sz_dec or 0) <= 0:
raise LiveTradingError(f"Invalid size (below step/min): requested={req}")
sz_str = self._dec_str(sz_dec)
else:
sz_str = str(req)
body: Dict[str, Any] = {
"side": sd,
"symbol": sym,
"size": sz_str,
"orderType": "market",
"force": "gtc",
}
if client_order_id:
body["clientOid"] = str(client_order_id)
raw = self._signed_request("POST", "/api/v2/spot/trade/place-order", json_body=body)
data = raw.get("data") if isinstance(raw, dict) else None
order_id = str(data.get("orderId") or "") if isinstance(data, dict) else ""
return LiveOrderResult(exchange_id="bitget", exchange_order_id=order_id, filled=0.0, avg_price=0.0, raw=raw)
def cancel_order(self, *, symbol: str, client_order_id: str) -> Dict[str, Any]:
sym = to_bitget_um_symbol(symbol)
if not client_order_id:
raise LiveTradingError("BitgetSpot cancel_order requires client_order_id")
body = {"symbol": sym, "clientOid": str(client_order_id)}
return self._signed_request("POST", "/api/v2/spot/trade/cancel-order", json_body=body)
def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
sym = to_bitget_um_symbol(symbol)
params: Dict[str, Any] = {"symbol": sym}
if order_id:
params["orderId"] = str(order_id)
elif client_order_id:
params["clientOid"] = str(client_order_id)
else:
raise LiveTradingError("BitgetSpot get_order requires order_id or client_order_id")
return self._signed_request("GET", "/api/v2/spot/trade/orderInfo", params=params)
def get_fills(self, *, symbol: str, order_id: str) -> Dict[str, Any]:
sym = to_bitget_um_symbol(symbol)
params: Dict[str, Any] = {"symbol": sym, "orderId": str(order_id)}
return self._signed_request("GET", "/api/v2/spot/trade/fills", params=params)
def wait_for_fill(
self,
*,
symbol: str,
order_id: str,
client_order_id: str = "",
max_wait_sec: float = 10.0,
poll_interval_sec: float = 0.5,
) -> Dict[str, Any]:
end_ts = time.time() + float(max_wait_sec or 0.0)
last_order: Dict[str, Any] = {}
last_fills: Dict[str, Any] = {}
state = ""
while True:
# Prefer fills to compute weighted average if available.
try:
last_fills = self.get_fills(symbol=symbol, order_id=str(order_id))
data = last_fills.get("data") if isinstance(last_fills, dict) else None
fills = data if isinstance(data, list) else []
total_base = 0.0
total_quote = 0.0
if isinstance(fills, list):
for f in fills:
try:
sz = float(f.get("size") or 0.0)
px = float(f.get("priceAvg") or f.get("price") or 0.0)
if sz > 0 and px > 0:
total_base += sz
total_quote += sz * px
except Exception:
continue
if total_base > 0 and total_quote > 0:
return {"filled": total_base, "avg_price": total_quote / total_base, "state": state, "order": last_order, "fills": last_fills}
except Exception:
pass
try:
last_order = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or ""))
od = last_order.get("data") if isinstance(last_order, dict) else None
if isinstance(od, dict):
state = str(od.get("status") or od.get("state") or "")
except Exception:
pass
if time.time() >= end_ts:
return {"filled": 0.0, "avg_price": 0.0, "state": state, "order": last_order, "fills": last_fills}
time.sleep(float(poll_interval_sec or 0.5))
def get_assets(self) -> Dict[str, Any]:
"""
Spot assets/balances.
Endpoint: GET /api/v2/spot/account/assets
"""
return self._signed_request("GET", "/api/v2/spot/account/assets")