Files
sanguo_vnpy_v2/sanguo_qmt_bridge/xt_gateway.py
T
claude_dev 434d7cb357
CI/CD / test (push) Failing after 7s
CI/CD / nas-deploy (push) Has been cancelled
CI/CD / nas-verify (push) Has been cancelled
feat(config): QMT 部署身份单一权威源 config/qmt_identity.json——P2-18 账号硬编码根治(⚖️-2 用户拍板:确认永为模拟号+令根治硬编码)
根因三层(全案=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>
2026-10-05 17:32:42 +08:00

329 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""xtquant 网关封装:单例 trader,连接 miniQMT 客户端。
照搬 check_xtquant.py 验证过的 xtquant 调用模式:
- XtQuantTrader(userdata, session_id) -> start() -> connect()
- StockAccount(account_id) -> subscribe(account)
- query_stock_asset / query_stock_positions / order_stock
启动时连接 miniQMT;连不上不崩溃,bridge /health 报 disconnected。
所有 xtquant import 延迟到 connect() 内部(Mac/NAS 无 xtquant 时模块仍可加载)。
"""
import logging
import os
from typing import Any
logger = logging.getLogger(__name__)
# ===== 配置(环境变量优先,fallback 到 check_xtquant.py 默认值)=====
MINIQMT_USERDATA = os.environ.get(
"MINIQMT_USERDATA",
r"D:\国金QMT交易端模拟\userdata_mini",
)
def _qmt_account() -> str:
"""部署身份链(env ACCOUNT_ID/SANGUO_QMT_ACCOUNT/BIGQMT_ACCOUNT_ID >
config/qmt_identity.json 单一权威源, P2-18 根治): 账号字面量退役。
镜像自 scripts/qmt_relogin/qmt_gate_common.load_identity(灾备腿正典),
CI 同步测钉一致; 文件缺失=FileNotFoundError fail-loud 不猜默认。
"""
for name in ("ACCOUNT_ID", "SANGUO_QMT_ACCOUNT", "BIGQMT_ACCOUNT_ID"):
v = os.environ.get(name)
if v:
return v.strip()
import json
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"]
ACCOUNT_ID = _qmt_account()
SESSION_ID = int(os.environ.get("BRIDGE_SESSION_ID", "20260710"))
# ===== 代码格式转换 =====
def to_xtquant_code(code: str) -> str:
"""sanguo 格式(sh600000/sz000001) -> xtquant 格式(600000.SH/000001.SZ)。"""
code = code.strip().lower()
if "." in code:
return code.upper()
if code.startswith("sh"):
return f"{code[2:]}.SH"
if code.startswith("sz"):
return f"{code[2:]}.SZ"
raise ValueError(f"无法识别的股票代码格式: {code}")
def to_sanguo_code(code: str) -> str:
"""xtquant 格式(600000.SH/000001.SZ) -> sanguo 格式(sh600000/sz000001)。"""
code = code.strip()
if "." not in code:
raise ValueError(f"无法识别的 xtquant 代码格式: {code}")
symbol, market = code.split(".", 1)
return f"{market.lower()}{symbol}"
# ===== 网关 =====
class XtGateway:
"""xtquant 单例网关,封装 connect/query/place_order。
connected 属性供 bridge /health 查询;未连接时 query/place_order 抛 RuntimeError。
"""
def __init__(self) -> None:
self._xt: Any = None
self._account: Any = None
self._connected: bool = False
self._last_query_ok: bool = False # 上次 query 是否成功(供 /health 探活参考)
@property
def connected(self) -> bool:
"""miniQMT 是否已连接。"""
return self._connected
def connect(self) -> bool:
"""连接 miniQMT 客户端。失败记日志不崩溃,返回 False。"""
try:
return self._open_session()
except ImportError as e:
logger.error("xtquant import 失败(检查 site-packages): %s", e)
self._connected = False
return False
except Exception as e:
logger.error("xtquant 连接异常: %s", e)
self._connected = False
return False
def _open_session(self) -> bool:
"""建 XtQuantTrader + start + connect + subscribe(延迟 import xtquant)。
从 connect() 抽出,供 reconnect() 复用。raise 异常由调用方捕获。
"""
from xtquant import xttrader
from xtquant.xttype import StockAccount
xt = xttrader.XtQuantTrader(MINIQMT_USERDATA, SESSION_ID)
xt.start()
ret = xt.connect()
if ret != 0:
logger.error(
"xtquant connect 返回 %s(miniQMT 未登录或路径错误)", ret,
)
self._connected = False
return False
account = StockAccount(ACCOUNT_ID)
try:
xt.subscribe(account)
except Exception as e:
logger.warning("subscribe 异常(可忽略,继续): %s", e)
self._xt = xt
self._account = account
self._connected = True
logger.info("xtquant 连接成功 account=%s", ACCOUNT_ID)
return True
def reconnect(self) -> bool:
"""重连:stop 旧 trader(若有),再 _open_session 重建连接。
miniQMT 客户端重启后旧 XtQuantTrader 连接失效,必须重建。
"""
if self._xt is not None and hasattr(self._xt, "stop"):
try:
self._xt.stop()
except Exception as e:
logger.warning("旧 trader stop 异常(忽略,继续重建): %s", e)
self._xt = None
self._account = None
self._connected = False
logger.info("开始重连 miniQMT ...")
return self.connect()
def is_alive(self) -> bool:
"""轻量探活:query_stock_asset 返回非空 = True。
供 /health 调用检测真实连接状态(_connected 标志可能假阳性)。
不触发 reconnect(保持轻量),由调用方决定是否重连。
"""
try:
if not self._connected:
return False
asset = self._xt.query_stock_asset(self._account)
alive = asset is not None
self._last_query_ok = alive
return alive
except Exception as e:
logger.debug("is_alive 探活异常: %s", e)
self._last_query_ok = False
return False
def _retry_with_reconnect(self, fn: Any, fail_msg: str) -> Any:
"""执行 fn(),失败(异常)时 reconnect 一次再重试。
重连仍失败则 raise(由调用方按原逻辑降级返回空/False)。
Args:
fn: 无参可调用,执行实际 query/order。
fail_msg: 日志标识(如 "query_account")。
"""
try:
result = fn()
self._last_query_ok = True
return result
except Exception as e:
self._last_query_ok = False
logger.warning("%s 首次失败,尝试重连: %s", fail_msg, e)
if not self.reconnect():
raise RuntimeError(f"重连失败,放弃 {fail_msg}: {e}") from e
result = fn() # 重试一次(不再重连)
self._last_query_ok = True
return result
def query_account(self) -> dict[str, float]:
"""查资金:{cash, frozen, market_value, total}(断线自动重连重试一次)。"""
def _do() -> dict[str, float]:
if not self._connected:
raise RuntimeError("xtquant 未连接")
asset = self._xt.query_stock_asset(self._account)
if asset is None:
raise RuntimeError("query_stock_asset 返回空(账户ID/权限问题或断线)")
return {
"cash": float(asset.cash),
"frozen": float(asset.frozen_cash),
"market_value": float(asset.market_value),
"total": float(asset.total_asset),
}
return self._retry_with_reconnect(_do, "query_account")
def query_positions(self) -> list[dict[str, Any]]:
"""查持仓:[{code, volume, can_use, avg_price}],code 已转 sanguo 格式。
None(断线)触发重连重试,空列表 [](真没持仓)是正常结果。
"""
def _do() -> list[dict[str, Any]]:
if not self._connected:
raise RuntimeError("xtquant 未连接")
positions = self._xt.query_stock_positions(self._account)
if positions is None:
raise RuntimeError("query_stock_positions 返回 None(疑似断线)")
return [
{
"code": to_sanguo_code(p.stock_code),
"volume": int(p.volume),
"can_use": int(p.can_use_volume),
"avg_price": float(p.avg_price),
}
for p in positions
]
return self._retry_with_reconnect(_do, "query_positions")
def place_order(
self,
code: str,
action: str,
price: float,
volume: int,
price_type: str = "limit",
) -> int:
"""下单,返回 order_id(>0 = 报单成功)。
断线(调用抛异常)自动重连重试一次;order_id<=0 是 broker 拒单
(如非交易日/资金不足),不重试直接返回。
Args:
code: sanguo 格式(sh600000/sz000001),内部转 xtquant 格式。
action: "buy" / "sell"。
price: 委托价格(市价单忽略)。
volume: 委托数量(股)。
price_type: "limit"(限价 FIX_PRICE) / "market"(市价最新 LATEST_PRICE)。
Raises:
ValueError: action/price_type/code 不合法。
RuntimeError: xtquant 未连接 / 重连失败。
"""
def _do() -> int:
if not self._connected:
raise RuntimeError("xtquant 未连接")
from xtquant import xtconstant
order_type = (
xtconstant.STOCK_BUY if action == "buy" else xtconstant.STOCK_SELL
)
xt_price_type = (
xtconstant.FIX_PRICE
if price_type == "limit"
else xtconstant.LATEST_PRICE
)
xt_code = to_xtquant_code(code)
order_id = self._xt.order_stock(
self._account,
xt_code,
order_type,
volume,
xt_price_type,
price,
"sanguo_bridge",
"",
)
return int(order_id)
return self._retry_with_reconnect(_do, "place_order")
def query_orders(self) -> list[dict[str, Any]]:
"""查委托列表:[{order_id, code, status, status_name, volume, traded, price}]。"""
def _do() -> list[dict[str, Any]]:
if not self._connected:
raise RuntimeError("xtquant 未连接")
orders = self._xt.query_stock_orders(self._account)
if not orders:
return []
# xtquant order_status 实测映射(50=挂单,56=已成,57=拒单,54=撤单)
_STATUS = {48: "unknown", 50: "pending", 51: "reporting", 52: "reported",
53: "reported", 54: "canceled", 55: "canceled",
56: "filled", 57: "rejected"}
result = []
for o in orders:
try:
st = getattr(o, "order_status", None)
code = getattr(o, "stock_code", None)
result.append({
"order_id": getattr(o, "order_id", None),
"code": to_sanguo_code(code) if code else None,
"status": st,
"status_name": _STATUS.get(st, str(st)),
"status_msg": getattr(o, "status_msg", "") or "",
"volume": int(getattr(o, "order_volume", 0) or 0),
"traded": int(getattr(o, "traded_volume", 0) or 0),
"price": float(getattr(o, "price", 0) or 0),
})
except Exception as e:
logger.warning("query_orders 解析异常: %s", e)
return result
return self._retry_with_reconnect(_do, "query_orders")
def cancel_order(self, order_id: int) -> int:
"""撤单,返回 order_id(>0=撤单请求受理,实际状态查 /orders)。"""
def _do() -> int:
if not self._connected:
raise RuntimeError("xtquant 未连接")
return int(self._xt.cancel_order_stock(self._account, int(order_id)))
return self._retry_with_reconnect(_do, "cancel_order")
# 模块级单例(bridge.py 启动时调 connect)
gateway = XtGateway()