@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Live trading (direct exchange REST) clients.
|
||||
|
||||
This package intentionally does NOT use ccxt.
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Base REST client helpers for direct exchange connections.
|
||||
|
||||
Notes:
|
||||
- Keep this minimal and dependency-light (requests only).
|
||||
- All secrets must be excluded from logs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveOrderResult:
|
||||
exchange_id: str
|
||||
exchange_order_id: str
|
||||
filled: float
|
||||
avg_price: float
|
||||
raw: Dict[str, Any]
|
||||
|
||||
|
||||
class LiveTradingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BaseRestClient:
|
||||
def __init__(self, base_url: str, timeout_sec: float = 15.0):
|
||||
self.base_url = (base_url or "").rstrip("/")
|
||||
self.timeout_sec = float(timeout_sec)
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
p = str(path or "")
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
return f"{self.base_url}{p}"
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
data: Optional[Any] = None,
|
||||
) -> Tuple[int, Dict[str, Any], str]:
|
||||
url = self._url(path)
|
||||
resp = requests.request(
|
||||
method=str(method or "GET").upper(),
|
||||
url=url,
|
||||
params=params or None,
|
||||
json=json_body if json_body is not None else None,
|
||||
data=data,
|
||||
headers=headers or None,
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
text = resp.text or ""
|
||||
parsed: Dict[str, Any] = {}
|
||||
try:
|
||||
parsed = resp.json() if text else {}
|
||||
except Exception:
|
||||
parsed = {"raw_text": text[:2000]}
|
||||
return int(resp.status_code), parsed, text
|
||||
|
||||
@staticmethod
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
@staticmethod
|
||||
def _json_dumps(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
"""
|
||||
Binance USDT-M Futures (direct REST) client.
|
||||
|
||||
API docs (reference):
|
||||
- Signed endpoints use HMAC SHA256 over query string.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
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_binance_futures_symbol
|
||||
|
||||
|
||||
class BinanceFuturesClient(BaseRestClient):
|
||||
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://fapi.binance.com", timeout_sec: float = 15.0):
|
||||
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.secret_key = (secret_key or "").strip()
|
||||
if not self.api_key or not self.secret_key:
|
||||
raise LiveTradingError("Missing Binance api_key/secret_key")
|
||||
|
||||
# Best-effort cache for public symbol filters used to normalize quantities.
|
||||
# Key: symbol -> (fetched_at_ts, filters_dict)
|
||||
self._sym_filter_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._sym_filter_cache_ttl_sec = 300.0
|
||||
|
||||
# Best-effort cache for account position mode (Hedge vs One-way).
|
||||
# Binance endpoint: GET /fapi/v1/positionSide/dual -> {"dualSidePosition": true/false}
|
||||
self._dual_side_cache: Optional[Tuple[float, bool]] = None
|
||||
self._dual_side_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) -> 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, query_string: str) -> str:
|
||||
sig = hmac.new(self.secret_key.encode("utf-8"), query_string.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
return sig
|
||||
|
||||
def _signed_headers(self) -> Dict[str, str]:
|
||||
return {"X-MBX-APIKEY": self.api_key}
|
||||
|
||||
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
p = dict(params or {})
|
||||
# Use server-accepted timestamp in ms.
|
||||
p["timestamp"] = int(time.time() * 1000)
|
||||
qs = urlencode(p, doseq=True)
|
||||
p["signature"] = self._sign(qs)
|
||||
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Binance HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
|
||||
raise LiveTradingError(f"Binance 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"Binance HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
|
||||
raise LiveTradingError(f"Binance error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def get_mark_price(self, *, symbol: str) -> float:
|
||||
"""
|
||||
Best-effort mark price for MIN_NOTIONAL validation.
|
||||
|
||||
Endpoint: GET /fapi/v1/premiumIndex?symbol=...
|
||||
"""
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
if not sym:
|
||||
return 0.0
|
||||
try:
|
||||
data = self._public_request("GET", "/fapi/v1/premiumIndex", params={"symbol": sym})
|
||||
except Exception:
|
||||
return 0.0
|
||||
try:
|
||||
return float(data.get("markPrice") or 0.0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def get_symbol_filters(self, *, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get futures symbol filters from exchangeInfo (best-effort).
|
||||
|
||||
Endpoint: GET /fapi/v1/exchangeInfo?symbol=...
|
||||
"""
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
if not sym:
|
||||
return {}
|
||||
now = time.time()
|
||||
cached = self._sym_filter_cache.get(sym)
|
||||
if cached:
|
||||
ts, obj = cached
|
||||
if obj and (now - float(ts or 0.0)) <= float(self._sym_filter_cache_ttl_sec or 300.0):
|
||||
return obj
|
||||
|
||||
raw = self._public_request("GET", "/fapi/v1/exchangeInfo", params={"symbol": sym})
|
||||
symbols = raw.get("symbols") if isinstance(raw, dict) else None
|
||||
# Important: Binance may still return the full symbols list even when `symbol=...` is provided.
|
||||
# Never assume `symbols[0]` matches the requested symbol.
|
||||
first: Dict[str, Any] = {}
|
||||
if isinstance(symbols, list) and symbols:
|
||||
picked = None
|
||||
try:
|
||||
picked = next((s for s in symbols if isinstance(s, dict) and str(s.get("symbol") or "") == sym), None)
|
||||
except Exception:
|
||||
picked = None
|
||||
first = picked if isinstance(picked, dict) else (symbols[0] if isinstance(symbols[0], dict) else {})
|
||||
filters = first.get("filters") if isinstance(first, dict) else None
|
||||
fdict: Dict[str, Any] = {}
|
||||
if isinstance(filters, list):
|
||||
for f in filters:
|
||||
if isinstance(f, dict) and f.get("filterType"):
|
||||
fdict[str(f.get("filterType"))] = f
|
||||
# Also keep precision metadata when available (used to avoid -1111).
|
||||
try:
|
||||
qty_prec = first.get("quantityPrecision") if isinstance(first, dict) else None
|
||||
price_prec = first.get("pricePrecision") if isinstance(first, dict) else None
|
||||
meta = {
|
||||
"symbol": str(first.get("symbol") or "") if isinstance(first, dict) else "",
|
||||
"contractType": str(first.get("contractType") or "") if isinstance(first, dict) else "",
|
||||
"quantityPrecision": int(qty_prec) if qty_prec is not None else None,
|
||||
"pricePrecision": int(price_prec) if price_prec is not None else None,
|
||||
}
|
||||
fdict["_meta"] = meta
|
||||
except Exception:
|
||||
pass
|
||||
if fdict:
|
||||
self._sym_filter_cache[sym] = (now, fdict)
|
||||
return fdict
|
||||
|
||||
@staticmethod
|
||||
def _floor_to_precision(value: Decimal, precision: Optional[int]) -> Decimal:
|
||||
try:
|
||||
if precision is None:
|
||||
return value
|
||||
p = int(precision)
|
||||
except Exception:
|
||||
return value
|
||||
if p < 0 or p > 18:
|
||||
return value
|
||||
try:
|
||||
q = Decimal("1").scaleb(-p) # 1e-precision
|
||||
return value.quantize(q, rounding=ROUND_DOWN)
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
def _normalize_price(self, *, symbol: str, price: float) -> Decimal:
|
||||
"""
|
||||
Normalize futures limit price using PRICE_FILTER tickSize (best-effort).
|
||||
|
||||
Binance rejects prices/quantities whose precision exceeds allowed decimals (-1111),
|
||||
so we must quantize to tickSize and send as string.
|
||||
"""
|
||||
px = self._to_dec(price)
|
||||
if px <= 0:
|
||||
return Decimal("0")
|
||||
fdict: Dict[str, Any] = {}
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
except Exception:
|
||||
fdict = {}
|
||||
|
||||
filt = fdict.get("PRICE_FILTER") or {}
|
||||
tick = self._to_dec((filt or {}).get("tickSize") or "0")
|
||||
min_px = self._to_dec((filt or {}).get("minPrice") or "0")
|
||||
|
||||
if tick > 0:
|
||||
px = self._floor_to_step(px, tick)
|
||||
# Enforce price precision cap (some symbols reject more decimals even if tick looks permissive).
|
||||
try:
|
||||
meta = fdict.get("_meta") or {}
|
||||
px = self._floor_to_precision(px, (meta.get("pricePrecision") if isinstance(meta, dict) else None))
|
||||
except Exception:
|
||||
pass
|
||||
if min_px > 0 and px < min_px:
|
||||
return Decimal("0")
|
||||
return px
|
||||
|
||||
def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Decimal:
|
||||
"""
|
||||
Normalize futures order quantity using LOT_SIZE / MARKET_LOT_SIZE filters (best-effort).
|
||||
"""
|
||||
q = self._to_dec(quantity)
|
||||
if q <= 0:
|
||||
return Decimal("0")
|
||||
fdict: Dict[str, Any] = {}
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
except Exception:
|
||||
fdict = {}
|
||||
|
||||
key = "MARKET_LOT_SIZE" if for_market else "LOT_SIZE"
|
||||
filt = fdict.get(key) or fdict.get("LOT_SIZE") or {}
|
||||
|
||||
step = self._to_dec((filt or {}).get("stepSize") or "0")
|
||||
min_qty = self._to_dec((filt or {}).get("minQty") or "0")
|
||||
|
||||
if step > 0:
|
||||
q = self._floor_to_step(q, step)
|
||||
# Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111).
|
||||
try:
|
||||
meta = fdict.get("_meta") or {}
|
||||
q = self._floor_to_precision(q, (meta.get("quantityPrecision") if isinstance(meta, dict) else None))
|
||||
except Exception:
|
||||
pass
|
||||
if min_qty > 0 and q < min_qty:
|
||||
return Decimal("0")
|
||||
return q
|
||||
|
||||
def ping(self) -> bool:
|
||||
code, data, _ = self._request("GET", "/fapi/v1/time")
|
||||
return code == 200 and isinstance(data, dict)
|
||||
|
||||
def get_account(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Private endpoint to validate credentials.
|
||||
"""
|
||||
return self._signed_request("GET", "/fapi/v2/account", params={})
|
||||
|
||||
def get_dual_side_position(self) -> Optional[bool]:
|
||||
"""
|
||||
Best-effort read of position mode:
|
||||
- True => Hedge Mode (dual-side position enabled): orders must specify positionSide=LONG/SHORT
|
||||
- False => One-way Mode: orders should NOT specify LONG/SHORT
|
||||
|
||||
Endpoint: GET /fapi/v1/positionSide/dual
|
||||
"""
|
||||
now = time.time()
|
||||
cached = self._dual_side_cache
|
||||
if cached:
|
||||
ts, val = cached
|
||||
if (now - float(ts or 0.0)) <= float(self._dual_side_cache_ttl_sec or 60.0):
|
||||
return bool(val)
|
||||
try:
|
||||
data = self._signed_request("GET", "/fapi/v1/positionSide/dual", params={})
|
||||
v = data.get("dualSidePosition") if isinstance(data, dict) else None
|
||||
if v is None:
|
||||
return None
|
||||
val = bool(v)
|
||||
self._dual_side_cache = (now, val)
|
||||
return val
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_err_code(err: Exception, code: int) -> bool:
|
||||
try:
|
||||
s = str(err or "")
|
||||
except Exception:
|
||||
s = ""
|
||||
return f'\"code\":{int(code)}' in s or f"'code': {int(code)}" in s or f"'code':{int(code)}" in s
|
||||
|
||||
@staticmethod
|
||||
def _normalize_position_side(pos_side: Optional[str]) -> str:
|
||||
p = (pos_side or "").strip().lower()
|
||||
if p in ("long", "l"):
|
||||
return "LONG"
|
||||
if p in ("short", "s"):
|
||||
return "SHORT"
|
||||
if p in ("both", "net"):
|
||||
return "BOTH"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _infer_position_side(*, side: str, reduce_only: bool) -> str:
|
||||
sd = (side or "").strip().upper()
|
||||
ro = bool(reduce_only)
|
||||
# Open:
|
||||
# - BUY => LONG
|
||||
# - SELL => SHORT
|
||||
# Reduce/Close:
|
||||
# - SELL reduceOnly => close LONG
|
||||
# - BUY reduceOnly => close SHORT
|
||||
if ro:
|
||||
return "LONG" if sd == "SELL" else "SHORT"
|
||||
return "LONG" if sd == "BUY" else "SHORT"
|
||||
|
||||
def get_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
order_id: str = "",
|
||||
client_order_id: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Query order status/details.
|
||||
|
||||
Endpoint: GET /fapi/v1/order
|
||||
"""
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
params: Dict[str, Any] = {"symbol": sym}
|
||||
if order_id:
|
||||
params["orderId"] = str(order_id)
|
||||
elif client_order_id:
|
||||
params["origClientOrderId"] = str(client_order_id)
|
||||
else:
|
||||
raise LiveTradingError("Binance get_order requires order_id or client_order_id")
|
||||
return self._signed_request("GET", "/fapi/v1/order", params=params)
|
||||
|
||||
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]:
|
||||
"""
|
||||
Poll order detail to obtain (best-effort) executed quantity and average price.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"filled": float,
|
||||
"avg_price": float,
|
||||
"status": str,
|
||||
"order": {...}
|
||||
}
|
||||
"""
|
||||
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("status") or "")
|
||||
try:
|
||||
filled = float(last.get("executedQty") or 0.0)
|
||||
except Exception:
|
||||
filled = 0.0
|
||||
|
||||
# Futures order endpoint usually provides avgPrice; fall back to price/cumQuote.
|
||||
avg_price = 0.0
|
||||
try:
|
||||
if last.get("avgPrice") is not None and str(last.get("avgPrice")).strip() != "":
|
||||
avg_price = float(last.get("avgPrice") or 0.0)
|
||||
except Exception:
|
||||
avg_price = 0.0
|
||||
if avg_price <= 0 and filled > 0:
|
||||
try:
|
||||
cum_quote = float(last.get("cumQuote") or 0.0)
|
||||
if cum_quote > 0:
|
||||
avg_price = cum_quote / filled
|
||||
except Exception:
|
||||
pass
|
||||
if avg_price <= 0:
|
||||
try:
|
||||
avg_price = float(last.get("price") or 0.0)
|
||||
except Exception:
|
||||
avg_price = 0.0
|
||||
|
||||
if filled > 0 and avg_price > 0:
|
||||
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
|
||||
|
||||
if status in ("FILLED", "CANCELED", "EXPIRED", "REJECTED"):
|
||||
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
|
||||
|
||||
if time.time() >= end_ts:
|
||||
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
|
||||
time.sleep(float(poll_interval_sec or 0.5))
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
reduce_only: bool = False,
|
||||
position_side: Optional[str] = None,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
sd = (side or "").upper()
|
||||
if sd not in ("BUY", "SELL"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
q_req = float(quantity or 0.0)
|
||||
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=True)
|
||||
if float(q_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
|
||||
|
||||
# Best-effort MIN_NOTIONAL validation (common reason for "open still fails" with small qty).
|
||||
# Use markPrice as an approximation for MARKET order notional.
|
||||
min_notional = Decimal("0")
|
||||
mark_price = 0.0
|
||||
notional = Decimal("0")
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
mn = (fdict.get("MIN_NOTIONAL") or {}).get("notional")
|
||||
min_notional = self._to_dec(mn or "0")
|
||||
if min_notional > 0:
|
||||
mark_price = float(self.get_mark_price(symbol=symbol) or 0.0)
|
||||
if mark_price > 0:
|
||||
notional = q_dec * self._to_dec(mark_price)
|
||||
if notional < min_notional:
|
||||
raise LiveTradingError(
|
||||
"Order notional is below MIN_NOTIONAL. "
|
||||
f"symbol={sym} side={sd} qty={self._dec_str(q_dec)} "
|
||||
f"markPrice={mark_price} notional={self._dec_str(notional)} "
|
||||
f"minNotional={self._dec_str(min_notional)}"
|
||||
)
|
||||
except LiveTradingError:
|
||||
raise
|
||||
except Exception:
|
||||
# Never block order placement due to a best-effort validation failure.
|
||||
pass
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"symbol": sym,
|
||||
"side": sd,
|
||||
"type": "MARKET",
|
||||
"quantity": self._dec_str(q_dec),
|
||||
}
|
||||
if reduce_only:
|
||||
params["reduceOnly"] = "true"
|
||||
if client_order_id:
|
||||
params["newClientOrderId"] = str(client_order_id)
|
||||
|
||||
# Hedge mode requires explicit positionSide (LONG/SHORT). One-way mode should not use LONG/SHORT.
|
||||
dual_side = self.get_dual_side_position()
|
||||
pos_norm = self._normalize_position_side(position_side)
|
||||
if dual_side is True:
|
||||
params["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
|
||||
elif dual_side is False:
|
||||
# Keep default (BOTH) by omitting positionSide.
|
||||
params.pop("positionSide", None)
|
||||
else:
|
||||
# Unknown mode: try without positionSide first; we may retry on -4061.
|
||||
params.pop("positionSide", None)
|
||||
|
||||
try:
|
||||
raw = self._signed_request("POST", "/fapi/v1/order", params=params)
|
||||
except LiveTradingError as e:
|
||||
# Retry once if position mode mismatch (-4061).
|
||||
if self._is_err_code(e, -4061):
|
||||
params2 = dict(params)
|
||||
if params2.get("positionSide"):
|
||||
# Likely one-way mode but we sent LONG/SHORT
|
||||
params2.pop("positionSide", None)
|
||||
try:
|
||||
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
|
||||
# Cache for future calls.
|
||||
self._dual_side_cache = (time.time(), False)
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
|
||||
filled=float(raw.get("executedQty") or 0.0),
|
||||
avg_price=float(raw.get("avgPrice") or raw.get("price") or 0.0),
|
||||
raw=raw,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Likely hedge mode; retry with inferred positionSide.
|
||||
params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
|
||||
try:
|
||||
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
|
||||
self._dual_side_cache = (time.time(), True)
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
|
||||
filled=float(raw.get("executedQty") or 0.0),
|
||||
avg_price=float(raw.get("avgPrice") or raw.get("price") or 0.0),
|
||||
raw=raw,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Attach normalized params for easier debugging of precision issues (-1111).
|
||||
# Also attach best-effort public filters and minNotional diagnostics.
|
||||
step = "n/a"
|
||||
qty_prec = "n/a"
|
||||
min_not = "n/a"
|
||||
filt_symbol = "n/a"
|
||||
contract_type = "n/a"
|
||||
dual_mode = "n/a"
|
||||
pos_side_used = "n/a"
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
lot = fdict.get("MARKET_LOT_SIZE") or fdict.get("LOT_SIZE") or {}
|
||||
step = str(lot.get("stepSize") or "n/a")
|
||||
meta = fdict.get("_meta") or {}
|
||||
if isinstance(meta, dict) and meta.get("quantityPrecision") is not None:
|
||||
qty_prec = str(meta.get("quantityPrecision"))
|
||||
if isinstance(meta, dict) and meta.get("symbol"):
|
||||
filt_symbol = str(meta.get("symbol"))
|
||||
if isinstance(meta, dict) and meta.get("contractType"):
|
||||
contract_type = str(meta.get("contractType"))
|
||||
mn = fdict.get("MIN_NOTIONAL") or {}
|
||||
min_not = str(mn.get("notional") or "n/a")
|
||||
dm = self.get_dual_side_position()
|
||||
dual_mode = "true" if dm is True else ("false" if dm is False else "unknown")
|
||||
pos_side_used = str((params or {}).get("positionSide") or "n/a")
|
||||
except Exception:
|
||||
pass
|
||||
raise LiveTradingError(
|
||||
f"{e} | debug: symbol={sym} side={sd} "
|
||||
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} "
|
||||
f"base_url={self.base_url} filtersSymbol={filt_symbol} contractType={contract_type} "
|
||||
f"stepSize={step} quantityPrecision={qty_prec} minNotional={min_not} "
|
||||
f"dualSidePosition={dual_mode} positionSide={pos_side_used} "
|
||||
f"markPrice={mark_price} notional={self._dec_str(notional)}"
|
||||
)
|
||||
|
||||
# Best-effort parse fill info.
|
||||
exchange_order_id = str(raw.get("orderId") or raw.get("clientOrderId") or "")
|
||||
filled = float(raw.get("executedQty") or 0.0)
|
||||
avg_price = float(raw.get("avgPrice") or raw.get("price") or 0.0)
|
||||
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=exchange_order_id,
|
||||
filled=filled,
|
||||
avg_price=avg_price,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def place_limit_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
price: float,
|
||||
reduce_only: bool = False,
|
||||
position_side: Optional[str] = None,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
sd = (side or "").upper()
|
||||
if sd not in ("BUY", "SELL"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
q_req = float(quantity or 0.0)
|
||||
px = float(price or 0.0)
|
||||
if q_req <= 0 or px <= 0:
|
||||
raise LiveTradingError("Invalid quantity/price")
|
||||
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=False)
|
||||
if float(q_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
|
||||
px_dec = self._normalize_price(symbol=symbol, price=px)
|
||||
if float(px_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid price (bad tick/minPrice): requested={px}")
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"symbol": sym,
|
||||
"side": sd,
|
||||
"type": "LIMIT",
|
||||
"timeInForce": "GTC",
|
||||
"quantity": self._dec_str(q_dec),
|
||||
"price": self._dec_str(px_dec),
|
||||
}
|
||||
if reduce_only:
|
||||
params["reduceOnly"] = "true"
|
||||
if client_order_id:
|
||||
params["newClientOrderId"] = str(client_order_id)
|
||||
|
||||
dual_side = self.get_dual_side_position()
|
||||
pos_norm = self._normalize_position_side(position_side)
|
||||
if dual_side is True:
|
||||
params["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
|
||||
elif dual_side is False:
|
||||
params.pop("positionSide", None)
|
||||
else:
|
||||
params.pop("positionSide", None)
|
||||
try:
|
||||
raw = self._signed_request("POST", "/fapi/v1/order", params=params)
|
||||
except LiveTradingError as e:
|
||||
if self._is_err_code(e, -4061):
|
||||
params2 = dict(params)
|
||||
if params2.get("positionSide"):
|
||||
params2.pop("positionSide", None)
|
||||
try:
|
||||
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
|
||||
self._dual_side_cache = (time.time(), False)
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
|
||||
filled=float(raw.get("executedQty") or 0.0),
|
||||
avg_price=float(raw.get("avgPrice") or raw.get("price") or 0.0),
|
||||
raw=raw,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
|
||||
try:
|
||||
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
|
||||
self._dual_side_cache = (time.time(), True)
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
|
||||
filled=float(raw.get("executedQty") or 0.0),
|
||||
avg_price=float(raw.get("avgPrice") or raw.get("price") or 0.0),
|
||||
raw=raw,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise LiveTradingError(
|
||||
f"{e} | debug: symbol={sym} side={sd} "
|
||||
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} "
|
||||
f"price_req={px} price_norm={self._dec_str(px_dec)}"
|
||||
)
|
||||
exchange_order_id = str(raw.get("orderId") or raw.get("clientOrderId") or "")
|
||||
filled = float(raw.get("executedQty") or 0.0)
|
||||
avg_price = float(raw.get("avgPrice") or raw.get("price") or 0.0)
|
||||
return LiveOrderResult(exchange_id="binance", exchange_order_id=exchange_order_id, filled=filled, avg_price=avg_price, raw=raw)
|
||||
|
||||
def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
params: Dict[str, Any] = {"symbol": sym}
|
||||
if order_id:
|
||||
params["orderId"] = str(order_id)
|
||||
elif client_order_id:
|
||||
params["origClientOrderId"] = str(client_order_id)
|
||||
else:
|
||||
raise LiveTradingError("Binance cancel_order requires order_id or client_order_id")
|
||||
return self._signed_request("DELETE", "/fapi/v1/order", params=params)
|
||||
|
||||
def get_positions(self) -> Any:
|
||||
"""
|
||||
Return all futures positions (position risk endpoint).
|
||||
|
||||
Endpoint: GET /fapi/v2/positionRisk
|
||||
"""
|
||||
return self._signed_request("GET", "/fapi/v2/positionRisk", params={})
|
||||
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Binance Spot (direct REST) client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
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_binance_futures_symbol
|
||||
|
||||
|
||||
class BinanceSpotClient(BaseRestClient):
|
||||
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.binance.com", timeout_sec: float = 15.0):
|
||||
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.secret_key = (secret_key or "").strip()
|
||||
if not self.api_key or not self.secret_key:
|
||||
raise LiveTradingError("Missing Binance api_key/secret_key")
|
||||
|
||||
# Best-effort cache for public symbol filters used to normalize quantities.
|
||||
self._sym_filter_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._sym_filter_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, query_string: str) -> str:
|
||||
return hmac.new(self.secret_key.encode("utf-8"), query_string.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
def _signed_headers(self) -> Dict[str, str]:
|
||||
return {"X-MBX-APIKEY": self.api_key}
|
||||
|
||||
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
p = dict(params or {})
|
||||
p["timestamp"] = int(time.time() * 1000)
|
||||
qs = urlencode(p, doseq=True)
|
||||
p["signature"] = self._sign(qs)
|
||||
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
|
||||
raise LiveTradingError(f"BinanceSpot error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""
|
||||
Public connectivity check.
|
||||
|
||||
Endpoint: GET /api/v3/time
|
||||
"""
|
||||
code, data, _ = self._request("GET", "/api/v3/time")
|
||||
return code == 200 and isinstance(data, dict)
|
||||
|
||||
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"BinanceSpot HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
|
||||
raise LiveTradingError(f"BinanceSpot error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def get_symbol_filters(self, *, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get spot symbol filters from exchangeInfo (best-effort).
|
||||
|
||||
Endpoint: GET /api/v3/exchangeInfo?symbol=...
|
||||
"""
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
if not sym:
|
||||
return {}
|
||||
now = time.time()
|
||||
cached = self._sym_filter_cache.get(sym)
|
||||
if cached:
|
||||
ts, obj = cached
|
||||
if obj and (now - float(ts or 0.0)) <= float(self._sym_filter_cache_ttl_sec or 300.0):
|
||||
return obj
|
||||
|
||||
raw = self._public_request("GET", "/api/v3/exchangeInfo", params={"symbol": sym})
|
||||
symbols = raw.get("symbols") if isinstance(raw, dict) else None
|
||||
# Defensive: some gateways/proxies may strip query params; Binance may then return full list.
|
||||
first: Dict[str, Any] = {}
|
||||
if isinstance(symbols, list) and symbols:
|
||||
picked = None
|
||||
try:
|
||||
picked = next((s for s in symbols if isinstance(s, dict) and str(s.get("symbol") or "") == sym), None)
|
||||
except Exception:
|
||||
picked = None
|
||||
first = picked if isinstance(picked, dict) else (symbols[0] if isinstance(symbols[0], dict) else {})
|
||||
filters = first.get("filters") if isinstance(first, dict) else None
|
||||
fdict: Dict[str, Any] = {}
|
||||
if isinstance(filters, list):
|
||||
for f in filters:
|
||||
if isinstance(f, dict) and f.get("filterType"):
|
||||
fdict[str(f.get("filterType"))] = f
|
||||
# Also keep precision metadata when available (used to avoid -1111).
|
||||
try:
|
||||
qty_prec = first.get("baseAssetPrecision") if isinstance(first, dict) else None
|
||||
# For spot, price precision is typically quotePrecision/quoteAssetPrecision.
|
||||
price_prec = None
|
||||
if isinstance(first, dict):
|
||||
price_prec = first.get("quotePrecision")
|
||||
if price_prec is None:
|
||||
price_prec = first.get("quoteAssetPrecision")
|
||||
meta = {
|
||||
"symbol": str(first.get("symbol") or "") if isinstance(first, dict) else "",
|
||||
"quantityPrecision": int(qty_prec) if qty_prec is not None else None,
|
||||
"pricePrecision": int(price_prec) if price_prec is not None else None,
|
||||
}
|
||||
fdict["_meta"] = meta
|
||||
except Exception:
|
||||
pass
|
||||
if fdict:
|
||||
self._sym_filter_cache[sym] = (now, fdict)
|
||||
return fdict
|
||||
|
||||
@staticmethod
|
||||
def _floor_to_precision(value: Decimal, precision: Optional[int]) -> Decimal:
|
||||
try:
|
||||
if precision is None:
|
||||
return value
|
||||
p = int(precision)
|
||||
except Exception:
|
||||
return value
|
||||
if p < 0 or p > 18:
|
||||
return value
|
||||
try:
|
||||
q = Decimal("1").scaleb(-p)
|
||||
return value.quantize(q, rounding=ROUND_DOWN)
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
def _normalize_price(self, *, symbol: str, price: float) -> Decimal:
|
||||
"""
|
||||
Normalize spot limit price using PRICE_FILTER tickSize (best-effort).
|
||||
"""
|
||||
px = self._to_dec(price)
|
||||
if px <= 0:
|
||||
return Decimal("0")
|
||||
fdict: Dict[str, Any] = {}
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
except Exception:
|
||||
fdict = {}
|
||||
|
||||
filt = fdict.get("PRICE_FILTER") or {}
|
||||
tick = self._to_dec((filt or {}).get("tickSize") or "0")
|
||||
min_px = self._to_dec((filt or {}).get("minPrice") or "0")
|
||||
|
||||
if tick > 0:
|
||||
px = self._floor_to_step(px, tick)
|
||||
# Enforce price precision cap (some symbols reject more decimals even if tick looks permissive).
|
||||
try:
|
||||
meta = fdict.get("_meta") or {}
|
||||
px = self._floor_to_precision(px, (meta.get("pricePrecision") if isinstance(meta, dict) else None))
|
||||
except Exception:
|
||||
pass
|
||||
if min_px > 0 and px < min_px:
|
||||
return Decimal("0")
|
||||
return px
|
||||
|
||||
def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Decimal:
|
||||
"""
|
||||
Normalize spot order quantity using LOT_SIZE / MARKET_LOT_SIZE filters (best-effort).
|
||||
"""
|
||||
q = self._to_dec(quantity)
|
||||
if q <= 0:
|
||||
return Decimal("0")
|
||||
fdict: Dict[str, Any] = {}
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
except Exception:
|
||||
fdict = {}
|
||||
|
||||
key = "MARKET_LOT_SIZE" if for_market else "LOT_SIZE"
|
||||
filt = fdict.get(key) or fdict.get("LOT_SIZE") or {}
|
||||
|
||||
step = self._to_dec((filt or {}).get("stepSize") or "0")
|
||||
min_qty = self._to_dec((filt or {}).get("minQty") or "0")
|
||||
|
||||
if step > 0:
|
||||
q = self._floor_to_step(q, step)
|
||||
# Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111).
|
||||
try:
|
||||
meta = fdict.get("_meta") or {}
|
||||
q = self._floor_to_precision(q, (meta.get("quantityPrecision") if isinstance(meta, dict) else None))
|
||||
except Exception:
|
||||
pass
|
||||
if min_qty > 0 and q < min_qty:
|
||||
return Decimal("0")
|
||||
return q
|
||||
|
||||
def place_limit_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
price: float,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
sd = (side or "").upper()
|
||||
if sd not in ("BUY", "SELL"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
q_req = float(quantity or 0.0)
|
||||
px = float(price or 0.0)
|
||||
if q_req <= 0 or px <= 0:
|
||||
raise LiveTradingError("Invalid quantity/price")
|
||||
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=False)
|
||||
if float(q_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
|
||||
px_dec = self._normalize_price(symbol=symbol, price=px)
|
||||
if float(px_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid price (bad tick/minPrice): requested={px}")
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"symbol": sym,
|
||||
"side": sd,
|
||||
"type": "LIMIT",
|
||||
"timeInForce": "GTC",
|
||||
"quantity": self._dec_str(q_dec),
|
||||
"price": self._dec_str(px_dec),
|
||||
}
|
||||
if client_order_id:
|
||||
params["newClientOrderId"] = str(client_order_id)
|
||||
try:
|
||||
raw = self._signed_request("POST", "/api/v3/order", params=params)
|
||||
except LiveTradingError as e:
|
||||
raise LiveTradingError(
|
||||
f"{e} | debug: symbol={sym} side={sd} "
|
||||
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} "
|
||||
f"price_req={px} price_norm={self._dec_str(px_dec)}"
|
||||
)
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
|
||||
filled=float(raw.get("executedQty") or 0.0),
|
||||
avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0) if float(raw.get("executedQty") or 0.0) > 0 else 0.0,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
sd = (side or "").upper()
|
||||
if sd not in ("BUY", "SELL"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
q_req = float(quantity or 0.0)
|
||||
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=True)
|
||||
if float(q_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"symbol": sym,
|
||||
"side": sd,
|
||||
"type": "MARKET",
|
||||
"quantity": self._dec_str(q_dec),
|
||||
}
|
||||
if client_order_id:
|
||||
params["newClientOrderId"] = str(client_order_id)
|
||||
try:
|
||||
raw = self._signed_request("POST", "/api/v3/order", params=params)
|
||||
except LiveTradingError as e:
|
||||
raise LiveTradingError(
|
||||
f"{e} | debug: symbol={sym} side={sd} "
|
||||
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)}"
|
||||
)
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
|
||||
filled=float(raw.get("executedQty") or 0.0),
|
||||
avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0) if float(raw.get("executedQty") or 0.0) > 0 else 0.0,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def get_account(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get spot account balances.
|
||||
|
||||
Endpoint: GET /api/v3/account
|
||||
"""
|
||||
return self._signed_request("GET", "/api/v3/account", params={})
|
||||
|
||||
def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
params: Dict[str, Any] = {"symbol": sym}
|
||||
if order_id:
|
||||
params["orderId"] = str(order_id)
|
||||
elif client_order_id:
|
||||
params["origClientOrderId"] = str(client_order_id)
|
||||
else:
|
||||
raise LiveTradingError("BinanceSpot cancel_order requires order_id or client_order_id")
|
||||
return self._signed_request("DELETE", "/api/v3/order", params=params)
|
||||
|
||||
def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
params: Dict[str, Any] = {"symbol": sym}
|
||||
if order_id:
|
||||
params["orderId"] = str(order_id)
|
||||
elif client_order_id:
|
||||
params["origClientOrderId"] = str(client_order_id)
|
||||
else:
|
||||
raise LiveTradingError("BinanceSpot get_order requires order_id or client_order_id")
|
||||
return self._signed_request("GET", "/api/v3/order", 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: 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("status") or "")
|
||||
try:
|
||||
filled = float(last.get("executedQty") or 0.0)
|
||||
except Exception:
|
||||
filled = 0.0
|
||||
avg_price = 0.0
|
||||
try:
|
||||
cum_quote = float(last.get("cummulativeQuoteQty") or 0.0)
|
||||
if filled > 0 and cum_quote > 0:
|
||||
avg_price = cum_quote / filled
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if filled > 0 and avg_price > 0:
|
||||
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
|
||||
if status in ("FILLED", "CANCELED", "EXPIRED", "REJECTED"):
|
||||
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
|
||||
if time.time() >= end_ts:
|
||||
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
|
||||
time.sleep(float(poll_interval_sec or 0.5))
|
||||
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
"""
|
||||
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):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
secret_key: str,
|
||||
passphrase: str,
|
||||
base_url: str = "https://api.bitget.com",
|
||||
timeout_sec: float = 15.0,
|
||||
):
|
||||
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()
|
||||
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) -> 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")
|
||||
|
||||
@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) -> Dict[str, str]:
|
||||
return {
|
||||
"ACCESS-KEY": self.api_key,
|
||||
"ACCESS-SIGN": sign,
|
||||
"ACCESS-TIMESTAMP": ts_ms,
|
||||
"ACCESS-PASSPHRASE": self.passphrase,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
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) -> Decimal:
|
||||
"""
|
||||
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).
|
||||
"""
|
||||
req_base = self._to_dec(base_size)
|
||||
if req_base <= 0:
|
||||
return Decimal("0")
|
||||
|
||||
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")
|
||||
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)))
|
||||
|
||||
if step > 0:
|
||||
qty = self._floor_to_step(qty, step)
|
||||
|
||||
# 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")
|
||||
return qty
|
||||
|
||||
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 = 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),
|
||||
}
|
||||
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 = 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),
|
||||
}
|
||||
# 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))
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
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")
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Translate a strategy signal into a direct-exchange order call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.binance import BinanceFuturesClient
|
||||
from app.services.live_trading.binance_spot import BinanceSpotClient
|
||||
from app.services.live_trading.okx import OkxClient
|
||||
from app.services.live_trading.bitget import BitgetMixClient
|
||||
from app.services.live_trading.bitget_spot import BitgetSpotClient
|
||||
|
||||
|
||||
def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]:
|
||||
"""
|
||||
Returns (side, pos_side, reduce_only)
|
||||
- side: buy/sell
|
||||
- pos_side: long/short (for OKX)
|
||||
"""
|
||||
sig = (signal_type or "").strip().lower()
|
||||
if sig in ("open_long", "add_long"):
|
||||
return "buy", "long", False
|
||||
if sig in ("open_short", "add_short"):
|
||||
return "sell", "short", False
|
||||
if sig in ("close_long", "reduce_long"):
|
||||
return "sell", "long", True
|
||||
if sig in ("close_short", "reduce_short"):
|
||||
return "buy", "short", True
|
||||
raise LiveTradingError(f"Unsupported signal_type: {signal_type}")
|
||||
|
||||
|
||||
def place_order_from_signal(
|
||||
client: BaseRestClient,
|
||||
*,
|
||||
signal_type: str,
|
||||
symbol: str,
|
||||
amount: float,
|
||||
market_type: str = "swap",
|
||||
exchange_config: Optional[Dict[str, Any]] = None,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
if amount is None:
|
||||
amount = 0.0
|
||||
qty = float(amount or 0.0)
|
||||
if qty <= 0:
|
||||
raise LiveTradingError("Invalid amount")
|
||||
|
||||
side, pos_side, reduce_only = _signal_to_sides(signal_type)
|
||||
|
||||
cfg = exchange_config if isinstance(exchange_config, dict) else {}
|
||||
mt = (market_type or cfg.get("market_type") or "swap").strip().lower()
|
||||
if mt in ("futures", "future", "perp", "perpetual"):
|
||||
mt = "swap"
|
||||
|
||||
# Spot does not support short signals in this system.
|
||||
if mt == "spot" and ("short" in (signal_type or "").lower()):
|
||||
raise LiveTradingError("spot market does not support short signals")
|
||||
|
||||
if isinstance(client, BinanceFuturesClient):
|
||||
return client.place_market_order(
|
||||
symbol=symbol,
|
||||
side="BUY" if side == "buy" else "SELL",
|
||||
quantity=qty,
|
||||
reduce_only=reduce_only,
|
||||
position_side=pos_side,
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
if isinstance(client, OkxClient):
|
||||
td_mode = (cfg.get("margin_mode") or cfg.get("td_mode") or "cross")
|
||||
return client.place_market_order(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
pos_side=pos_side,
|
||||
size=qty,
|
||||
td_mode=str(td_mode),
|
||||
reduce_only=reduce_only,
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
if isinstance(client, BitgetMixClient):
|
||||
margin_coin = str(cfg.get("margin_coin") or cfg.get("marginCoin") or "USDT")
|
||||
product_type = str(cfg.get("product_type") or cfg.get("productType") or "USDT-FUTURES")
|
||||
margin_mode = str(cfg.get("margin_mode") or cfg.get("marginMode") or cfg.get("td_mode") or "cross")
|
||||
return client.place_market_order(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
size=qty,
|
||||
margin_coin=margin_coin,
|
||||
product_type=product_type,
|
||||
margin_mode=margin_mode,
|
||||
reduce_only=reduce_only,
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
if isinstance(client, BinanceSpotClient):
|
||||
return client.place_market_order(
|
||||
symbol=symbol,
|
||||
side="BUY" if side == "buy" else "SELL",
|
||||
quantity=qty,
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
if isinstance(client, BitgetSpotClient):
|
||||
# For spot market BUY, Bitget may expect quote size; we pass base size here and let caller override if needed.
|
||||
return client.place_market_order(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
size=qty,
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
|
||||
raise LiveTradingError(f"Unsupported client type: {type(client)}")
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Factory for direct exchange clients.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveTradingError
|
||||
from app.services.live_trading.binance import BinanceFuturesClient
|
||||
from app.services.live_trading.binance_spot import BinanceSpotClient
|
||||
from app.services.live_trading.okx import OkxClient
|
||||
from app.services.live_trading.bitget import BitgetMixClient
|
||||
from app.services.live_trading.bitget_spot import BitgetSpotClient
|
||||
|
||||
|
||||
def _get(cfg: Dict[str, Any], *keys: str) -> str:
|
||||
for k in keys:
|
||||
v = cfg.get(k)
|
||||
if v is None:
|
||||
continue
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") -> BaseRestClient:
|
||||
if not isinstance(exchange_config, dict):
|
||||
raise LiveTradingError("Invalid exchange_config")
|
||||
exchange_id = _get(exchange_config, "exchange_id", "exchangeId").lower()
|
||||
api_key = _get(exchange_config, "api_key", "apiKey")
|
||||
secret_key = _get(exchange_config, "secret_key", "secret")
|
||||
passphrase = _get(exchange_config, "passphrase", "password")
|
||||
|
||||
mt = (market_type or exchange_config.get("market_type") or exchange_config.get("defaultType") or "swap").strip().lower()
|
||||
if mt in ("futures", "future", "perp", "perpetual"):
|
||||
mt = "swap"
|
||||
|
||||
if exchange_id == "binance":
|
||||
if mt == "spot":
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.binance.com"
|
||||
return BinanceSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
|
||||
# Default to USDT-M futures
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://fapi.binance.com"
|
||||
return BinanceFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
|
||||
if exchange_id == "okx":
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://www.okx.com"
|
||||
return OkxClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url)
|
||||
if exchange_id == "bitget":
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitget.com"
|
||||
if mt == "spot":
|
||||
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "bntva"
|
||||
return BitgetSpotClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code)
|
||||
return BitgetMixClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url)
|
||||
|
||||
raise LiveTradingError(f"Unsupported exchange_id: {exchange_id}")
|
||||
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
"""
|
||||
OKX (direct REST) client for perpetual swap orders.
|
||||
|
||||
Signing:
|
||||
- OK-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_okx_swap_inst_id, to_okx_spot_inst_id
|
||||
|
||||
|
||||
class OkxClient(BaseRestClient):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
secret_key: str,
|
||||
passphrase: str,
|
||||
base_url: str = "https://www.okx.com",
|
||||
timeout_sec: float = 15.0,
|
||||
):
|
||||
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()
|
||||
if not self.api_key or not self.secret_key or not self.passphrase:
|
||||
raise LiveTradingError("Missing OKX api_key/secret_key/passphrase")
|
||||
|
||||
# Best-effort cache for public instrument metadata used to normalize order sizes.
|
||||
# Key: f"{inst_type}:{inst_id}" -> (fetched_at_ts, instrument_dict)
|
||||
self._inst_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._inst_cache_ttl_sec = 300.0
|
||||
|
||||
# Best-effort cache for account config (position mode).
|
||||
# Key: "account_config" -> (fetched_at_ts, config_dict)
|
||||
self._acct_cfg_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._acct_cfg_cache_ttl_sec = 30.0
|
||||
|
||||
# Best-effort cache for leverage settings to avoid spamming set-leverage on every tick.
|
||||
# Key: f"{inst_id}:{mgn_mode}:{pos_side}:{lever}" -> (fetched_at_ts, True)
|
||||
self._lev_cache: Dict[str, Tuple[float, bool]] = {}
|
||||
self._lev_cache_ttl_sec = 60.0
|
||||
|
||||
@staticmethod
|
||||
def _dec_str(d: Decimal) -> str:
|
||||
"""
|
||||
Convert Decimal to a non-scientific string (OKX expects plain decimal strings).
|
||||
"""
|
||||
try:
|
||||
return format(d, "f")
|
||||
except Exception:
|
||||
return str(d)
|
||||
|
||||
@staticmethod
|
||||
def _to_dec(x: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(x))
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
@staticmethod
|
||||
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
|
||||
if step is None:
|
||||
return value
|
||||
try:
|
||||
st = Decimal(step)
|
||||
except Exception:
|
||||
st = Decimal("0")
|
||||
if st <= 0:
|
||||
return value
|
||||
if value <= 0:
|
||||
return Decimal("0")
|
||||
try:
|
||||
n = (value / st).to_integral_value(rounding=ROUND_DOWN)
|
||||
return n * st
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
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, json_body=None, headers=None, data=None)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"OKX HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and str(data.get("code") or "") not in ("0", ""):
|
||||
raise LiveTradingError(f"OKX error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def get_instrument(self, *, inst_type: str, inst_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch OKX instrument metadata from public endpoint:
|
||||
GET /api/v5/public/instruments?instType=...&instId=...
|
||||
"""
|
||||
it = str(inst_type or "").strip().upper()
|
||||
iid = str(inst_id or "").strip()
|
||||
if not it or not iid:
|
||||
return {}
|
||||
|
||||
key = f"{it}:{iid}"
|
||||
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", "/api/v5/public/instruments", params={"instType": it, "instId": iid})
|
||||
data = (raw.get("data") or []) if isinstance(raw, dict) else []
|
||||
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
|
||||
if isinstance(first, dict) and first:
|
||||
self._inst_cache[key] = (now, first)
|
||||
return first if isinstance(first, dict) else {}
|
||||
|
||||
def _normalize_order_size(self, *, inst_id: str, market_type: str, size: float) -> Decimal:
|
||||
"""
|
||||
Normalize requested size to OKX constraints:
|
||||
- Spot: size is base currency quantity; align to lotSz/minSz.
|
||||
- Swap: OKX sz is in contracts; convert base qty -> contracts using ctVal, then align to lotSz/minSz.
|
||||
|
||||
Note: this system passes `amount` around as base-asset quantity across exchanges.
|
||||
"""
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
iid = str(inst_id or "").strip()
|
||||
req = self._to_dec(size)
|
||||
if req <= 0:
|
||||
return Decimal("0")
|
||||
|
||||
inst_type = "SPOT" if mt == "spot" else "SWAP"
|
||||
inst: Dict[str, Any] = {}
|
||||
if iid:
|
||||
try:
|
||||
inst = self.get_instrument(inst_type=inst_type, inst_id=iid) or {}
|
||||
except Exception:
|
||||
inst = {}
|
||||
|
||||
lot_sz = self._to_dec((inst or {}).get("lotSz") or "0")
|
||||
min_sz = self._to_dec((inst or {}).get("minSz") or "0")
|
||||
|
||||
# Convert base qty -> contracts for swaps if ctVal is provided.
|
||||
if mt != "spot":
|
||||
ct_val = self._to_dec((inst or {}).get("ctVal") or "0")
|
||||
if ct_val > 0:
|
||||
req = req / ct_val
|
||||
|
||||
# Align to lot size step.
|
||||
if lot_sz > 0:
|
||||
req = self._floor_to_step(req, lot_sz)
|
||||
|
||||
# Enforce min size best-effort.
|
||||
if min_sz > 0 and req < min_sz:
|
||||
return Decimal("0")
|
||||
return req
|
||||
|
||||
def _iso_ts(self) -> str:
|
||||
# OKX requires RFC3339 timestamp with milliseconds, e.g. 2020-12-08T09:08:57.715Z
|
||||
t = time.time()
|
||||
sec = int(t)
|
||||
ms = int((t - sec) * 1000)
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(sec)) + f".{ms:03d}Z"
|
||||
|
||||
def _sign(self, ts: str, method: str, path: str, body: str) -> str:
|
||||
prehash = f"{ts}{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: str, sign: str) -> Dict[str, str]:
|
||||
return {
|
||||
"OK-ACCESS-KEY": self.api_key,
|
||||
"OK-ACCESS-SIGN": sign,
|
||||
"OK-ACCESS-TIMESTAMP": ts,
|
||||
"OK-ACCESS-PASSPHRASE": self.passphrase,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _signed_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Important: the signature must be computed over the exact request body string that is sent.
|
||||
Therefore we use `data=<serialized_json>` instead of `json=<dict>` to avoid re-serialization differences.
|
||||
|
||||
For GET requests with params, the query string must be part of request_path in the prehash.
|
||||
"""
|
||||
ts = self._iso_ts()
|
||||
body_str = self._json_dumps(json_body) if json_body is not None else ""
|
||||
|
||||
qs = ""
|
||||
if params:
|
||||
# OKX expects the query string in the signed request path. Keep key order stable.
|
||||
# Convert all values to string to avoid "True"/"False" surprises.
|
||||
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, 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, sign),
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"OKX HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and str(data.get("code") or "") not in ("0", ""):
|
||||
raise LiveTradingError(f"OKX error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def ping(self) -> bool:
|
||||
code, data, _ = self._request("GET", "/api/v5/public/time")
|
||||
return code == 200 and isinstance(data, dict)
|
||||
|
||||
def get_balance(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Private endpoint to validate credentials (best-effort).
|
||||
"""
|
||||
return self._signed_request("GET", "/api/v5/account/balance")
|
||||
|
||||
def get_positions(self, *, inst_id: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
Get swap positions (best-effort).
|
||||
|
||||
Endpoint: GET /api/v5/account/positions
|
||||
"""
|
||||
params: Dict[str, Any] = {"instType": "SWAP"}
|
||||
if inst_id:
|
||||
params["instId"] = str(inst_id)
|
||||
return self._signed_request("GET", "/api/v5/account/positions", params=params)
|
||||
|
||||
def set_leverage(self, *, inst_id: str, lever: float, mgn_mode: str = "cross", pos_side: str = "") -> bool:
|
||||
"""
|
||||
Set leverage for an instrument (best-effort).
|
||||
|
||||
Endpoint: POST /api/v5/account/set-leverage
|
||||
Body:
|
||||
- instId
|
||||
- lever
|
||||
- mgnMode: cross / isolated
|
||||
- posSide: net / long / short (required depending on posMode)
|
||||
"""
|
||||
iid = str(inst_id or "").strip()
|
||||
if not iid:
|
||||
return False
|
||||
try:
|
||||
lv = int(float(lever or 0))
|
||||
except Exception:
|
||||
lv = 0
|
||||
if lv <= 0:
|
||||
lv = 1
|
||||
|
||||
mm = str(mgn_mode or "cross").strip().lower()
|
||||
if mm not in ("cross", "isolated"):
|
||||
mm = "cross"
|
||||
|
||||
ps = str(pos_side or "").strip().lower()
|
||||
# In net_mode, OKX requires posSide=net. In long_short_mode, requires long/short.
|
||||
# Caller should pass already resolved posSide; but keep a safe fallback.
|
||||
if ps not in ("net", "long", "short"):
|
||||
try:
|
||||
cfg = self.get_account_config() or {}
|
||||
pm = str(cfg.get("posMode") or "").strip().lower()
|
||||
ps = "net" if pm in ("net_mode", "net") else ""
|
||||
except Exception:
|
||||
ps = ""
|
||||
|
||||
cache_key = f"{iid}:{mm}:{ps}:{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] = {"instId": iid, "lever": str(lv), "mgnMode": mm}
|
||||
if ps:
|
||||
body["posSide"] = ps
|
||||
_ = self._signed_request("POST", "/api/v5/account/set-leverage", json_body=body)
|
||||
self._lev_cache[cache_key] = (now, True)
|
||||
return True
|
||||
|
||||
def get_account_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get account configuration (best-effort).
|
||||
|
||||
Endpoint: GET /api/v5/account/config
|
||||
Important field:
|
||||
- posMode: "net_mode" or "long_short_mode"
|
||||
"""
|
||||
key = "account_config"
|
||||
now = time.time()
|
||||
cached = self._acct_cfg_cache.get(key)
|
||||
if cached:
|
||||
ts, obj = cached
|
||||
if obj and (now - float(ts or 0.0)) <= float(self._acct_cfg_cache_ttl_sec or 30.0):
|
||||
return obj
|
||||
|
||||
raw = self._signed_request("GET", "/api/v5/account/config")
|
||||
data = (raw.get("data") or []) if isinstance(raw, dict) else []
|
||||
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
|
||||
if isinstance(first, dict) and first:
|
||||
self._acct_cfg_cache[key] = (now, first)
|
||||
return first if isinstance(first, dict) else {}
|
||||
|
||||
def _resolve_pos_side(self, *, requested_pos_side: str, market_type: str) -> str:
|
||||
"""
|
||||
OKX swap position mode compatibility:
|
||||
- long_short_mode: posSide must be "long" or "short"
|
||||
- net_mode: posSide must be "net" (and close orders should use reduceOnly=true)
|
||||
"""
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
if mt == "spot":
|
||||
return ""
|
||||
|
||||
ps = (requested_pos_side or "").strip().lower()
|
||||
# Default to long/short requested.
|
||||
pos_mode = ""
|
||||
try:
|
||||
cfg = self.get_account_config() or {}
|
||||
pos_mode = str(cfg.get("posMode") or "").strip().lower()
|
||||
except Exception:
|
||||
pos_mode = ""
|
||||
|
||||
if pos_mode in ("net_mode", "net"):
|
||||
return "net"
|
||||
if pos_mode in ("long_short_mode", "longshort_mode", "long_short", "longshort"):
|
||||
if ps not in ("long", "short"):
|
||||
raise LiveTradingError(f"Invalid posSide for long_short_mode: {requested_pos_side}")
|
||||
return ps
|
||||
|
||||
# Unknown mode: be permissive but keep existing validation.
|
||||
if ps not in ("long", "short"):
|
||||
raise LiveTradingError(f"Invalid posSide: {requested_pos_side}")
|
||||
return ps
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
size: float,
|
||||
market_type: str = "swap",
|
||||
pos_side: str = "",
|
||||
td_mode: str = "cross",
|
||||
reduce_only: bool = False,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
inst_id = to_okx_spot_inst_id(symbol) if mt == "spot" else to_okx_swap_inst_id(symbol)
|
||||
sd = (side or "").lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
sz_raw = float(size or 0.0)
|
||||
sz_dec = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw)
|
||||
if float(sz_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}")
|
||||
|
||||
if mt == "spot":
|
||||
body: Dict[str, Any] = {
|
||||
"instId": inst_id,
|
||||
"tdMode": "cash",
|
||||
"side": sd,
|
||||
"ordType": "market",
|
||||
"sz": self._dec_str(sz_dec),
|
||||
# Follow hummingbot approach so "sz" is in base currency.
|
||||
"tgtCcy": "base_ccy",
|
||||
}
|
||||
else:
|
||||
ps = self._resolve_pos_side(requested_pos_side=pos_side, market_type=mt)
|
||||
td = (td_mode or "cross").lower()
|
||||
if td not in ("cross", "isolated"):
|
||||
td = "cross"
|
||||
body = {
|
||||
"instId": inst_id,
|
||||
"tdMode": td,
|
||||
"side": sd,
|
||||
"posSide": ps,
|
||||
"ordType": "market",
|
||||
"sz": self._dec_str(sz_dec),
|
||||
}
|
||||
if reduce_only:
|
||||
body["reduceOnly"] = "true"
|
||||
if client_order_id:
|
||||
body["clOrdId"] = str(client_order_id)
|
||||
|
||||
raw = self._signed_request("POST", "/api/v5/trade/order", json_body=body)
|
||||
data = (raw.get("data") or []) if isinstance(raw, dict) else []
|
||||
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
|
||||
exchange_order_id = str(first.get("ordId") or first.get("clOrdId") or "")
|
||||
|
||||
# OKX place-order does not guarantee fill fields. Keep them best-effort.
|
||||
filled = 0.0
|
||||
avg_price = 0.0
|
||||
return LiveOrderResult(
|
||||
exchange_id="okx",
|
||||
exchange_order_id=exchange_order_id,
|
||||
filled=filled,
|
||||
avg_price=avg_price,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def place_limit_order(
|
||||
self,
|
||||
*,
|
||||
market_type: str,
|
||||
symbol: str,
|
||||
side: str,
|
||||
size: float,
|
||||
price: float,
|
||||
pos_side: str = "",
|
||||
td_mode: str = "cross",
|
||||
reduce_only: bool = False,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
sd = (side or "").lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
sz_raw = float(size or 0.0)
|
||||
px = float(price or 0.0)
|
||||
if sz_raw <= 0 or px <= 0:
|
||||
raise LiveTradingError("Invalid size/price")
|
||||
|
||||
if mt == "spot":
|
||||
inst_id = to_okx_spot_inst_id(symbol)
|
||||
sz_dec = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw)
|
||||
if float(sz_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}")
|
||||
body: Dict[str, Any] = {
|
||||
"instId": inst_id,
|
||||
"tdMode": "cash",
|
||||
"side": sd,
|
||||
"ordType": "limit",
|
||||
"sz": self._dec_str(sz_dec),
|
||||
"px": str(px),
|
||||
}
|
||||
else:
|
||||
inst_id = to_okx_swap_inst_id(symbol)
|
||||
ps = self._resolve_pos_side(requested_pos_side=pos_side, market_type=mt)
|
||||
sz_dec = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw)
|
||||
if float(sz_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}")
|
||||
td = (td_mode or "cross").lower()
|
||||
if td not in ("cross", "isolated"):
|
||||
td = "cross"
|
||||
body = {
|
||||
"instId": inst_id,
|
||||
"tdMode": td,
|
||||
"side": sd,
|
||||
"posSide": ps,
|
||||
"ordType": "limit",
|
||||
"sz": self._dec_str(sz_dec),
|
||||
"px": str(px),
|
||||
}
|
||||
if reduce_only:
|
||||
body["reduceOnly"] = "true"
|
||||
|
||||
if client_order_id:
|
||||
body["clOrdId"] = str(client_order_id)
|
||||
|
||||
raw = self._signed_request("POST", "/api/v5/trade/order", json_body=body)
|
||||
data = (raw.get("data") or []) if isinstance(raw, dict) else []
|
||||
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
|
||||
exchange_order_id = str(first.get("ordId") or first.get("clOrdId") or "")
|
||||
return LiveOrderResult(exchange_id="okx", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw)
|
||||
|
||||
def cancel_order(self, *, market_type: str, symbol: str, ord_id: str = "", cl_ord_id: str = "") -> Dict[str, Any]:
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
if mt == "spot":
|
||||
inst_id = to_okx_spot_inst_id(symbol)
|
||||
else:
|
||||
inst_id = to_okx_swap_inst_id(symbol)
|
||||
body: Dict[str, Any] = {"instId": inst_id}
|
||||
if ord_id:
|
||||
body["ordId"] = str(ord_id)
|
||||
elif cl_ord_id:
|
||||
body["clOrdId"] = str(cl_ord_id)
|
||||
else:
|
||||
raise LiveTradingError("OKX cancel_order requires ord_id or cl_ord_id")
|
||||
return self._signed_request("POST", "/api/v5/trade/cancel-order", json_body=body)
|
||||
|
||||
def get_order(self, *, inst_id: str, ord_id: str = "", cl_ord_id: str = "") -> Dict[str, Any]:
|
||||
params: Dict[str, Any] = {"instId": str(inst_id)}
|
||||
if ord_id:
|
||||
params["ordId"] = str(ord_id)
|
||||
elif cl_ord_id:
|
||||
params["clOrdId"] = str(cl_ord_id)
|
||||
else:
|
||||
raise LiveTradingError("OKX get_order requires ord_id or cl_ord_id")
|
||||
resp = self._signed_request("GET", "/api/v5/trade/order", params=params)
|
||||
data = (resp.get("data") or []) if isinstance(resp, dict) else []
|
||||
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
|
||||
return first
|
||||
|
||||
def get_order_fills(self, *, inst_id: str, ord_id: str, inst_type: str = "SWAP") -> Dict[str, Any]:
|
||||
params: Dict[str, Any] = {"instId": str(inst_id), "ordId": str(ord_id), "instType": str(inst_type)}
|
||||
return self._signed_request("GET", "/api/v5/trade/fills", params=params)
|
||||
|
||||
def wait_for_fill(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
ord_id: str,
|
||||
cl_ord_id: str = "",
|
||||
market_type: str = "swap",
|
||||
max_wait_sec: float = 3.0,
|
||||
poll_interval_sec: float = 0.5,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Poll order detail / fills to obtain (best-effort) executed size and average price.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"filled": float,
|
||||
"avg_price": float,
|
||||
"fee": float,
|
||||
"fee_ccy": str,
|
||||
"state": str,
|
||||
"order": {...},
|
||||
"fills": {...}
|
||||
}
|
||||
"""
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
inst_id = to_okx_spot_inst_id(symbol) if mt == "spot" else to_okx_swap_inst_id(symbol)
|
||||
# IMPORTANT: For OKX SWAP, fillSz/accFillSz are in "contracts" (张数), not base-asset quantity.
|
||||
# Our system standardizes on base-asset quantity everywhere ("币数"), so we convert using ctVal.
|
||||
ct_val = Decimal("0")
|
||||
if mt != "spot":
|
||||
try:
|
||||
inst = self.get_instrument(inst_type="SWAP", inst_id=inst_id) or {}
|
||||
ct_val = self._to_dec(inst.get("ctVal") or "0")
|
||||
except Exception:
|
||||
ct_val = Decimal("0")
|
||||
if ct_val <= 0:
|
||||
# Fallback: keep quantities unchanged if ctVal is unavailable (best-effort).
|
||||
ct_val = Decimal("1")
|
||||
end_ts = time.time() + float(max_wait_sec or 0.0)
|
||||
last_order: Dict[str, Any] = {}
|
||||
last_fills: Dict[str, Any] = {}
|
||||
|
||||
while True:
|
||||
try:
|
||||
last_order = self.get_order(inst_id=inst_id, ord_id=str(ord_id or ""), cl_ord_id=str(cl_ord_id or ""))
|
||||
except Exception:
|
||||
last_order = last_order or {}
|
||||
|
||||
state = str(last_order.get("state") or "")
|
||||
filled_str = str(last_order.get("accFillSz") or last_order.get("fillSz") or "0")
|
||||
avg_str = str(last_order.get("avgPx") or last_order.get("fillPx") or "0")
|
||||
try:
|
||||
filled_contracts = self._to_dec(filled_str or "0")
|
||||
except Exception:
|
||||
filled_contracts = Decimal("0")
|
||||
try:
|
||||
avg_price = float(avg_str or 0.0)
|
||||
except Exception:
|
||||
avg_price = 0.0
|
||||
|
||||
filled_base_dec = filled_contracts
|
||||
if mt != "spot":
|
||||
filled_base_dec = filled_contracts * ct_val
|
||||
try:
|
||||
filled = float(filled_base_dec or 0)
|
||||
except Exception:
|
||||
filled = 0.0
|
||||
|
||||
# Prefer fills endpoint for fee (and more reliable avg/filled aggregation).
|
||||
try:
|
||||
inst_type = "SPOT" if mt == "spot" else "SWAP"
|
||||
last_fills = self.get_order_fills(inst_id=inst_id, ord_id=str(ord_id), inst_type=inst_type)
|
||||
fills = (last_fills.get("data") or []) if isinstance(last_fills, dict) else []
|
||||
total_base = Decimal("0")
|
||||
total_quote = Decimal("0")
|
||||
total_fee = 0.0
|
||||
fee_ccy = ""
|
||||
got_any_fill = False
|
||||
if isinstance(fills, list):
|
||||
for f in fills:
|
||||
try:
|
||||
sz_contracts = self._to_dec(f.get("fillSz") or "0")
|
||||
px = self._to_dec(f.get("fillPx") or "0")
|
||||
fee_v = f.get("fee")
|
||||
if fee_v is None:
|
||||
fee_v = f.get("fillFee")
|
||||
try:
|
||||
fee = float(fee_v or 0.0)
|
||||
except Exception:
|
||||
fee = 0.0
|
||||
ccy = str(f.get("feeCcy") or f.get("fillFeeCcy") or "").strip()
|
||||
sz_base = sz_contracts
|
||||
if mt != "spot":
|
||||
sz_base = sz_contracts * ct_val
|
||||
if sz_base > 0 and px > 0:
|
||||
total_base += sz_base
|
||||
total_quote += sz_base * px
|
||||
got_any_fill = True
|
||||
if fee != 0.0:
|
||||
# OKX fees are often negative for costs; store absolute cost.
|
||||
total_fee += abs(float(fee))
|
||||
if (not fee_ccy) and ccy:
|
||||
fee_ccy = ccy
|
||||
except Exception:
|
||||
continue
|
||||
# If fills are present, they are the best source of fee/avg aggregation.
|
||||
# However, OKX may lag in exposing fills right after an order is filled.
|
||||
# To avoid losing commission, do not fall back early when we haven't seen any fills yet.
|
||||
if got_any_fill and 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,
|
||||
"order": last_order,
|
||||
"fills": last_fills,
|
||||
"filled_unit": "base",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: order detail may include avg/filled but fee is not available.
|
||||
# IMPORTANT: If the order is already filled but fills endpoint hasn't returned data yet,
|
||||
# keep polling until timeout to give fills a chance to show up (so we can record fees).
|
||||
if filled > 0 and avg_price > 0 and time.time() >= end_ts:
|
||||
return {
|
||||
"filled": filled,
|
||||
"avg_price": avg_price,
|
||||
"fee": 0.0,
|
||||
"fee_ccy": "",
|
||||
"state": state,
|
||||
"order": last_order,
|
||||
"fills": last_fills,
|
||||
"filled_unit": "base",
|
||||
}
|
||||
|
||||
# Terminal states: return whatever we have.
|
||||
if state in ("filled", "canceled", "cancelled"):
|
||||
if time.time() >= end_ts:
|
||||
return {"filled": filled, "avg_price": avg_price, "fee": 0.0, "fee_ccy": "", "state": state, "order": last_order, "fills": last_fills}
|
||||
|
||||
if time.time() >= end_ts:
|
||||
return {"filled": filled, "avg_price": avg_price, "fee": 0.0, "fee_ccy": "", "state": state, "order": last_order, "fills": last_fills}
|
||||
time.sleep(float(poll_interval_sec or 0.5))
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
DB helpers for recording live trades and maintaining local position snapshots.
|
||||
|
||||
Important:
|
||||
- This is a local DB snapshot, not the source of truth (exchange is).
|
||||
- We keep it best-effort to support UI display and strategy state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
|
||||
|
||||
def record_trade(
|
||||
*,
|
||||
strategy_id: int,
|
||||
symbol: str,
|
||||
trade_type: str,
|
||||
price: float,
|
||||
amount: float,
|
||||
commission: float = 0.0,
|
||||
commission_ccy: str = "",
|
||||
profit: Optional[float] = None,
|
||||
) -> None:
|
||||
now = int(time.time())
|
||||
value = float(amount or 0.0) * float(price or 0.0)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_trades
|
||||
(strategy_id, symbol, type, price, amount, value, commission, commission_ccy, profit, created_at)
|
||||
VALUES
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
int(strategy_id),
|
||||
str(symbol),
|
||||
str(trade_type),
|
||||
float(price or 0.0),
|
||||
float(amount or 0.0),
|
||||
float(value),
|
||||
float(commission or 0.0),
|
||||
str(commission_ccy or ""),
|
||||
profit,
|
||||
now,
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def _fetch_position(strategy_id: int, symbol: str, side: str) -> Dict[str, Any]:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT * FROM qd_strategy_positions WHERE strategy_id = %s AND symbol = %s AND side = %s",
|
||||
(int(strategy_id), str(symbol), str(side)),
|
||||
)
|
||||
row = cur.fetchone() or {}
|
||||
cur.close()
|
||||
return row if isinstance(row, dict) else {}
|
||||
|
||||
|
||||
def _delete_position(strategy_id: int, symbol: str, side: str) -> None:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM qd_strategy_positions WHERE strategy_id = %s AND symbol = %s AND side = %s",
|
||||
(int(strategy_id), str(symbol), str(side)),
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def upsert_position(
|
||||
*,
|
||||
strategy_id: int,
|
||||
symbol: str,
|
||||
side: str,
|
||||
size: float,
|
||||
entry_price: float,
|
||||
current_price: float,
|
||||
highest_price: float = 0.0,
|
||||
lowest_price: float = 0.0,
|
||||
) -> None:
|
||||
now = int(time.time())
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_positions
|
||||
(strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at)
|
||||
VALUES
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT(strategy_id, symbol, side) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
entry_price = excluded.entry_price,
|
||||
current_price = excluded.current_price,
|
||||
highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE highest_price END,
|
||||
lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE lowest_price END,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(int(strategy_id), str(symbol), str(side), float(size or 0.0), float(entry_price or 0.0), float(current_price or 0.0), float(highest_price or 0.0), float(lowest_price or 0.0), now),
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def apply_fill_to_local_position(
|
||||
*,
|
||||
strategy_id: int,
|
||||
symbol: str,
|
||||
signal_type: str,
|
||||
filled: float,
|
||||
avg_price: float,
|
||||
) -> Tuple[Optional[float], Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
Apply a fill to the local position snapshot.
|
||||
|
||||
Returns (profit, updated_position_row_or_none)
|
||||
- profit is only calculated on close/reduce fills (best-effort, based on local entry_price).
|
||||
"""
|
||||
sig = (signal_type or "").strip().lower()
|
||||
filled_qty = float(filled or 0.0)
|
||||
px = float(avg_price or 0.0)
|
||||
if filled_qty <= 0 or px <= 0:
|
||||
return None, None
|
||||
|
||||
if "long" in sig:
|
||||
side = "long"
|
||||
elif "short" in sig:
|
||||
side = "short"
|
||||
else:
|
||||
return None, None
|
||||
|
||||
is_open = sig.startswith("open_") or sig.startswith("add_")
|
||||
is_close = sig.startswith("close_") or sig.startswith("reduce_")
|
||||
|
||||
current = _fetch_position(strategy_id, symbol, side)
|
||||
cur_size = float(current.get("size") or 0.0)
|
||||
cur_entry = float(current.get("entry_price") or 0.0)
|
||||
cur_high = float(current.get("highest_price") or 0.0)
|
||||
cur_low = float(current.get("lowest_price") or 0.0)
|
||||
|
||||
profit: Optional[float] = None
|
||||
|
||||
if is_open:
|
||||
new_size = cur_size + filled_qty
|
||||
if new_size <= 0:
|
||||
return None, None
|
||||
# Weighted average entry.
|
||||
if cur_size > 0 and cur_entry > 0:
|
||||
new_entry = (cur_size * cur_entry + filled_qty * px) / new_size
|
||||
else:
|
||||
new_entry = px
|
||||
new_high = max(cur_high or px, px)
|
||||
new_low = min(cur_low or px, px)
|
||||
upsert_position(
|
||||
strategy_id=strategy_id,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
size=new_size,
|
||||
entry_price=new_entry,
|
||||
current_price=px,
|
||||
highest_price=new_high,
|
||||
lowest_price=new_low,
|
||||
)
|
||||
return None, _fetch_position(strategy_id, symbol, side)
|
||||
|
||||
if is_close:
|
||||
# Calculate PnL using local entry price.
|
||||
if cur_size > 0 and cur_entry > 0:
|
||||
close_qty = min(cur_size, filled_qty)
|
||||
if side == "long":
|
||||
profit = (px - cur_entry) * close_qty
|
||||
else:
|
||||
profit = (cur_entry - px) * close_qty
|
||||
|
||||
new_size = cur_size - filled_qty
|
||||
if new_size <= 0:
|
||||
_delete_position(strategy_id, symbol, side)
|
||||
return profit, None
|
||||
# Keep entry price for remaining position.
|
||||
new_high = max(cur_high or px, px)
|
||||
new_low = min(cur_low or px, px)
|
||||
upsert_position(
|
||||
strategy_id=strategy_id,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
size=new_size,
|
||||
entry_price=cur_entry if cur_entry > 0 else px,
|
||||
current_price=px,
|
||||
highest_price=new_high,
|
||||
lowest_price=new_low,
|
||||
)
|
||||
return profit, _fetch_position(strategy_id, symbol, side)
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Symbol normalization helpers.
|
||||
|
||||
Input symbols may come from UI/strategy config in ccxt-like shape:
|
||||
- "SOL/USDT:USDT"
|
||||
- "SOL/USDT"
|
||||
|
||||
We convert them into exchange-specific identifiers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def _split_base_quote(symbol: str) -> Tuple[str, str]:
|
||||
s = (symbol or "").strip()
|
||||
if ":" in s:
|
||||
s = s.split(":", 1)[0]
|
||||
if "/" not in s:
|
||||
# Already exchange-specific (best-effort)
|
||||
return s, ""
|
||||
base, quote = s.split("/", 1)
|
||||
return base.strip().upper(), quote.strip().upper()
|
||||
|
||||
|
||||
def to_binance_futures_symbol(symbol: str) -> str:
|
||||
base, quote = _split_base_quote(symbol)
|
||||
if not quote:
|
||||
return (symbol or "").replace("/", "").replace(":", "").upper()
|
||||
return f"{base}{quote}"
|
||||
|
||||
|
||||
def to_okx_swap_inst_id(symbol: str) -> str:
|
||||
base, quote = _split_base_quote(symbol)
|
||||
if not base or not quote:
|
||||
return symbol
|
||||
# OKX perpetual swap instrument id: BASE-QUOTE-SWAP
|
||||
return f"{base}-{quote}-SWAP"
|
||||
|
||||
|
||||
def to_okx_spot_inst_id(symbol: str) -> str:
|
||||
base, quote = _split_base_quote(symbol)
|
||||
if not base or not quote:
|
||||
return symbol
|
||||
return f"{base}-{quote}"
|
||||
|
||||
|
||||
def to_bitget_um_symbol(symbol: str) -> str:
|
||||
base, quote = _split_base_quote(symbol)
|
||||
if not quote:
|
||||
return (symbol or "").replace("/", "").replace(":", "").upper()
|
||||
return f"{base}{quote}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user