f43312a858
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
667 lines
27 KiB
Python
667 lines
27 KiB
Python
"""
|
|
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={})
|
|
|
|
|