修复持仓

This commit is contained in:
dekun
2026-05-31 12:32:06 +08:00
parent cdbe087202
commit ee8cd5caf0
4 changed files with 58 additions and 9 deletions
+14 -1
View File
@@ -3,8 +3,12 @@ from __future__ import annotations
import hashlib import hashlib
import hmac import hmac
import time import time
from typing import Mapping
from urllib.parse import urlparse 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: def gate_sign_path(api_base: str, path_rel: str) -> str:
"""签名用路径:/api/v4 + /futures/usdt/...(不含 host)。""" """签名用路径:/api/v4 + /futures/usdt/...(不含 host)。"""
@@ -28,10 +32,19 @@ def gate_sign_v4_headers(
hashed = m.hexdigest() hashed = m.hexdigest()
payload = f"{method.upper()}\n{sign_path}\n{query_string}\n{hashed}\n{ts}" 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() sign = hmac.new(api_secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha512).hexdigest()
return { headers: dict[str, str] = {
"KEY": api_key, "KEY": api_key,
"Timestamp": ts, "Timestamp": ts,
"SIGN": sign, "SIGN": sign,
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "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
+35 -7
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import math import math
@@ -10,7 +11,7 @@ from typing import Any
import httpx import httpx
from .config import Settings 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 .gate_price_rounding import _format_trigger_price, _trigger_price_tick
from .models_signal import TradeSignal from .models_signal import TradeSignal
from .proxy_util import httpx_client_kwargs 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: async def _public_get(self, rel: str, *, params: dict[str, str] | None = None) -> Any:
url = f"{self._base}{rel}" url = f"{self._base}{rel}"
async with httpx.AsyncClient(**self._kw) as client: 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() r.raise_for_status()
return r.json() return r.json()
@@ -95,14 +96,40 @@ class GateFuturesLive:
async def fetch_net_position_size(client: GateFuturesLive, contract: str) -> float: async def fetch_net_position_size(client: GateFuturesLive, contract: str) -> float:
"""该合约净持仓张数(单向模式 size 正负表示方向)。""" """该合约净持仓张数(单向模式 size 正负表示方向)。"""
ct = contract.strip().upper() 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): if not isinstance(data, list):
return 0.0 return 0.0
for p in data: for p in data:
if str(p.get("contract") or "").strip().upper() != ct: if str(p.get("contract") or "").strip().upper() != ct:
continue continue
return _float(p.get("size")) return _float(p.get("size"))
# 兜底:单合约查询(部分账户 holding 列表延迟时仍可取到)
try:
row = await client._signed("GET", f"{client._prefix}/positions/{ct}")
except RuntimeError:
return 0.0 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( async def post_stop_loss_price_order(
@@ -190,7 +217,7 @@ def _float(x: Any, default: float = 0.0) -> float:
async def fetch_open_contracts(client: GateFuturesLive) -> set[str]: async def fetch_open_contracts(client: GateFuturesLive) -> set[str]:
rel = f"{client._prefix}/positions" 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): if not isinstance(data, list):
return set() return set()
out: set[str] = set() out: set[str] = set()
@@ -288,9 +315,10 @@ def _market_fill_accepted(
st = str(order.get("status") or "") st = str(order.get("status") or "")
finish = str(order.get("finish_as") or "") finish = str(order.get("finish_as") or "")
if effective >= min_sz: 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" 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 False, effective, f"finish_as={finish}"
return True, effective, note return True, effective, note
return False, effective, "no_fill" return False, effective, "no_fill"
@@ -369,7 +397,7 @@ async def execute_signal_live(settings: Settings, sig: TradeSignal) -> dict:
if not isinstance(order, dict): if not isinstance(order, dict):
return {"status": "error", "reason": "order_response_invalid"} 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( fill_ok, filled_abs, fill_note = _market_fill_accepted(
order, order,
net_size=net_size, net_size=net_size,
+1 -1
View File
@@ -47,7 +47,7 @@ async def list_futures_positions(
cap = max(1, min(int(limit), 200)) cap = max(1, min(int(limit), 200))
try: try:
c = GateFuturesLive(settings) 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): if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}" return None, f"unexpected_response:{type(data).__name__}"
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
@@ -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": "0.4", "left": "0"}), 0.4, places=6)
self.assertAlmostEqual(_order_filled_abs({"size": "-1", "left": "-0.6"}), 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__": if __name__ == "__main__":
unittest.main() unittest.main()