e77c9df0d4
- bridge_client: from_bridge_code(sh/sz→纯数字码, to_bridge_code逆函数) - live_orchestrator: reconcile_from_bridge 读bridge /account /positions校正account现金+持仓+持久化, 默认mode_b=false - live_step step8: 影子后调reconcile(mode_b=true生效, mode_b=false跳过) - config: live.mode_b开关(默认false模式A) - test_reconcile: 10例(cash/positions校正+code转换+失败降级+mode_b跳过) - NAS环境15 passed(reconcile10+shadow5无回归) - 真桥集成: live_step mode_b=true → reconcile读bridge → account校正(1000万/空仓=bridge真实账本)+持久化 安全: mode_b默认关+bridge失败降级不阻断+token走env
113 lines
4.5 KiB
Python
113 lines
4.5 KiB
Python
"""sanguo 端 QMT bridge HTTP 客户端(D-3 影子下单)。
|
||
|
||
封装跨网调 Windows bridge 的 HTTP 调用(POST /order、GET /account、GET /positions)。
|
||
设计原则:失败不抛异常,记 logger.warning 返回 None —— 影子下单是旁路,
|
||
绝不能阻断 PaperEngine 的模拟盘撮合主流程(spec §5 模式 A)。
|
||
|
||
依赖:仅标准库 urllib(不引入 requests/httpx),避免新增第三方依赖。
|
||
接口契约见 sanguo_qmt_bridge/README.md。
|
||
"""
|
||
import json
|
||
import logging
|
||
import urllib.error
|
||
import urllib.request
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_TIMEOUT = 10 # 秒
|
||
|
||
|
||
def to_bridge_code(symbol: str) -> str:
|
||
"""sanguo symbol → bridge code(sh600000 / sz000001)。
|
||
|
||
sanguo 内部 symbol 为纯数字码(如 '600000'、'000001'),
|
||
paper_trades.symbol 即此格式(见 engine._match 的 save_trade 入参)。
|
||
bridge 接受 sh/sz 前缀码(见 sanguo_qmt_bridge/README.md「代码格式」)。
|
||
|
||
规则(与 sanguo_data.datareader.guess_exchange 一致,避免在客户端 import vnpy):
|
||
沪市 SSE:60/68/51/56/58 开头 → sh
|
||
深市 SZSE:00/30/15 开头 → sz
|
||
默认 → sh(guess_exchange 兜底亦为 SSE)
|
||
|
||
若 symbol 已含 sh/sz 前缀,直接小写返回。
|
||
"""
|
||
if symbol[:2].lower() in ("sh", "sz"):
|
||
return symbol.lower()
|
||
if symbol.startswith(("60", "68", "51", "56", "58")):
|
||
return f"sh{symbol}"
|
||
if symbol.startswith(("00", "30", "15")):
|
||
return f"sz{symbol}"
|
||
return f"sh{symbol}"
|
||
|
||
|
||
def from_bridge_code(code: str) -> str:
|
||
"""bridge code(sh600000/sz000001)→ sanguo 纯数字码(to_bridge_code 逆函数)。
|
||
|
||
D-4c 模式 B reconcile:bridge /positions 返回 sh/sz 前缀码,
|
||
account.positions 以纯数字码为 key(与 PaperEngine trade.symbol 一致)。
|
||
已是纯数字则原样返回。
|
||
"""
|
||
if code[:2].lower() in ("sh", "sz"):
|
||
return code[2:]
|
||
return code
|
||
|
||
|
||
class BridgeClient:
|
||
"""QMT bridge HTTP 客户端(影子下单旁路)。
|
||
|
||
每个请求带 header X-Bridge-Token。任何网络/解析失败均返回 None 并记 warning,
|
||
不向调用方抛异常 —— 影子下单失败不得阻断 live_step 主流程。
|
||
"""
|
||
|
||
def __init__(self, url: str, token: str) -> None:
|
||
self._url = url.rstrip("/")
|
||
self._token = token
|
||
|
||
def _headers(self) -> dict[str, str]:
|
||
return {"X-Bridge-Token": self._token, "Content-Type": "application/json"}
|
||
|
||
def _post(self, path: str, payload: dict[str, Any]) -> dict | None:
|
||
"""POST JSON,返回解析后 dict;失败记 warning 返回 None。"""
|
||
data = json.dumps(payload).encode("utf-8")
|
||
req = urllib.request.Request(
|
||
f"{self._url}{path}", data=data, headers=self._headers(), method="POST"
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
||
return json.loads(resp.read().decode("utf-8"))
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as e:
|
||
logger.warning("bridge POST %s 失败: %s", path, e)
|
||
return None
|
||
|
||
def _get(self, path: str) -> dict | None:
|
||
"""GET JSON,返回解析后 dict;失败记 warning 返回 None。"""
|
||
req = urllib.request.Request(
|
||
f"{self._url}{path}", headers=self._headers(), method="GET"
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
||
return json.loads(resp.read().decode("utf-8"))
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as e:
|
||
logger.warning("bridge GET %s 失败: %s", path, e)
|
||
return None
|
||
|
||
def place_order(self, code: str, action: str, price: float, volume: int,
|
||
price_type: str = "limit", reason: str = "") -> dict | None:
|
||
"""POST /order → {ok: bool, order_id: int} 或 {ok: false, error: str};失败返回 None。"""
|
||
return self._post("/order", {
|
||
"code": code, "action": action, "price": price,
|
||
"volume": volume, "price_type": price_type, "reason": reason,
|
||
})
|
||
|
||
def get_account(self) -> dict | None:
|
||
"""GET /account → {ok, cash, frozen, market_value, total};失败返回 None。"""
|
||
return self._get("/account")
|
||
|
||
def get_positions(self) -> list | None:
|
||
"""GET /positions → positions 列表;失败返回 None。"""
|
||
resp = self._get("/positions")
|
||
if resp is None:
|
||
return None
|
||
return resp.get("positions")
|