@@ -9,12 +9,75 @@ Notes:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cached SSL verify setting for all live-trading REST calls (requests + SOCKS proxy).
|
||||
_requests_verify_value: Optional[Union[bool, str]] = None
|
||||
_ssl_verify_disabled_logged = False
|
||||
|
||||
# OS CA bundles (Docker / slim images: install ``ca-certificates``; corporate roots often added here too).
|
||||
_SYSTEM_CA_BUNDLE_CANDIDATES: Tuple[str, ...] = (
|
||||
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
|
||||
"/etc/ssl/cert.pem", # Alpine, some slim images
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora
|
||||
)
|
||||
|
||||
|
||||
def _get_requests_verify() -> Union[bool, str]:
|
||||
"""
|
||||
Resolve ``verify`` for ``requests`` when calling exchanges through proxies (e.g. PROXY_URL=socks5h://...).
|
||||
|
||||
- LIVE_TRADING_SSL_VERIFY=0|false|no|off: disable verification (insecure; mitm risk).
|
||||
- LIVE_TRADING_CA_BUNDLE / REQUESTS_CA_BUNDLE / SSL_CERT_FILE / CURL_CA_BUNDLE: path to a PEM CA bundle
|
||||
(needed for corporate TLS inspection or custom roots).
|
||||
- Else a non-empty OS CA file if present (helps Gate/HTX/hbdm etc. in minimal images).
|
||||
- Otherwise certifi's bundle when available.
|
||||
"""
|
||||
global _requests_verify_value, _ssl_verify_disabled_logged
|
||||
if _requests_verify_value is not None:
|
||||
return _requests_verify_value
|
||||
|
||||
flag = (os.environ.get("LIVE_TRADING_SSL_VERIFY") or "").strip().lower()
|
||||
if flag in ("0", "false", "no", "off"):
|
||||
if not _ssl_verify_disabled_logged:
|
||||
logger.warning(
|
||||
"LIVE_TRADING_SSL_VERIFY is disabled: HTTPS certificate verification is OFF for live trading "
|
||||
"requests (MITM risk). Fix CA trust or set LIVE_TRADING_CA_BUNDLE instead for production."
|
||||
)
|
||||
_ssl_verify_disabled_logged = True
|
||||
_requests_verify_value = False
|
||||
return _requests_verify_value
|
||||
|
||||
for key in ("LIVE_TRADING_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE"):
|
||||
path = (os.environ.get(key) or "").strip()
|
||||
if path and os.path.isfile(path):
|
||||
_requests_verify_value = path
|
||||
return _requests_verify_value
|
||||
|
||||
for path in _SYSTEM_CA_BUNDLE_CANDIDATES:
|
||||
try:
|
||||
if path and os.path.isfile(path) and os.path.getsize(path) >= 256:
|
||||
_requests_verify_value = path
|
||||
return _requests_verify_value
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
try:
|
||||
import certifi
|
||||
|
||||
_requests_verify_value = certifi.where()
|
||||
except ImportError:
|
||||
_requests_verify_value = True
|
||||
return _requests_verify_value
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveOrderResult:
|
||||
@@ -51,15 +114,26 @@ class BaseRestClient:
|
||||
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,
|
||||
)
|
||||
try:
|
||||
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,
|
||||
verify=_get_requests_verify(),
|
||||
)
|
||||
except requests.exceptions.SSLError as e:
|
||||
logger.warning(
|
||||
"Exchange HTTPS TLS verify failed (%s). Same setting applies to all REST exchanges (Gate, HTX/hbdm, etc.). "
|
||||
"Behind PROXY_URL/SOCKS or TLS inspection: set LIVE_TRADING_CA_BUNDLE to a PEM bundle (or REQUESTS_CA_BUNDLE), "
|
||||
"ensure ca-certificates in the image, or dev-only LIVE_TRADING_SSL_VERIFY=false. %s",
|
||||
url,
|
||||
e,
|
||||
)
|
||||
raise
|
||||
text = resp.text or ""
|
||||
parsed: Dict[str, Any] = {}
|
||||
try:
|
||||
|
||||
@@ -32,7 +32,7 @@ class BybitClient(BaseRestClient):
|
||||
base_url: str = "https://api.bybit.com",
|
||||
timeout_sec: float = 15.0,
|
||||
category: str = "linear", # "linear" (USDT perpetual) or "spot"
|
||||
recv_window_ms: int = 5000,
|
||||
recv_window_ms: int = 12000,
|
||||
broker_referer: str = "",
|
||||
hedge_mode: bool = False,
|
||||
):
|
||||
@@ -45,11 +45,13 @@ class BybitClient(BaseRestClient):
|
||||
if self.category not in ("linear", "spot"):
|
||||
self.category = "linear"
|
||||
try:
|
||||
self.recv_window_ms = int(recv_window_ms or 5000)
|
||||
self.recv_window_ms = int(recv_window_ms or 12000)
|
||||
except Exception:
|
||||
self.recv_window_ms = 12000
|
||||
if self.recv_window_ms < 5000:
|
||||
self.recv_window_ms = 5000
|
||||
if self.recv_window_ms <= 0:
|
||||
self.recv_window_ms = 5000
|
||||
if self.recv_window_ms > 60000:
|
||||
self.recv_window_ms = 60000
|
||||
|
||||
if not self.api_key or not self.secret_key:
|
||||
raise LiveTradingError("Missing Bybit api_key/secret_key")
|
||||
@@ -59,6 +61,12 @@ class BybitClient(BaseRestClient):
|
||||
self._inst_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._inst_cache_ttl_sec = 300.0
|
||||
|
||||
# Bybit v5 rejects requests if local clock diverges from server (retCode 10002).
|
||||
# Offset = server_ms - local_ms; signed timestamp uses local_ms + offset.
|
||||
self._time_offset_ms: int = 0
|
||||
self._time_offset_at: float = 0.0
|
||||
self._time_sync_ttl_sec: float = 55.0
|
||||
|
||||
@staticmethod
|
||||
def _to_dec(x: Any) -> Decimal:
|
||||
try:
|
||||
@@ -161,6 +169,48 @@ class BybitClient(BaseRestClient):
|
||||
def _sign(self, prehash: str) -> str:
|
||||
return hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _parse_server_time_ms_from_market_time(raw: Dict[str, Any]) -> int:
|
||||
"""Parse milliseconds from GET /v5/market/time (or similar) JSON."""
|
||||
if not isinstance(raw, dict):
|
||||
raise LiveTradingError("Bybit market/time: invalid response")
|
||||
res = raw.get("result")
|
||||
if isinstance(res, dict):
|
||||
nano = res.get("timeNano")
|
||||
if nano is not None and str(nano).strip() != "":
|
||||
try:
|
||||
return int(int(str(nano)) // 1_000_000)
|
||||
except Exception:
|
||||
pass
|
||||
sec = res.get("timeSecond")
|
||||
if sec is not None and str(sec).strip() != "":
|
||||
try:
|
||||
return int(float(sec) * 1000)
|
||||
except Exception:
|
||||
pass
|
||||
t = raw.get("time")
|
||||
if t is not None:
|
||||
try:
|
||||
return int(t)
|
||||
except Exception:
|
||||
pass
|
||||
raise LiveTradingError("Bybit market/time: missing time fields")
|
||||
|
||||
def sync_server_time_offset(self, *, force: bool = False) -> None:
|
||||
"""Align signing timestamp with Bybit server (public /v5/market/time)."""
|
||||
now = time.time()
|
||||
if (
|
||||
not force
|
||||
and self._time_offset_at > 0
|
||||
and (now - self._time_offset_at) < float(self._time_sync_ttl_sec or 55.0)
|
||||
):
|
||||
return
|
||||
raw = self._public_request("GET", "/v5/market/time")
|
||||
srv_ms = self._parse_server_time_ms_from_market_time(raw)
|
||||
local_ms = int(time.time() * 1000)
|
||||
self._time_offset_ms = int(srv_ms - local_ms)
|
||||
self._time_offset_at = now
|
||||
|
||||
def _resolve_position_idx(self, pos_side: str) -> Optional[int]:
|
||||
if not self.hedge_mode:
|
||||
return None
|
||||
@@ -193,32 +243,55 @@ class BybitClient(BaseRestClient):
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
m = str(method or "GET").upper()
|
||||
ts_ms = str(int(time.time() * 1000))
|
||||
|
||||
body_str = self._json_dumps(json_body) if json_body is not None else ""
|
||||
qs = ""
|
||||
qs_base = ""
|
||||
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)
|
||||
qs_base = urlencode(sorted(norm.items()), doseq=True)
|
||||
payload_get = qs_base
|
||||
payload_post = body_str
|
||||
|
||||
payload = qs if m == "GET" else body_str
|
||||
prehash = f"{ts_ms}{self.api_key}{self.recv_window_ms}{payload}"
|
||||
sign = self._sign(prehash)
|
||||
last_err: Optional[LiveTradingError] = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
self.sync_server_time_offset(force=(attempt > 0))
|
||||
except Exception as e:
|
||||
if attempt == 0:
|
||||
# First attempt: still try with raw local time; second pass may recover.
|
||||
pass
|
||||
else:
|
||||
raise LiveTradingError(f"Bybit time sync failed: {e}") from e
|
||||
|
||||
code, data, text = self._request(
|
||||
m,
|
||||
path,
|
||||
params=params if (m == "GET" and params) else (params or None),
|
||||
data=body_str if body_str else None,
|
||||
headers=self._headers(ts_ms, sign),
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict):
|
||||
rc = data.get("retCode")
|
||||
if rc not in (0, "0", None, ""):
|
||||
raise LiveTradingError(f"Bybit error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
ts_ms = str(int(time.time() * 1000) + int(self._time_offset_ms or 0))
|
||||
payload = payload_get if m == "GET" else payload_post
|
||||
prehash = f"{ts_ms}{self.api_key}{self.recv_window_ms}{payload}"
|
||||
sign = self._sign(prehash)
|
||||
|
||||
code, data, text = self._request(
|
||||
m,
|
||||
path,
|
||||
params=params if (m == "GET" and params) else (params or None),
|
||||
data=body_str if body_str else None,
|
||||
headers=self._headers(ts_ms, sign),
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict):
|
||||
rc = data.get("retCode")
|
||||
try:
|
||||
rc_int = int(rc) if rc is not None and str(rc).strip() != "" else 0
|
||||
except Exception:
|
||||
rc_int = -1
|
||||
if rc_int == 10002 and attempt == 0:
|
||||
last_err = LiveTradingError(f"Bybit error: {data}")
|
||||
continue
|
||||
if rc not in (0, "0", None, ""):
|
||||
raise LiveTradingError(f"Bybit error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
if last_err:
|
||||
raise last_err
|
||||
raise LiveTradingError("Bybit signed request failed after time resync")
|
||||
|
||||
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)
|
||||
@@ -237,6 +310,109 @@ class BybitClient(BaseRestClient):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _row_to_ticker_out(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(row, dict):
|
||||
return {}
|
||||
last_raw = row.get("lastPrice") or row.get("last") or row.get("markPrice") or row.get("indexPrice") or 0
|
||||
try:
|
||||
px = float(str(last_raw).replace(",", "").strip() or 0)
|
||||
except Exception:
|
||||
px = 0.0
|
||||
if px <= 0:
|
||||
return {}
|
||||
out: Dict[str, Any] = dict(row)
|
||||
out["last"] = px
|
||||
out["price"] = px
|
||||
return out
|
||||
|
||||
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Public market price for USDT notional -> base qty (quick_trade / execution).
|
||||
|
||||
Tries: ``/v5/market/tickers`` (by symbol) → ``/v5/market/orderbook`` (mid) →
|
||||
``/v5/market/tickers`` (category-only, scan list). Some environments return an empty
|
||||
ticker list for filtered queries; fallbacks avoid silent failure.
|
||||
"""
|
||||
sym = to_bybit_symbol(symbol)
|
||||
if not sym:
|
||||
return {}
|
||||
cat = "spot" if (self.category or "").strip().lower() == "spot" else "linear"
|
||||
sym_u = sym.upper()
|
||||
|
||||
# 1) Filtered tickers (preferred)
|
||||
try:
|
||||
raw = self._public_request("GET", "/v5/market/tickers", params={"category": cat, "symbol": sym_u})
|
||||
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
|
||||
if isinstance(lst, list) and lst:
|
||||
out = self._row_to_ticker_out(lst[0] if isinstance(lst[0], dict) else {})
|
||||
if out:
|
||||
return out
|
||||
except LiveTradingError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) Order book mid (bid/ask)
|
||||
try:
|
||||
ob = self._public_request(
|
||||
"GET",
|
||||
"/v5/market/orderbook",
|
||||
params={"category": cat, "symbol": sym_u, "limit": 25},
|
||||
)
|
||||
res = (ob.get("result") or {}) if isinstance(ob, dict) else {}
|
||||
bids = res.get("b") or []
|
||||
asks = res.get("a") or []
|
||||
bid_p = 0.0
|
||||
ask_p = 0.0
|
||||
if isinstance(bids, list) and bids and isinstance(bids[0], (list, tuple)) and len(bids[0]) > 0:
|
||||
try:
|
||||
bid_p = float(str(bids[0][0]).replace(",", ""))
|
||||
except Exception:
|
||||
bid_p = 0.0
|
||||
if isinstance(asks, list) and asks and isinstance(asks[0], (list, tuple)) and len(asks[0]) > 0:
|
||||
try:
|
||||
ask_p = float(str(asks[0][0]).replace(",", ""))
|
||||
except Exception:
|
||||
ask_p = 0.0
|
||||
mid = 0.0
|
||||
if bid_p > 0 and ask_p > 0:
|
||||
mid = (bid_p + ask_p) / 2.0
|
||||
else:
|
||||
mid = bid_p or ask_p
|
||||
if mid > 0:
|
||||
return {
|
||||
"symbol": sym_u,
|
||||
"last": mid,
|
||||
"price": mid,
|
||||
"bid1Price": bid_p,
|
||||
"ask1Price": ask_p,
|
||||
}
|
||||
except LiveTradingError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3) Full category ticker list, match symbol (larger payload; last resort)
|
||||
try:
|
||||
raw = self._public_request("GET", "/v5/market/tickers", params={"category": cat})
|
||||
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
|
||||
if isinstance(lst, list):
|
||||
for row in lst:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if str(row.get("symbol") or "").strip().upper() != sym_u:
|
||||
continue
|
||||
out = self._row_to_ticker_out(row)
|
||||
if out:
|
||||
return out
|
||||
except LiveTradingError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {}
|
||||
|
||||
def get_wallet_balance(self, *, account_type: str = "UNIFIED") -> Dict[str, Any]:
|
||||
return self._signed_request("GET", "/v5/account/wallet-balance", params={"accountType": str(account_type or "UNIFIED")})
|
||||
|
||||
@@ -497,10 +673,28 @@ class BybitClient(BaseRestClient):
|
||||
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
|
||||
time.sleep(float(poll_interval_sec or 0.5))
|
||||
|
||||
def get_positions(self) -> Dict[str, Any]:
|
||||
def get_positions(
|
||||
self,
|
||||
*,
|
||||
symbol: str = "",
|
||||
settle_coin: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
GET /v5/position/list — Bybit v5 requires ``symbol`` OR ``settleCoin`` with ``category``.
|
||||
|
||||
- Pass ``symbol`` (e.g. ETH/USDT) to query one contract.
|
||||
- Omit ``symbol`` and pass ``settle_coin`` (default USDT) to list all USDT-linear positions.
|
||||
"""
|
||||
if self.category != "linear":
|
||||
raise LiveTradingError("Bybit positions are only supported for linear category in this client")
|
||||
return self._signed_request("GET", "/v5/position/list", params={"category": "linear"})
|
||||
params: Dict[str, Any] = {"category": "linear"}
|
||||
sym = to_bybit_symbol(symbol) if (symbol or "").strip() else ""
|
||||
if sym:
|
||||
params["symbol"] = sym
|
||||
else:
|
||||
sc = (settle_coin or "USDT").strip().upper() or "USDT"
|
||||
params["settleCoin"] = sc
|
||||
return self._signed_request("GET", "/v5/position/list", params=params)
|
||||
|
||||
def set_leverage(self, *, symbol: str, leverage: float) -> bool:
|
||||
if self.category != "linear":
|
||||
|
||||
@@ -117,7 +117,7 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
|
||||
default_bybit = "https://api-testnet.bybit.com" if is_demo else "https://api.bybit.com"
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or default_bybit
|
||||
category = "spot" if mt == "spot" else "linear"
|
||||
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000)
|
||||
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 12000)
|
||||
broker_referer = _get(exchange_config, "bybit_referer", "broker_referer", "brokerReferer") or "Ri001020"
|
||||
hedge_mode_raw = exchange_config.get("hedge_mode")
|
||||
if hedge_mode_raw is None:
|
||||
|
||||
@@ -4,7 +4,7 @@ Gate.io (direct REST) clients:
|
||||
- Futures USDT: /api/v4/futures/usdt/*
|
||||
|
||||
Signing (apiv4):
|
||||
SIGN = hex(hmac_sha512(secret, method + "\\n" + url + "\\n" + query + "\\n" + body + "\\n" + timestamp))
|
||||
SIGN = hex(hmac_sha512(secret, method + "\\n" + url + "\\n" + query + "\\n" + hexencode(sha512(payload)) + "\\n" + timestamp))
|
||||
Headers:
|
||||
- KEY: api key
|
||||
- Timestamp: unix seconds
|
||||
@@ -15,14 +15,42 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.symbols import to_gate_currency_pair
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _gate_ticker_response_to_normalized(raw: Any) -> Dict[str, Any]:
|
||||
"""Parse Gate spot/futures tickers API (array of one row) into a dict with float ``last`` for quick_trade."""
|
||||
row: Dict[str, Any] = {}
|
||||
if isinstance(raw, list) and raw and isinstance(raw[0], dict):
|
||||
row = raw[0]
|
||||
elif isinstance(raw, dict) and raw:
|
||||
row = raw
|
||||
else:
|
||||
return {}
|
||||
last = 0.0
|
||||
for key in ("last", "mark_price", "index_price", "close", "price"):
|
||||
v = row.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
try:
|
||||
last = float(str(v).replace(",", ""))
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
out = dict(row)
|
||||
out["last"] = last
|
||||
out["close"] = last
|
||||
out["price"] = last
|
||||
return out
|
||||
|
||||
|
||||
class _GateBase(BaseRestClient):
|
||||
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0, channel_id: str = ""):
|
||||
@@ -34,7 +62,10 @@ class _GateBase(BaseRestClient):
|
||||
raise LiveTradingError("Missing Gate api_key/secret_key")
|
||||
|
||||
def _sign(self, *, method: str, url: str, query_string: str, body_str: str, ts: str) -> str:
|
||||
msg = f"{method.upper()}\n{url}\n{query_string}\n{body_str}\n{ts}"
|
||||
# Per https://www.gate.com/docs/developers/apiv4/en/#authentication — payload slot is SHA512(body).hexdigest(),
|
||||
# not the raw body (GET / no body => hash of empty string).
|
||||
hashed_payload = hashlib.sha512((body_str or "").encode("utf-8")).hexdigest()
|
||||
msg = f"{method.upper()}\n{url}\n{query_string}\n{hashed_payload}\n{ts}"
|
||||
return hmac.new(self.secret_key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha512).hexdigest()
|
||||
|
||||
def _headers(self, ts: str, sign: str) -> Dict[str, str]:
|
||||
@@ -58,7 +89,15 @@ class _GateBase(BaseRestClient):
|
||||
text = f"t-{text}"
|
||||
return text[:28]
|
||||
|
||||
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
||||
def _signed_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Any:
|
||||
m = str(method or "GET").upper()
|
||||
ts = str(int(time.time()))
|
||||
body_str = self._json_dumps(json_body) if json_body is not None else ""
|
||||
@@ -67,7 +106,10 @@ class _GateBase(BaseRestClient):
|
||||
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
|
||||
qs = urlencode(sorted(norm.items()), doseq=True)
|
||||
sign = self._sign(method=m, url=path, query_string=qs, body_str=body_str, ts=ts)
|
||||
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts, sign))
|
||||
hdrs = dict(self._headers(ts, sign))
|
||||
if extra_headers:
|
||||
hdrs.update({str(k): str(v) for k, v in extra_headers.items()})
|
||||
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=hdrs)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Gate HTTP {code}: {text[:500]}")
|
||||
return data
|
||||
@@ -87,6 +129,11 @@ class GateSpotClient(_GateBase):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
|
||||
pair = to_gate_currency_pair(symbol)
|
||||
raw = self._public_request("GET", "/api/v4/spot/tickers", params={"currency_pair": pair})
|
||||
return _gate_ticker_response_to_normalized(raw)
|
||||
|
||||
def get_accounts(self) -> Any:
|
||||
return self._signed_request("GET", "/api/v4/spot/accounts")
|
||||
|
||||
@@ -204,13 +251,21 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
return Decimal("0")
|
||||
|
||||
def ping(self) -> bool:
|
||||
# Gate futures REST no longer serves /api/v4/futures/usdt/time (returns 400 on fx-api / api hosts).
|
||||
# Use a lightweight public list call instead.
|
||||
try:
|
||||
_ = self._public_request("GET", "/api/v4/futures/usdt/time")
|
||||
_ = self._public_request("GET", "/api/v4/futures/usdt/contracts", params={"limit": 1})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
raw = self._public_request("GET", "/api/v4/futures/usdt/tickers", params={"contract": contract})
|
||||
return _gate_ticker_response_to_normalized(raw)
|
||||
|
||||
def get_contract(self, *, contract: str) -> Dict[str, Any]:
|
||||
"""Fetch contract metadata with ``X-Gate-Size-Decimal: 1`` to get accurate string-typed size fields."""
|
||||
c = str(contract or "").strip()
|
||||
if not c:
|
||||
return {}
|
||||
@@ -220,33 +275,122 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
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", f"/api/v4/futures/usdt/contracts/{c}")
|
||||
obj = raw if isinstance(raw, dict) else {}
|
||||
code, data, text = self._request(
|
||||
"GET", f"/api/v4/futures/usdt/contracts/{c}",
|
||||
params=None, headers={"X-Gate-Size-Decimal": "1"},
|
||||
json_body=None, data=None,
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Gate HTTP {code}: {text[:500]}")
|
||||
obj = data if isinstance(data, dict) else {}
|
||||
if obj:
|
||||
self._contract_cache[c] = (now, obj)
|
||||
return obj
|
||||
|
||||
def _base_to_contracts(self, *, contract: str, base_size: float) -> int:
|
||||
@staticmethod
|
||||
def _decimal_places(d: Decimal) -> int:
|
||||
"""Return the number of decimal places in a Decimal value."""
|
||||
sign, digits, exponent = d.as_tuple()
|
||||
return max(0, -int(exponent))
|
||||
|
||||
def _resolve_order_size(self, *, contract: str, side: str, base_size: float) -> Tuple[str, Optional[Dict[str, str]]]:
|
||||
"""
|
||||
Convert base-asset qty to a signed Gate ``size`` string and determine whether to use
|
||||
the ``X-Gate-Size-Decimal`` header.
|
||||
|
||||
Per Gate announcement (2025-12-18):
|
||||
- ``size`` is always in **contracts** (not base-asset units).
|
||||
- With ``X-Gate-Size-Decimal: 1``, ``size`` becomes a string that supports decimals.
|
||||
- A contract supports fractional ordering when ``order_size_min`` (queried with the
|
||||
decimal header) contains a fractional part (e.g. ``"0.1"``).
|
||||
- Precision must align with ``order_size_min`` (e.g. if min is ``"0.1"`` → 1 dp).
|
||||
"""
|
||||
sd = (side or "").strip().lower()
|
||||
sign = Decimal("1") if sd == "buy" else Decimal("-1")
|
||||
req = self._to_dec(base_size)
|
||||
if req <= 0:
|
||||
return 0
|
||||
return ("0", None)
|
||||
|
||||
meta: Dict[str, Any] = {}
|
||||
try:
|
||||
meta = self.get_contract(contract=contract) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
qm = self._to_dec(meta.get("quanto_multiplier") or meta.get("quantoMultiplier") or meta.get("contract_size") or meta.get("contractSize") or "0")
|
||||
|
||||
qm = self._to_dec(meta.get("quanto_multiplier") or meta.get("quantoMultiplier") or "0")
|
||||
if qm <= 0:
|
||||
# Fallback: 1 contract ~= 1 base unit (best-effort)
|
||||
qm = Decimal("1")
|
||||
|
||||
contracts = req / qm
|
||||
return int(self._floor(contracts))
|
||||
|
||||
order_min = self._to_dec(meta.get("order_size_min") or "1")
|
||||
if order_min <= 0:
|
||||
order_min = Decimal("1")
|
||||
dp = self._decimal_places(order_min)
|
||||
|
||||
if dp > 0:
|
||||
step = Decimal(10) ** (-dp)
|
||||
q = contracts.quantize(step, rounding=ROUND_DOWN)
|
||||
if q < order_min and contracts > 0:
|
||||
q = order_min
|
||||
signed_q = q * sign
|
||||
s = format(signed_q, "f")
|
||||
if "." in s:
|
||||
s = s.rstrip("0").rstrip(".")
|
||||
return (s if s and s not in ("-", "+", "-0", "+0", "0") else "0",
|
||||
{"X-Gate-Size-Decimal": "1"})
|
||||
else:
|
||||
iv = int(self._floor(contracts))
|
||||
int_min = max(1, int(order_min))
|
||||
if iv < int_min and contracts > 0:
|
||||
iv = int_min
|
||||
signed_iv = int(Decimal(iv) * sign)
|
||||
return (str(signed_iv), None)
|
||||
|
||||
def _base_to_contracts(self, *, contract: str, base_size: float) -> int:
|
||||
"""Integer contracts estimate (for internal use like position sizing display)."""
|
||||
meta: Dict[str, Any] = {}
|
||||
try:
|
||||
meta = self.get_contract(contract=contract) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
qm = self._to_dec(meta.get("quanto_multiplier") or "0")
|
||||
if qm <= 0:
|
||||
qm = Decimal("1")
|
||||
return max(1, int(self._floor(self._to_dec(base_size) / qm)))
|
||||
|
||||
def contracts_signed_to_base_qty(self, *, contract: str, contracts_signed: float) -> float:
|
||||
"""Convert signed position size (contracts) from Gate positions API to base-asset quantity."""
|
||||
try:
|
||||
ct = abs(float(contracts_signed or 0.0))
|
||||
except Exception:
|
||||
return 0.0
|
||||
if ct <= 0:
|
||||
return 0.0
|
||||
meta: Dict[str, Any] = {}
|
||||
try:
|
||||
meta = self.get_contract(contract=str(contract)) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
qm = self._to_dec(
|
||||
meta.get("quanto_multiplier")
|
||||
or meta.get("quantoMultiplier")
|
||||
or meta.get("contract_size")
|
||||
or meta.get("contractSize")
|
||||
or "0"
|
||||
)
|
||||
if qm <= 0:
|
||||
qm = Decimal("1")
|
||||
return float(Decimal(str(ct)) * qm)
|
||||
|
||||
def get_accounts(self) -> Any:
|
||||
return self._signed_request("GET", "/api/v4/futures/usdt/accounts")
|
||||
|
||||
def get_positions(self) -> Any:
|
||||
return self._signed_request("GET", "/api/v4/futures/usdt/positions")
|
||||
return self._signed_request(
|
||||
"GET", "/api/v4/futures/usdt/positions",
|
||||
extra_headers={"X-Gate-Size-Decimal": "1"},
|
||||
)
|
||||
|
||||
def set_leverage(self, *, contract: str, leverage: float) -> bool:
|
||||
c = str(contract or "").strip()
|
||||
@@ -258,11 +402,26 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
lv = 1
|
||||
if lv < 1:
|
||||
lv = 1
|
||||
try:
|
||||
_ = self._signed_request("POST", f"/api/v4/futures/usdt/positions/{c}/leverage", json_body={"leverage": str(lv)})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
path = f"/api/v4/futures/usdt/positions/{c}/leverage"
|
||||
lv_s = str(lv)
|
||||
# Gate expects ``leverage`` / ``cross_leverage_limit`` as **query parameters**, not JSON body
|
||||
# (see gateapi-python: update_position_leverage). Cross / portfolio mode: leverage=0 + cross_leverage_limit.
|
||||
attempts: Tuple[Dict[str, str], ...] = (
|
||||
{"leverage": lv_s},
|
||||
{"leverage": "0", "cross_leverage_limit": lv_s},
|
||||
{"leverage": lv_s, "cross_leverage_limit": lv_s},
|
||||
)
|
||||
last_err: Optional[Exception] = None
|
||||
for qp in attempts:
|
||||
try:
|
||||
_ = self._signed_request("POST", path, params=qp, json_body=None)
|
||||
return True
|
||||
except LiveTradingError as e:
|
||||
last_err = e
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
logger.warning("Gate set_leverage failed contract=%s leverage=%s: %s", c, lv, last_err)
|
||||
return False
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
@@ -276,18 +435,27 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
sd = (side or "").strip().lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
base_qty = float(size or 0.0)
|
||||
if base_qty <= 0:
|
||||
raise LiveTradingError("Invalid size (<= 0)")
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
csz = self._base_to_contracts(contract=contract, base_size=float(size or 0.0))
|
||||
if csz <= 0:
|
||||
raise LiveTradingError("Invalid size (converted contracts <= 0)")
|
||||
signed_size = int(csz) if sd == "buy" else -int(csz)
|
||||
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": "0", "tif": "ioc"}
|
||||
size_str, extra_headers = self._resolve_order_size(contract=contract, side=sd, base_size=base_qty)
|
||||
if size_str in ("0", "-0", ""):
|
||||
raise LiveTradingError("Invalid size (resolved contracts == 0)")
|
||||
logger.info("Gate futures market: contract=%s side=%s base_qty=%s size_str=%s decimal_hdr=%s",
|
||||
contract, sd, base_qty, size_str, extra_headers is not None)
|
||||
body: Dict[str, Any] = {"contract": contract, "size": size_str, "price": "0", "tif": "ioc"}
|
||||
if reduce_only:
|
||||
body["reduce_only"] = True
|
||||
text = self._format_text(client_order_id)
|
||||
if text:
|
||||
body["text"] = text
|
||||
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
|
||||
raw = self._signed_request(
|
||||
"POST",
|
||||
"/api/v4/futures/usdt/orders",
|
||||
json_body=body,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
|
||||
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
|
||||
|
||||
@@ -304,21 +472,28 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
sd = (side or "").strip().lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
csz = self._base_to_contracts(contract=contract, base_size=float(size or 0.0))
|
||||
if csz <= 0:
|
||||
raise LiveTradingError("Invalid size (converted contracts <= 0)")
|
||||
base_qty = float(size or 0.0)
|
||||
if base_qty <= 0:
|
||||
raise LiveTradingError("Invalid size (<= 0)")
|
||||
px = float(price or 0.0)
|
||||
if px <= 0:
|
||||
raise LiveTradingError("Invalid price")
|
||||
signed_size = int(csz) if sd == "buy" else -int(csz)
|
||||
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": str(px), "tif": "gtc"}
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
size_str, extra_headers = self._resolve_order_size(contract=contract, side=sd, base_size=base_qty)
|
||||
if size_str in ("0", "-0", ""):
|
||||
raise LiveTradingError("Invalid size (resolved contracts == 0)")
|
||||
body: Dict[str, Any] = {"contract": contract, "size": size_str, "price": str(px), "tif": "gtc"}
|
||||
if reduce_only:
|
||||
body["reduce_only"] = True
|
||||
text = self._format_text(client_order_id)
|
||||
if text:
|
||||
body["text"] = text
|
||||
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
|
||||
raw = self._signed_request(
|
||||
"POST",
|
||||
"/api/v4/futures/usdt/orders",
|
||||
json_body=body,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
|
||||
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
|
||||
|
||||
|
||||
@@ -19,9 +19,13 @@ from urllib.parse import urlencode, urlparse
|
||||
import datetime
|
||||
import time
|
||||
|
||||
import logging
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.symbols import to_htx_contract_code, to_htx_spot_symbol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HtxClient(BaseRestClient):
|
||||
def __init__(
|
||||
@@ -53,6 +57,17 @@ class HtxClient(BaseRestClient):
|
||||
self._contract_cache_ttl_sec = 300.0
|
||||
self._lever_cache: Dict[str, int] = {}
|
||||
|
||||
@staticmethod
|
||||
def _format_swap_client_order_id(client_order_id: Optional[str]) -> Optional[int]:
|
||||
"""HTX swap/linear-swap client_order_id must be a pure numeric long (1~9223372036854775807)."""
|
||||
if not client_order_id:
|
||||
return None
|
||||
digits = "".join(c for c in str(client_order_id) if c.isdigit())
|
||||
if not digits:
|
||||
digits = str(int(time.time() * 1000))
|
||||
val = int(digits[-18:])
|
||||
return val if 0 < val <= 9223372036854775807 else None
|
||||
|
||||
def _format_spot_client_order_id(self, client_order_id: Optional[str]) -> str:
|
||||
prefix = str(self.broker_id or "").strip()
|
||||
raw = str(client_order_id or "").strip()
|
||||
@@ -202,11 +217,33 @@ class HtxClient(BaseRestClient):
|
||||
if self.market_type == "spot":
|
||||
account_id = self._get_spot_account_id()
|
||||
return self._spot_private_request("GET", f"/v1/account/accounts/{account_id}/balance")
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
|
||||
# 1) v1 cross
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
except LiveTradingError as e:
|
||||
logger.debug("HTX v1 cross account_info failed: %s", e)
|
||||
# 2) v3 unified (unified / multi-asset collateral accounts)
|
||||
try:
|
||||
raw = self._swap_private_request("GET", "/linear-swap-api/v3/unified_account_info")
|
||||
v3_code = raw.get("code")
|
||||
if v3_code is not None and int(v3_code) == 200:
|
||||
data = raw.get("data")
|
||||
if isinstance(data, list) and data:
|
||||
logger.info("HTX v3 unified_account_info succeeded")
|
||||
return raw
|
||||
else:
|
||||
logger.debug("HTX v3 unified_account_info returned code=%s", v3_code)
|
||||
except (LiveTradingError, Exception) as e:
|
||||
logger.debug("HTX v3 unified_account_info failed: %s", e)
|
||||
# 3) v1 isolated
|
||||
try:
|
||||
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
|
||||
except LiveTradingError as e:
|
||||
logger.warning("HTX all balance endpoints failed, last error: %s", e)
|
||||
return {"data": []}
|
||||
|
||||
def get_positions(self, *, symbol: str = "") -> Any:
|
||||
if self.market_type == "spot":
|
||||
@@ -235,11 +272,35 @@ class HtxClient(BaseRestClient):
|
||||
return {"data": rows}
|
||||
|
||||
body = {"contract_code": to_htx_contract_code(symbol)} if symbol else {}
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
|
||||
# 1) v1 cross
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
except LiveTradingError as e:
|
||||
logger.debug("HTX v1 cross position_info failed: %s", e)
|
||||
# 2) v1 isolated
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
except LiveTradingError as e:
|
||||
logger.debug("HTX v1 isolated position_info failed: %s", e)
|
||||
# 3) v3 unified - extract positions from cross_swap sub-array
|
||||
try:
|
||||
raw = self._swap_private_request("GET", "/linear-swap-api/v3/unified_account_info")
|
||||
v3_code = raw.get("code")
|
||||
if v3_code is not None and int(v3_code) == 200:
|
||||
v3_data = raw.get("data") or []
|
||||
if isinstance(v3_data, list) and v3_data:
|
||||
logger.info("HTX v3 unified_account_info for positions succeeded")
|
||||
return raw
|
||||
except (LiveTradingError, Exception) as e:
|
||||
logger.debug("HTX v3 unified_account_info (positions) failed: %s", e)
|
||||
logger.warning("HTX all position endpoints failed for symbol=%s", symbol)
|
||||
return {"data": []}
|
||||
|
||||
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
|
||||
if self.market_type == "spot":
|
||||
@@ -355,7 +416,7 @@ class HtxClient(BaseRestClient):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
offset = "close" if reduce_only else "open"
|
||||
lever_rate = int(self._lever_cache.get(contract_code) or 5)
|
||||
body = {
|
||||
body: Dict[str, Any] = {
|
||||
"contract_code": contract_code,
|
||||
"volume": volume,
|
||||
"direction": sd,
|
||||
@@ -365,8 +426,18 @@ class HtxClient(BaseRestClient):
|
||||
}
|
||||
if self.broker_id:
|
||||
body["channel_code"] = self.broker_id
|
||||
if client_order_id:
|
||||
body["client_order_id"] = str(client_order_id)[:64]
|
||||
swap_coid = self._format_swap_client_order_id(client_order_id)
|
||||
if swap_coid is not None:
|
||||
body["client_order_id"] = swap_coid
|
||||
cross_body = dict(body)
|
||||
cross_body["margin_account"] = "USDT"
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_order", json_body=cross_body)
|
||||
data = raw.get("data") or {}
|
||||
oid = str(data.get("order_id_str") or data.get("order_id") or "")
|
||||
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
|
||||
except LiveTradingError as e:
|
||||
logger.info("HTX swap_cross_order failed, trying swap_order: %s", e)
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
|
||||
data = raw.get("data") or {}
|
||||
oid = str(data.get("order_id_str") or data.get("order_id") or "")
|
||||
@@ -412,7 +483,7 @@ class HtxClient(BaseRestClient):
|
||||
contract_code = to_htx_contract_code(symbol)
|
||||
volume = self._base_to_contracts(symbol=symbol, qty=qty)
|
||||
lever_rate = int(self._lever_cache.get(contract_code) or 5)
|
||||
body = {
|
||||
body: Dict[str, Any] = {
|
||||
"contract_code": contract_code,
|
||||
"volume": volume,
|
||||
"direction": sd,
|
||||
@@ -423,8 +494,18 @@ class HtxClient(BaseRestClient):
|
||||
}
|
||||
if self.broker_id:
|
||||
body["channel_code"] = self.broker_id
|
||||
if client_order_id:
|
||||
body["client_order_id"] = str(client_order_id)[:64]
|
||||
swap_coid = self._format_swap_client_order_id(client_order_id)
|
||||
if swap_coid is not None:
|
||||
body["client_order_id"] = swap_coid
|
||||
cross_body = dict(body)
|
||||
cross_body["margin_account"] = "USDT"
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_order", json_body=cross_body)
|
||||
data = raw.get("data") or {}
|
||||
oid = str(data.get("order_id_str") or data.get("order_id") or "")
|
||||
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
|
||||
except LiveTradingError:
|
||||
pass
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
|
||||
data = raw.get("data") or {}
|
||||
oid = str(data.get("order_id_str") or data.get("order_id") or "")
|
||||
|
||||
Reference in New Issue
Block a user