修复持仓
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,16 +96,42 @@ 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
|
||||||
|
if isinstance(row, dict):
|
||||||
|
return _float(row.get("size"))
|
||||||
return 0.0
|
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(
|
||||||
client: GateFuturesLive,
|
client: GateFuturesLive,
|
||||||
*,
|
*,
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user