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>
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""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()
|