Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2025-12-29 03:06:49 +08:00
commit f43312a858
292 changed files with 103739 additions and 0 deletions
@@ -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}"