Files
sanguo_vnpy_v2/sanguo_qmt_bridge/xt_gateway.py
T
claude_dev 8c06ef1e53 feat(bridge): 补 /orders+/cancel 端点与客户端(实盘查委托/撤单)
bridge.py: POST /cancel + GET /orders; xt_gateway: query_orders/cancel_order(含xtquant状态映射50挂单/56已成/57拒单/54撤单); bridge_client: get_orders/cancel_order. 已在 VPS 生产验证.
2026-07-15 07:12:46 +08:00

308 lines
11 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",
)
ACCOUNT_ID = os.environ.get("ACCOUNT_ID", "66639661")
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 返回 %sminiQMT 未登录或路径错误)", 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()