05f07ee544
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
679 lines
27 KiB
Python
679 lines
27 KiB
Python
"""
|
|
Bitget (direct REST) client for USDT-margined perpetual orders.
|
|
|
|
Signing (Bitget):
|
|
- ACCESS-SIGN = base64(hmac_sha256(secret, timestamp + method + request_path + body))
|
|
"""
|
|
|
|
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 BitgetMixClient(BaseRestClient):
|
|
_CHANNEL_API_CODE_ORDER_PATHS = {
|
|
"/api/v2/mix/order/place-order",
|
|
"/api/v2/mix/order/batch-place-order",
|
|
"/api/v2/mix/order/modify-order",
|
|
"/api/v2/mix/order/place-plan-order",
|
|
"/api/v2/mix/order/place-tpsl-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 contract metadata used to normalize order sizes.
|
|
# Key: f"{product_type}:{symbol}" -> (fetched_at_ts, contract_dict)
|
|
self._contract_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
|
self._contract_cache_ttl_sec = 300.0
|
|
|
|
# Best-effort cache for leverage settings to avoid spamming set-leverage on every tick.
|
|
# Key: f"{product_type}:{symbol}:{margin_coin}:{margin_mode}:{hold_side}:{lever}" -> (fetched_at_ts, True)
|
|
self._lev_cache: Dict[str, Tuple[float, bool]] = {}
|
|
self._lev_cache_ttl_sec = 60.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 sizeStep/sizePlace 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")
|
|
|
|
@staticmethod
|
|
def _normalize_margin_mode(margin_mode: str) -> str:
|
|
"""
|
|
Normalize margin mode for Bitget mix orders.
|
|
|
|
Bitget expects:
|
|
- crossed
|
|
- isolated
|
|
|
|
Our system often uses:
|
|
- cross
|
|
- isolated
|
|
"""
|
|
m = str(margin_mode or "").strip().lower()
|
|
if not m:
|
|
return "crossed"
|
|
if m in ("cross", "crossed"):
|
|
return "crossed"
|
|
if m in ("isolated", "iso"):
|
|
return "isolated"
|
|
return "crossed"
|
|
|
|
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]:
|
|
headers = {
|
|
"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:
|
|
headers["X-CHANNEL-API-CODE"] = self.channel_api_code
|
|
return headers
|
|
|
|
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 is computed over (timestamp + method + request_path + body).
|
|
|
|
- Use `data=<serialized_json>` to ensure the signed body matches the sent body.
|
|
- For GET params, include query string into the signed request path.
|
|
"""
|
|
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"Bitget HTTP {code}: {text[:500]}")
|
|
if isinstance(data, dict):
|
|
# Bitget uses code == "00000" for success in many endpoints.
|
|
c = str(data.get("code") or "")
|
|
if c and c not in ("00000", "0"):
|
|
raise LiveTradingError(f"Bitget 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"Bitget 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"Bitget error: {data}")
|
|
return data if isinstance(data, dict) else {"raw": data}
|
|
|
|
def get_contract(self, *, symbol: str, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
|
|
"""
|
|
Fetch contract metadata (best-effort) from public endpoint.
|
|
|
|
Endpoint (Bitget v2 mix): GET /api/v2/mix/market/contracts
|
|
Params: productType, symbol(optional)
|
|
"""
|
|
sym = to_bitget_um_symbol(symbol)
|
|
pt = str(product_type or "USDT-FUTURES")
|
|
if not sym:
|
|
return {}
|
|
key = f"{pt}:{sym}"
|
|
now = time.time()
|
|
cached = self._contract_cache.get(key)
|
|
if cached:
|
|
ts, obj = cached
|
|
if obj and (now - float(ts or 0.0)) <= float(self._contract_cache_ttl_sec or 300.0):
|
|
return obj
|
|
|
|
raw = self._public_request("GET", "/api/v2/mix/market/contracts", params={"productType": pt, "symbol": sym})
|
|
data = raw.get("data") if isinstance(raw, dict) else None
|
|
items = data if isinstance(data, list) else ([data] if isinstance(data, dict) else [])
|
|
first: Dict[str, Any] = items[0] if isinstance(items, list) and items else {}
|
|
if isinstance(first, dict) and first:
|
|
self._contract_cache[key] = (now, first)
|
|
return first if isinstance(first, dict) else {}
|
|
|
|
def _normalize_size(self, *, symbol: str, product_type: str, base_size: float) -> Tuple[Decimal, Optional[int]]:
|
|
"""
|
|
Normalize Bitget mix order size.
|
|
|
|
This system computes `amount` as base-asset quantity (e.g. BTC amount).
|
|
Bitget mix `size` is typically in contracts; convert using contractSize if available,
|
|
then align to size step / min trade number (best-effort).
|
|
|
|
Returns:
|
|
Tuple of (normalized_size, precision) where precision is the number of decimal places required.
|
|
"""
|
|
req_base = self._to_dec(base_size)
|
|
if req_base <= 0:
|
|
return (Decimal("0"), None)
|
|
|
|
contract: Dict[str, Any] = {}
|
|
try:
|
|
contract = self.get_contract(symbol=symbol, product_type=product_type) or {}
|
|
except Exception:
|
|
contract = {}
|
|
|
|
# Convert base qty -> contracts if contractSize is provided.
|
|
ct = self._to_dec(contract.get("contractSize") or contract.get("contractSz") or contract.get("ctVal") or "0")
|
|
qty = req_base
|
|
if ct > 0:
|
|
qty = req_base / ct
|
|
|
|
# Determine step size.
|
|
step = self._to_dec(contract.get("sizeMultiplier") or contract.get("sizeStep") or contract.get("lotSize") or "0")
|
|
size_precision = None
|
|
if step <= 0:
|
|
sp = contract.get("sizePlace")
|
|
try:
|
|
places = int(sp) if sp 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:
|
|
qty = self._floor_to_step(qty, 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
|
|
|
|
# Enforce min trade number if present.
|
|
mn = self._to_dec(contract.get("minTradeNum") or contract.get("minSize") or contract.get("minQty") or "0")
|
|
if mn > 0 and qty < mn:
|
|
return (Decimal("0"), size_precision)
|
|
return (qty, size_precision)
|
|
|
|
def ping(self) -> bool:
|
|
code, data, _ = self._request("GET", "/api/v2/public/time")
|
|
return code == 200 and isinstance(data, dict)
|
|
|
|
def get_accounts(self, *, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
|
|
"""
|
|
Private endpoint to validate credentials (best-effort).
|
|
"""
|
|
return self._signed_request("GET", "/api/v2/mix/account/accounts", params={"productType": str(product_type or "USDT-FUTURES")})
|
|
|
|
def get_positions(self, *, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
|
|
"""
|
|
Get all positions (best-effort).
|
|
|
|
Endpoint: GET /api/v2/mix/position/all-position
|
|
"""
|
|
return self._signed_request("GET", "/api/v2/mix/position/all-position", params={"productType": str(product_type or "USDT-FUTURES")})
|
|
|
|
def set_leverage(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
leverage: float,
|
|
margin_coin: str = "USDT",
|
|
product_type: str = "USDT-FUTURES",
|
|
margin_mode: str = "crossed",
|
|
hold_side: str = "",
|
|
) -> bool:
|
|
"""
|
|
Best-effort set leverage for Bitget mix.
|
|
|
|
NOTE: Bitget requires leverage configured via a private endpoint; order placement may otherwise use defaults.
|
|
Endpoint (v2 mix): POST /api/v2/mix/account/set-leverage (best-effort).
|
|
"""
|
|
sym = to_bitget_um_symbol(symbol)
|
|
pt = str(product_type or "USDT-FUTURES")
|
|
mc = str(margin_coin or "USDT")
|
|
mm = self._normalize_margin_mode(margin_mode)
|
|
hs = str(hold_side or "").strip().lower()
|
|
try:
|
|
lv = int(float(leverage or 0))
|
|
except Exception:
|
|
lv = 0
|
|
if not sym or lv <= 0:
|
|
return False
|
|
|
|
cache_key = f"{pt}:{sym}:{mc}:{mm}:{hs}:{lv}"
|
|
now = time.time()
|
|
cached = self._lev_cache.get(cache_key)
|
|
if cached:
|
|
ts, ok = cached
|
|
if ok and (now - float(ts or 0.0)) <= float(self._lev_cache_ttl_sec or 60.0):
|
|
return True
|
|
|
|
body: Dict[str, Any] = {
|
|
"symbol": sym,
|
|
"productType": pt,
|
|
"marginCoin": mc,
|
|
"marginMode": mm,
|
|
"leverage": str(lv),
|
|
}
|
|
# Some Bitget accounts require holdSide for hedge mode; keep best-effort.
|
|
if hs in ("long", "short"):
|
|
body["holdSide"] = hs
|
|
|
|
try:
|
|
resp = self._signed_request("POST", "/api/v2/mix/account/set-leverage", json_body=body)
|
|
ok = isinstance(resp, dict) and str(resp.get("code") or "") in ("00000", "0", "")
|
|
if ok:
|
|
self._lev_cache[cache_key] = (now, True)
|
|
return bool(ok)
|
|
except Exception:
|
|
return False
|
|
|
|
def place_market_order(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
side: str,
|
|
size: float,
|
|
margin_coin: str = "USDT",
|
|
product_type: str = "USDT-FUTURES",
|
|
margin_mode: str = "crossed",
|
|
reduce_only: bool = False,
|
|
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)
|
|
sz_dec, sz_precision = self._normalize_size(symbol=symbol, product_type=product_type, base_size=req)
|
|
if float(sz_dec or 0) <= 0:
|
|
raise LiveTradingError(f"Invalid size (below step/min): requested={req}")
|
|
|
|
body: Dict[str, Any] = {
|
|
"symbol": sym,
|
|
"productType": str(product_type or "USDT-FUTURES"),
|
|
"marginCoin": str(margin_coin or "USDT"),
|
|
"marginMode": self._normalize_margin_mode(margin_mode),
|
|
"side": sd,
|
|
"orderType": "market",
|
|
"size": self._dec_str(sz_dec, strict_precision=sz_precision),
|
|
}
|
|
if reduce_only:
|
|
body["reduceOnly"] = "YES"
|
|
if client_order_id:
|
|
body["clientOid"] = str(client_order_id)
|
|
|
|
raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
|
|
data = raw.get("data") if isinstance(raw, dict) else None
|
|
exchange_order_id = ""
|
|
if isinstance(data, dict):
|
|
exchange_order_id = str(data.get("orderId") or data.get("clientOid") or "")
|
|
|
|
return LiveOrderResult(
|
|
exchange_id="bitget",
|
|
exchange_order_id=exchange_order_id,
|
|
filled=0.0,
|
|
avg_price=0.0,
|
|
raw=raw,
|
|
)
|
|
|
|
def place_limit_order(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
side: str,
|
|
size: float,
|
|
price: float,
|
|
margin_coin: str = "USDT",
|
|
product_type: str = "USDT-FUTURES",
|
|
margin_mode: str = "crossed",
|
|
reduce_only: bool = False,
|
|
post_only: bool = False,
|
|
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_size(symbol=symbol, product_type=product_type, base_size=req)
|
|
if float(sz_dec or 0) <= 0:
|
|
raise LiveTradingError(f"Invalid size (below step/min): requested={req}")
|
|
|
|
body: Dict[str, Any] = {
|
|
"symbol": sym,
|
|
"productType": str(product_type or "USDT-FUTURES"),
|
|
"marginCoin": str(margin_coin or "USDT"),
|
|
"marginMode": self._normalize_margin_mode(margin_mode),
|
|
"side": sd,
|
|
"orderType": "limit",
|
|
"price": str(px),
|
|
"size": self._dec_str(sz_dec, strict_precision=sz_precision),
|
|
}
|
|
# Force maker behavior when requested (avoid taker fills).
|
|
if post_only:
|
|
body["force"] = "post_only"
|
|
else:
|
|
body["force"] = "gtc"
|
|
if reduce_only:
|
|
body["reduceOnly"] = "YES"
|
|
if client_order_id:
|
|
body["clientOid"] = str(client_order_id)
|
|
raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
|
|
data = raw.get("data") if isinstance(raw, dict) else None
|
|
exchange_order_id = str(data.get("orderId") or data.get("clientOid") or "") if isinstance(data, dict) else ""
|
|
return LiveOrderResult(exchange_id="bitget", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw)
|
|
|
|
def cancel_order(self, *, symbol: str, product_type: str, margin_coin: str = "USDT", order_id: str = "", client_oid: str = "") -> Dict[str, Any]:
|
|
body: Dict[str, Any] = {
|
|
"symbol": to_bitget_um_symbol(symbol),
|
|
"productType": str(product_type or "USDT-FUTURES"),
|
|
"marginCoin": str(margin_coin or "USDT"),
|
|
}
|
|
if order_id:
|
|
body["orderId"] = str(order_id)
|
|
elif client_oid:
|
|
body["clientOid"] = str(client_oid)
|
|
else:
|
|
raise LiveTradingError("Bitget cancel_order requires order_id or client_oid")
|
|
return self._signed_request("POST", "/api/v2/mix/order/cancel-order", json_body=body)
|
|
|
|
def get_order_detail(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
product_type: str,
|
|
order_id: str = "",
|
|
client_oid: str = "",
|
|
) -> Dict[str, Any]:
|
|
params: Dict[str, Any] = {
|
|
"symbol": to_bitget_um_symbol(symbol),
|
|
"productType": str(product_type or "USDT-FUTURES"),
|
|
}
|
|
if order_id:
|
|
params["orderId"] = str(order_id)
|
|
elif client_oid:
|
|
params["clientOid"] = str(client_oid)
|
|
else:
|
|
raise LiveTradingError("Bitget get_order_detail requires order_id or client_oid")
|
|
return self._signed_request("GET", "/api/v2/mix/order/detail", params=params)
|
|
|
|
def get_order_fills(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
product_type: str,
|
|
order_id: str,
|
|
) -> Dict[str, Any]:
|
|
params: Dict[str, Any] = {
|
|
"orderId": str(order_id),
|
|
"productType": str(product_type or "USDT-FUTURES"),
|
|
"symbol": to_bitget_um_symbol(symbol),
|
|
}
|
|
return self._signed_request("GET", "/api/v2/mix/order/fills", params=params)
|
|
|
|
def wait_for_fill(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
product_type: str = "USDT-FUTURES",
|
|
order_id: str,
|
|
client_oid: str = "",
|
|
max_wait_sec: float = 3.0,
|
|
poll_interval_sec: float = 0.5,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Poll order fills/detail to obtain (best-effort) executed size and average price.
|
|
|
|
Returns:
|
|
{
|
|
"filled": float,
|
|
"avg_price": float,
|
|
"fee": float,
|
|
"fee_ccy": str,
|
|
"state": str,
|
|
"detail": {...},
|
|
"fills": {...}
|
|
}
|
|
"""
|
|
end_ts = time.time() + float(max_wait_sec or 0.0)
|
|
last_detail: Dict[str, Any] = {}
|
|
last_fills: Dict[str, Any] = {}
|
|
state = ""
|
|
|
|
# For robust parsing: contractSize helps converting contracts->base if needed.
|
|
ct = Decimal("0")
|
|
try:
|
|
contract = self.get_contract(symbol=symbol, product_type=product_type) or {}
|
|
ct = self._to_dec(contract.get("contractSize") or contract.get("contractSz") or contract.get("ctVal") or "0")
|
|
except Exception:
|
|
ct = Decimal("0")
|
|
|
|
while True:
|
|
# Prefer fills endpoint to calculate accurate weighted average.
|
|
try:
|
|
last_fills = self.get_order_fills(symbol=symbol, product_type=product_type, order_id=str(order_id))
|
|
data = last_fills.get("data") if isinstance(last_fills, dict) else None
|
|
fill_list = []
|
|
if isinstance(data, dict):
|
|
fill_list = data.get("fillList") or []
|
|
total_base = Decimal("0")
|
|
total_quote = Decimal("0")
|
|
total_fee = Decimal("0")
|
|
fee_ccy = ""
|
|
if isinstance(fill_list, list):
|
|
for f in fill_list:
|
|
try:
|
|
# Bitget fills may provide either baseVolume or size.
|
|
# Our system standardizes on base-asset quantity.
|
|
sz_base = self._to_dec(f.get("baseVolume") or "0")
|
|
if sz_base <= 0:
|
|
sz_contracts = self._to_dec(f.get("size") or f.get("fillSize") or "0")
|
|
if sz_contracts > 0 and ct > 0:
|
|
sz_base = sz_contracts * ct
|
|
px = self._to_dec(f.get("fillPrice") or f.get("price") or "0")
|
|
|
|
fee_v = f.get("fee")
|
|
if fee_v is None:
|
|
fee_v = f.get("fillFee")
|
|
fee = self._to_dec(fee_v or "0")
|
|
ccy = str(f.get("feeCoin") or f.get("feeCcy") or f.get("fillFeeCoin") or "").strip()
|
|
|
|
if sz_base > 0 and px > 0:
|
|
total_base += sz_base
|
|
total_quote += sz_base * px
|
|
if fee != 0:
|
|
# Fees may be negative; store absolute cost.
|
|
total_fee += abs(fee)
|
|
if (not fee_ccy) and ccy:
|
|
fee_ccy = ccy
|
|
except Exception:
|
|
continue
|
|
if total_base > 0 and total_quote > 0:
|
|
return {
|
|
"filled": float(total_base),
|
|
"avg_price": float(total_quote / total_base),
|
|
"fee": float(total_fee),
|
|
"fee_ccy": str(fee_ccy or ""),
|
|
"state": state,
|
|
"detail": last_detail,
|
|
"fills": last_fills,
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
# Fall back to order detail (state + sometimes avg/filled fields).
|
|
try:
|
|
last_detail = self.get_order_detail(
|
|
symbol=symbol,
|
|
product_type=product_type,
|
|
order_id=str(order_id or ""),
|
|
client_oid=str(client_oid or ""),
|
|
)
|
|
d = last_detail.get("data") if isinstance(last_detail, dict) else None
|
|
if isinstance(d, dict):
|
|
state = str(d.get("state") or d.get("status") or "")
|
|
avg = float(d.get("priceAvg") or d.get("fillPrice") or 0.0) if (d.get("priceAvg") or d.get("fillPrice")) else 0.0
|
|
filled = float(d.get("baseVolume") or d.get("filledQty") or 0.0) if (d.get("baseVolume") or d.get("filledQty")) else 0.0
|
|
if filled > 0 and avg > 0:
|
|
return {"filled": filled, "avg_price": avg, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills}
|
|
if state in ("filled", "canceled", "cancelled"):
|
|
return {"filled": filled, "avg_price": avg, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills}
|
|
except Exception:
|
|
pass
|
|
|
|
if time.time() >= end_ts:
|
|
return {"filled": 0.0, "avg_price": 0.0, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills}
|
|
time.sleep(float(poll_interval_sec or 0.5))
|
|
|
|
|