434d7cb357
根因三层(全案=audit/20261005_qmt_identity_env_plan.md): schtask 无 per-task env+机器级 env 未设→wrapper 被迫字面量;「部署身份」无单一权威源(env 名分裂三个);模拟号零安全压力致 32 文件 88 处 copy-paste 扩散。 - qmt_gate_common.load_identity 正典装载器(:14-59): env SANGUO_QMT_ACCOUNT/BIGQMT_ACCOUNT_ID(兼容别名)>json>RuntimeError fail-loud;SANGUO_IDENTITY_JSON 显式指路不存在=立即报错;查找=repo 相对>C:\sanguo_vnpy_v2\config\(生产副本回退位) - 灾备腿同源: relogin/qmt_bridge_probe/setclip 改 from qmt_gate_common import ACCOUNT - 桥腿镜像同链: xt_gateway._qmt_account/qmt_gateway_client._identity_default_account/is_price_source_vps/bridge_switch_ops(3 处 env 注入+1 处内嵌探针串)/check_xtquant——env 带默认的硬编码默认值全数退役 - xt_eod_wrapper.ps1 改 Get-Content identity json(保持 ASCII);码内 docstring 字面量→占位符(engine/runner/runner_live/live.yaml 注释/README) - .gitignore 白名单 !config/qmt_identity.json(全局 *.json 曾吞之,git check-ignore 实证) - tests/trader/test_qmt_identity.py 五钉: 装载链语义(env 双名覆盖/显式指路 fail)+单源一致性(identity≡live.yaml≡watch_accounts≡gate_common≡各腿镜像,休市表 CI 同步测先例)+码面字面量 grep-pin(生产码面零 66639661,只 config 三件套可含) - 监控 spec: §3.2 账号来源 bullet/§9.A-18 坑条/§12 行数+行号勘正(插入块致 bridge_ping/calendar_probe/relogin_running 引用行号推移);runbook: 非代码资产两行(副本回退路径+生产 wrapper 待改清单=10-08 窗口后 §五③) 生产 wrapper(C:\sanguo_bigqmt 库外)字面量收尾=10-08 复市验证窗口后非代码资产流程;VPS 班车同窗口(护 10-08 07:50 relogin 首班旧码纯净)。trader+api 620 绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
194 lines
8.2 KiB
Python
194 lines
8.2 KiB
Python
"""vnpy_qmt 进程内执行客户端(Phase 2:替掉 HTTP bridge)。
|
||
|
||
与 ``bridge_client.BridgeClient`` 同接口(place_order/get_account/get_positions/
|
||
get_orders/cancel_order),但底层是**进程内 QmtGateway 直连 miniQMT**(同机同会话,
|
||
xtquant 进程内调用),不经 HTTP bridge。
|
||
|
||
设计:
|
||
- ``_QmtExec`` 单例持有长生命周期的 EventEngine + QmtGateway,懒连接(首次用即连),
|
||
跨 live_step 调用复用。gateway 的定时器周期性 query account/position/order 并经事件回调
|
||
推入缓存,故 get_account/get_positions 读缓存即同步返回。
|
||
- ``QmtGatewayClient`` 包装单例 + 缓存,方法签名/返回与 BridgeClient 对齐,live_orchestrator
|
||
可零改调用方代码替换。
|
||
|
||
配置(env):SANGUO_QMT_ACCOUNT(交易账号,不设则读部署身份单一权威源
|
||
config/qmt_identity.json,P2-18)、SANGUO_QMT_PATH(userdata_mini
|
||
路径,不设则扫 C:\\ 自动发现,避免中文路径字面量)。
|
||
|
||
依赖:vnpy 4.4.0 源码(sys.path)+ vnpy_qmt 0.3.3(pip)+ miniQMT 同机运行。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import json
|
||
import os
|
||
import threading
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_singleton: _QmtExec | None = None
|
||
_lock = threading.Lock()
|
||
|
||
|
||
def _parse_code(code: str) -> tuple[str, Any]:
|
||
"""bridge code(sh600000/sz000001)→ (symbol, Exchange)。兼容纯数字码。"""
|
||
from vnpy.trader.constant import Exchange
|
||
c = code.lower()
|
||
if c.startswith("sh"):
|
||
return code[2:], Exchange.SSE
|
||
if c.startswith("sz"):
|
||
return code[2:], Exchange.SZSE
|
||
# 纯数字码兜底(与 bridge_client.to_bridge_code 一致)
|
||
if code.startswith(("60", "68", "51", "56", "58")):
|
||
return code, Exchange.SSE
|
||
return code, Exchange.SZSE
|
||
|
||
|
||
def _to_bridge_code(symbol: str, exchange: Any) -> str:
|
||
"""(symbol, Exchange) → bridge code(sh600000),get_positions 输出对齐 bridge。"""
|
||
from vnpy.trader.constant import Exchange
|
||
return f"sh{symbol}" if exchange == Exchange.SSE else f"sz{symbol}"
|
||
|
||
|
||
class _QmtExec:
|
||
"""长生命周期 vnpy_qmt 执行器单例:EventEngine + QmtGateway + 行情/账本缓存。"""
|
||
|
||
def __init__(self) -> None:
|
||
from vnpy.event import EventEngine
|
||
from vnpy.trader.event import EVENT_ACCOUNT, EVENT_POSITION, EVENT_ORDER
|
||
from vnpy_qmt import QmtGateway
|
||
|
||
self.account: Any = None # 最新 AccountData
|
||
self.positions: dict[str, Any] = {} # vt_symbol -> PositionData
|
||
self.orders: dict[str, Any] = {} # vt_orderid -> OrderData
|
||
|
||
self.ee = EventEngine()
|
||
self.ee.register(EVENT_ACCOUNT, self._on_account)
|
||
self.ee.register(EVENT_POSITION, self._on_position)
|
||
self.ee.register(EVENT_ORDER, self._on_order)
|
||
self.ee.start()
|
||
|
||
self.gateway = QmtGateway(self.ee)
|
||
self.gateway.connect(self._setting())
|
||
logger.info("QmtGatewayClient 连接 miniQMT: %s", self._setting().get("交易账号"))
|
||
|
||
@staticmethod
|
||
def _setting() -> dict[str, str]:
|
||
account = (os.environ.get("SANGUO_QMT_ACCOUNT")
|
||
or os.environ.get("BIGQMT_ACCOUNT_ID")
|
||
or _identity_default_account())
|
||
path = os.environ.get("SANGUO_QMT_PATH") or _find_userdata_mini()
|
||
return {"交易账号": account, "mini路径": path}
|
||
|
||
def _on_account(self, event: Any) -> None:
|
||
self.account = event.data
|
||
|
||
def _on_position(self, event: Any) -> None:
|
||
p = event.data
|
||
self.positions[p.vt_symbol] = p
|
||
|
||
def _on_order(self, event: Any) -> None:
|
||
o = event.data
|
||
self.orders[o.vt_orderid] = o
|
||
|
||
|
||
def _identity_default_account() -> str:
|
||
"""部署身份单一权威源 config/qmt_identity.json(P2-18 根治)——env 不设时
|
||
的账号兜底; 镜像自 scripts/qmt_relogin/qmt_gate_common(灾备腿正典),
|
||
CI 同步测钉一致; 文件缺失=FileNotFoundError fail-loud 不猜默认。"""
|
||
p = os.path.normpath(os.path.join(
|
||
os.path.dirname(os.path.abspath(__file__)), "..", "config",
|
||
"qmt_identity.json"))
|
||
with open(p, encoding="utf-8") as f:
|
||
return json.load(f)["default_account"]
|
||
|
||
|
||
def _find_userdata_mini() -> str:
|
||
"""扫 C:\\ 找 userdata_mini 目录(避免中文路径字面量编码问题)。"""
|
||
try:
|
||
for name in os.listdir("C:\\"):
|
||
cand = os.path.join("C:\\", name, "userdata_mini")
|
||
if os.path.isdir(cand):
|
||
return cand
|
||
except OSError:
|
||
pass
|
||
return ""
|
||
|
||
|
||
def get_qmt_exec() -> _QmtExec:
|
||
"""懒加载单例(线程安全)。首次调用连 miniQMT,之后复用。"""
|
||
global _singleton
|
||
if _singleton is None:
|
||
with _lock:
|
||
if _singleton is None:
|
||
_singleton = _QmtExec()
|
||
return _singleton
|
||
|
||
|
||
class QmtGatewayClient:
|
||
"""进程内 vnpy_qmt 执行客户端,接口对齐 BridgeClient。
|
||
|
||
构造签名兼容 BridgeClient(url, token)——url/token 被忽略(进程内无需),
|
||
便于 live_orchestrator 零改替换。
|
||
"""
|
||
|
||
def __init__(self, url: str = "", token: str = "") -> None:
|
||
self._exec = get_qmt_exec()
|
||
|
||
def place_order(self, code: str, action: str, price: float, volume: int,
|
||
price_type: str = "limit", reason: str = "") -> dict | None:
|
||
"""下单 → {ok: True, order_id: vt_orderid} 或 None。"""
|
||
try:
|
||
from vnpy.trader.constant import Direction, OrderType, Offset
|
||
from vnpy.trader.object import OrderRequest
|
||
symbol, exchange = _parse_code(code)
|
||
direction = Direction.LONG if action == "buy" else Direction.SHORT
|
||
otype = OrderType.LIMIT if price_type == "limit" else OrderType.MARKET
|
||
req = OrderRequest(symbol=symbol, exchange=exchange, direction=direction,
|
||
type=otype, volume=volume, price=price,
|
||
offset=Offset.NONE, reference=reason or "")
|
||
vt_orderid = self._exec.gateway.send_order(req)
|
||
return {"ok": True, "order_id": vt_orderid}
|
||
except Exception as e: # noqa: BLE001 影子下单绝不阻断 live_step
|
||
logger.warning("QmtGatewayClient.place_order 失败 code=%s: %s", code, e)
|
||
return None
|
||
|
||
def get_account(self) -> dict | None:
|
||
a = self._exec.account
|
||
if a is None:
|
||
return None
|
||
mv = sum(p.volume * p.price for p in self._exec.positions.values())
|
||
return {"ok": True, "cash": a.balance - mv, "frozen": a.frozen,
|
||
"market_value": mv, "total": a.balance}
|
||
|
||
def get_positions(self) -> list | None:
|
||
return [{"code": _to_bridge_code(p.symbol, p.exchange), "volume": int(p.volume),
|
||
"can_use": int(p.yd_volume), "avg_price": float(p.price)}
|
||
for p in self._exec.positions.values() if p.volume > 0]
|
||
|
||
def get_orders(self) -> list | None:
|
||
from vnpy.trader.constant import Status
|
||
return [{"vt_orderid": o.vt_orderid, "code": _to_bridge_code(o.symbol, o.exchange),
|
||
"direction": "buy" if o.direction.value == "多" else "sell",
|
||
"price": o.price, "volume": o.volume, "traded": o.traded,
|
||
"status": o.status.name, "reference": o.reference}
|
||
for o in self._exec.orders.values()]
|
||
|
||
def cancel_order(self, order_id) -> dict | None:
|
||
try:
|
||
from vnpy.trader.object import CancelRequest
|
||
oid = str(order_id)
|
||
vt = oid if oid.startswith("QMT.") else f"QMT.{oid}"
|
||
o = self._exec.orders.get(vt)
|
||
if o is None:
|
||
return {"ok": False, "error": f"order {oid} 不在缓存(gateway 未收到该单事件)"}
|
||
remark = vt[4:] # vt_orderid(QMT.184246#1)→ remark(184246#1),vnpy_qmt 按 remark 建 key
|
||
# vnpy 4.4.0 的 CancelRequest 必填 (orderid, symbol, exchange),缺 symbol/exchange 会 TypeError
|
||
self._exec.gateway.cancel_order(
|
||
CancelRequest(orderid=remark, symbol=o.symbol, exchange=o.exchange))
|
||
return {"ok": True, "order_id": order_id}
|
||
except Exception as e: # noqa: BLE001
|
||
logger.warning("QmtGatewayClient.cancel_order 失败 %s: %s", order_id, e)
|
||
return {"ok": False, "error": str(e)}
|