Signed-off-by: Dinger <quantdinger@gmail.com>
This commit is contained in:
Dinger
2026-04-07 22:47:07 +08:00
parent baa3182eca
commit 8563e4ea53
116 changed files with 3189 additions and 356 deletions
@@ -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})