8c06ef1e53
bridge.py: POST /cancel + GET /orders; xt_gateway: query_orders/cancel_order(含xtquant状态映射50挂单/56已成/57拒单/54撤单); bridge_client: get_orders/cancel_order. 已在 VPS 生产验证.
308 lines
11 KiB
Python
308 lines
11 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",
|
||
)
|
||
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 返回 %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()
|