Files
DinQuant/backend_api_python/app/services/live_trading/bitget_spot.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

478 lines
19 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):
_CHANNEL_API_CODE_ORDER_PATHS = {
"/api/v2/spot/trade/place-order",
"/api/v2/spot/trade/batch-orders",
"/api/v2/spot/trade/place-plan-order",
"/api/v3/trade/place-order",
"/api/v3/trade/place-batch",
"/api/v3/trade/modify-order",
}
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 = "qvz9x",
):
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, max_decimals: int = 18, strict_precision: Optional[int] = None) -> str:
"""
Convert Decimal to string with controlled precision.
Bitget requires quantities to match quantityStep/quantityScale 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, 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, request_path: 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",
}
clean_path = str(request_path or "").split("?", 1)[0]
if self.channel_api_code and clean_path in self._CHANNEL_API_CODE_ORDER_PATHS:
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, path),
)
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) -> Tuple[Decimal, Optional[int]]:
"""
Normalize spot base size to lot/step constraints (best-effort).
Returns:
Tuple of (normalized_size, precision) where precision is the number of decimal places required.
"""
req = self._to_dec(base_size)
if req <= 0:
return (Decimal("0"), None)
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")
size_precision = None
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)))
size_precision = places
if step > 0:
req = self._floor_to_step(req, step)
# Infer precision from step if not already set
if size_precision is None:
try:
step_normalized = step.normalize()
step_str = str(step_normalized)
if '.' in step_str:
decimal_part = step_str.split('.')[1]
size_precision = len(decimal_part)
if size_precision < 0:
size_precision = 0
if size_precision > 18:
size_precision = 18
else:
size_precision = 0
except Exception:
pass
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"), size_precision)
return (req, size_precision)
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, sz_precision = 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, strict_precision=sz_precision),
"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, sz_precision = 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, strict_precision=sz_precision)
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
total_fee = 0.0
fee_ccy = ""
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
# Best-effort fee extraction (fields vary by endpoint/version).
fee_v = f.get("fee")
if fee_v is None:
fee_v = f.get("fillFee")
if fee_v is None:
fee_v = f.get("tradeFee")
try:
fee = float(fee_v or 0.0)
except Exception:
fee = 0.0
ccy = str(f.get("feeCoin") or f.get("feeCcy") or f.get("fillFeeCoin") or f.get("fillFeeCcy") or "").strip()
if fee != 0.0:
total_fee += abs(float(fee))
if (not fee_ccy) and ccy:
fee_ccy = ccy
except Exception:
continue
if total_base > 0 and total_quote > 0:
return {
"filled": total_base,
"avg_price": total_quote / total_base,
"fee": float(total_fee),
"fee_ccy": str(fee_ccy or ""),
"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")