diff --git a/gate_order_executor/app/gate_auth.py b/gate_order_executor/app/gate_auth.py index d33b262..f47fd53 100644 --- a/gate_order_executor/app/gate_auth.py +++ b/gate_order_executor/app/gate_auth.py @@ -3,8 +3,12 @@ from __future__ import annotations import hashlib import hmac import time +from typing import Mapping from urllib.parse import urlparse +# SOL 等合约 enable_decimal 时 size 可为 0.5;未带此头 Gate 会把小数张数向下取整为 0。 +GATE_SIZE_DECIMAL_HEADER: dict[str, str] = {"X-Gate-Size-Decimal": "1"} + def gate_sign_path(api_base: str, path_rel: str) -> str: """签名用路径:/api/v4 + /futures/usdt/...(不含 host)。""" @@ -28,10 +32,19 @@ def gate_sign_v4_headers( hashed = m.hexdigest() payload = f"{method.upper()}\n{sign_path}\n{query_string}\n{hashed}\n{ts}" sign = hmac.new(api_secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha512).hexdigest() - return { + headers: dict[str, str] = { "KEY": api_key, "Timestamp": ts, "SIGN": sign, "Accept": "application/json", "Content-Type": "application/json", } + headers.update(GATE_SIZE_DECIMAL_HEADER) + return headers + + +def gate_public_headers(extra: Mapping[str, str] | None = None) -> dict[str, str]: + headers = {"Accept": "application/json", **GATE_SIZE_DECIMAL_HEADER} + if extra: + headers.update(extra) + return headers diff --git a/gate_order_executor/app/gate_futures_live.py b/gate_order_executor/app/gate_futures_live.py index 72a5db7..000aca5 100644 --- a/gate_order_executor/app/gate_futures_live.py +++ b/gate_order_executor/app/gate_futures_live.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import logging import math @@ -10,7 +11,7 @@ from typing import Any import httpx from .config import Settings -from .gate_auth import gate_sign_path, gate_sign_v4_headers +from .gate_auth import gate_public_headers, gate_sign_path, gate_sign_v4_headers from .gate_price_rounding import _format_trigger_price, _trigger_price_tick from .models_signal import TradeSignal from .proxy_util import httpx_client_kwargs @@ -47,7 +48,7 @@ class GateFuturesLive: async def _public_get(self, rel: str, *, params: dict[str, str] | None = None) -> Any: url = f"{self._base}{rel}" async with httpx.AsyncClient(**self._kw) as client: - r = await client.get(url, params=params) + r = await client.get(url, params=params, headers=gate_public_headers()) r.raise_for_status() return r.json() @@ -95,16 +96,42 @@ class GateFuturesLive: async def fetch_net_position_size(client: GateFuturesLive, contract: str) -> float: """该合约净持仓张数(单向模式 size 正负表示方向)。""" ct = contract.strip().upper() - data = await client._signed("GET", f"{client._prefix}/positions") + data = await client._signed("GET", f"{client._prefix}/positions", query_string="holding=true") if not isinstance(data, list): return 0.0 for p in data: if str(p.get("contract") or "").strip().upper() != ct: continue return _float(p.get("size")) + # 兜底:单合约查询(部分账户 holding 列表延迟时仍可取到) + try: + row = await client._signed("GET", f"{client._prefix}/positions/{ct}") + except RuntimeError: + return 0.0 + if isinstance(row, dict): + return _float(row.get("size")) return 0.0 +async def fetch_net_position_size_with_retry( + client: GateFuturesLive, + contract: str, + *, + attempts: int = 4, + delay_sec: float = 0.25, +) -> float: + """市价成交后持仓接口可能短暂滞后,带重试读取净张数。""" + n = max(1, int(attempts)) + last = 0.0 + for i in range(n): + last = await fetch_net_position_size(client, contract) + if abs(last) > 1e-12: + return last + if i + 1 < n: + await asyncio.sleep(delay_sec) + return last + + async def post_stop_loss_price_order( client: GateFuturesLive, *, @@ -190,7 +217,7 @@ def _float(x: Any, default: float = 0.0) -> float: async def fetch_open_contracts(client: GateFuturesLive) -> set[str]: rel = f"{client._prefix}/positions" - data = await client._signed("GET", rel) + data = await client._signed("GET", rel, query_string="holding=true") if not isinstance(data, list): return set() out: set[str] = set() @@ -288,9 +315,10 @@ def _market_fill_accepted( st = str(order.get("status") or "") finish = str(order.get("finish_as") or "") if effective >= min_sz: - if st == "finished" or net_abs >= min_sz: + filled_ok = filled + 1e-12 >= min_sz + if st == "finished" or net_abs >= min_sz or filled_ok: note = "partial_fill" if filled > 1e-12 and abs(_float(order.get("left"))) > 1e-12 else "filled" - if finish and finish not in {"filled", "ioc", ""} and net_abs < min_sz: + if finish and finish not in {"filled", "ioc", ""} and net_abs < min_sz and not filled_ok: return False, effective, f"finish_as={finish}" return True, effective, note return False, effective, "no_fill" @@ -369,7 +397,7 @@ async def execute_signal_live(settings: Settings, sig: TradeSignal) -> dict: if not isinstance(order, dict): return {"status": "error", "reason": "order_response_invalid"} - net_size = await fetch_net_position_size(client, contract) + net_size = await fetch_net_position_size_with_retry(client, contract) fill_ok, filled_abs, fill_note = _market_fill_accepted( order, net_size=net_size, diff --git a/gate_order_executor/app/gate_operations.py b/gate_order_executor/app/gate_operations.py index 0e6906f..5da704f 100644 --- a/gate_order_executor/app/gate_operations.py +++ b/gate_order_executor/app/gate_operations.py @@ -47,7 +47,7 @@ async def list_futures_positions( cap = max(1, min(int(limit), 200)) try: c = GateFuturesLive(settings) - data = await c._signed("GET", f"{c._prefix}/positions") + data = await c._signed("GET", f"{c._prefix}/positions", query_string="holding=true") if not isinstance(data, list): return None, f"unexpected_response:{type(data).__name__}" out: list[dict[str, Any]] = [] diff --git a/gate_order_executor/tests/test_market_fill.py b/gate_order_executor/tests/test_market_fill.py index 2a08d5c..169e9a2 100644 --- a/gate_order_executor/tests/test_market_fill.py +++ b/gate_order_executor/tests/test_market_fill.py @@ -16,6 +16,14 @@ class TestMarketFill(unittest.TestCase): self.assertAlmostEqual(_order_filled_abs({"size": "0.4", "left": "0"}), 0.4, places=6) self.assertAlmostEqual(_order_filled_abs({"size": "-1", "left": "-0.6"}), 0.4, places=6) + def test_accept_fill_from_order_when_net_still_zero(self): + """小数张数:订单已成交但持仓接口短暂返回 0 时仍应视为成交。""" + order = {"status": "open", "finish_as": "", "size": "0.5", "left": "0"} + ok, filled, note = _market_fill_accepted(order, net_size=0.0, order_size_min=0.1) + self.assertTrue(ok) + self.assertAlmostEqual(filled, 0.5, places=6) + self.assertEqual(note, "filled") + if __name__ == "__main__": unittest.main()