"""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") def get_orders(self) -> list | None: """GET /orders → 委托列表;失败返回 None。""" resp = self._get("/orders") if resp is None: return None return resp.get("orders") def cancel_order(self, order_id: int) -> dict | None: """POST /cancel → {ok, order_id} 或 {ok:false, error};失败返回 None。""" return self._post("/cancel", {"order_id": int(order_id)})