feat(bridge): bridge稳定性完善—自动重连miniQMT+health探活+交易日判断

- xt_gateway: _open_session抽离, reconnect(stop旧trader+重建), is_alive(query探活), _retry_with_reconnect(query/order失败重连重试一次)
- query_account/positions/place_order包重试: 断线(异常/None)→reconnect→重试, broker拒单(order_id<=0)不重连
- bridge /health: is_alive真实探活(5s缓存)+断线后台reconnect(不阻塞), 不再假阳性
- trade_calendar: is_trading_day(周一-周五), /order非交易日加warning(120141提示)
- test_gateway15+test_trade_calendar6=NAS21passed, 回归bridge_client/d4a 10绿
- 修复Issue#4运维发现: miniQMT重启后bridge自动重连(无需手动重启)
This commit is contained in:
2026-07-11 07:32:23 +08:00
parent a93d5ed8d8
commit cadc59e6dc
5 changed files with 488 additions and 77 deletions
+41 -3
View File
@@ -7,6 +7,8 @@ token 鉴权(auth.py),/health 豁免。
启动:uvicorn bridge:app --host 127.0.0.1 --port 8765
"""
import logging
import threading
import time
from contextlib import asynccontextmanager
from typing import Literal
@@ -14,6 +16,7 @@ from fastapi import Depends, FastAPI
from pydantic import BaseModel, Field
from auth import verify_token
from trade_calendar import is_trading_day
from xt_gateway import gateway
logging.basicConfig(
@@ -51,12 +54,44 @@ async def lifespan(_app: FastAPI):
app = FastAPI(title="sanguo QMT bridge", version="0.1.0", lifespan=lifespan)
# ===== /health 探活缓存(避免每次 health 都 query_stock_asset=====
_PROBE_CACHE_SECONDS = 5.0
_last_probe_time: float = 0.0
_last_probe_alive: bool = False
def _probe_alive() -> bool:
"""探活 miniQMT 真实连接(5 秒缓存)。
缓存过期时调 gateway.is_alive()query_stock_asset 探活);
探活失败且 gateway 自认已连接 → 后台触发 reconnect(不阻塞 health 响应)。
"""
global _last_probe_time, _last_probe_alive
now = time.monotonic()
if now - _last_probe_time < _PROBE_CACHE_SECONDS:
return _last_probe_alive
alive = gateway.is_alive()
_last_probe_time = now
_last_probe_alive = alive
if not alive and gateway.connected:
# _connected 假阳性(miniQMT 可能重启),后台重连不阻塞响应
threading.Thread(target=gateway.reconnect, daemon=True).start()
return alive
# ===== 接口 =====
@app.get("/health")
async def health() -> dict:
"""健康检查(无需鉴权,供 frpc/Caddy 探活)。"""
return {"status": "ok", "miniqmt_connected": gateway.connected}
"""健康检查(无需鉴权,供 frpc/Caddy 探活)。
miniqmt_connected 调 gateway.is_alive() 真实探活(5 秒缓存),
不再依赖可能假阳性的 _connected 标志。
"""
return {"status": "ok", "miniqmt_connected": _probe_alive()}
@app.post("/order", dependencies=[Depends(verify_token)])
@@ -86,7 +121,10 @@ async def place_order(req: OrderRequest) -> dict:
if order_id <= 0:
return {"ok": False, "error": f"报单失败 order_id={order_id}"}
logger.info("下单成功 order_id=%s code=%s action=%s", order_id, req.code, req.action)
return {"ok": True, "order_id": order_id}
result: dict = {"ok": True, "order_id": order_id}
if not is_trading_day():
result["warning"] = "非交易日,miniQMT 可能拒绝(120141)"
return result
@app.get("/account", dependencies=[Depends(verify_token)])
+24
View File
@@ -0,0 +1,24 @@
"""交易日判断(首版仅周末判断,节假日后续扩展)。
TODO: 后续接入 A 股法定节假日列表(交易所日历数据源或手动配置文件)。
"""
from datetime import date
def is_trading_day(day: date | None = None) -> bool:
"""判断是否为 A 股交易日。
首版:周一至周五 True,周六周日 False。
法定节假日(国庆/春节/清明等)暂不处理——miniQMT broker 会自行拒绝,
调用方可根据返回值在响应中加 warning 提示。
Args:
day: 待判断日期,None = 今天。
Returns:
True = 工作日(大概率交易日),False = 周末。
"""
if day is None:
day = date.today()
# TODO: 接入 A 股法定节假日列表(目前仅排除周末)
return day.weekday() < 5 # 0=Mon..4=Fri, 5=Sat, 6=Sun
+92 -9
View File
@@ -58,6 +58,7 @@ class XtGateway:
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:
@@ -67,14 +68,24 @@ class XtGateway:
def connect(self) -> bool:
"""连接 miniQMT 客户端。失败记日志不崩溃,返回 False。"""
try:
from xtquant import xttrader
from xtquant.xttype import StockAccount
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
try:
xt = xttrader.XtQuantTrader(MINIQMT_USERDATA, SESSION_ID)
xt.start()
ret = xt.connect()
@@ -96,18 +107,72 @@ class XtGateway:
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.error("xtquant 连接异常: %s", 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}。"""
"""查资金:{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/权限问题)")
raise RuntimeError("query_stock_asset 返回空(账户ID/权限问题或断线")
return {
"cash": float(asset.cash),
"frozen": float(asset.frozen_cash),
@@ -115,11 +180,20 @@ class XtGateway:
"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 格式。"""
"""查持仓:[{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) or []
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),
@@ -130,6 +204,8 @@ class XtGateway:
for p in positions
]
return self._retry_with_reconnect(_do, "query_positions")
def place_order(
self,
code: str,
@@ -140,6 +216,9 @@ class XtGateway:
) -> int:
"""下单,返回 order_id>0 = 报单成功)。
断线(调用抛异常)自动重连重试一次;order_id<=0 是 broker 拒单
(如非交易日/资金不足),不重试直接返回。
Args:
code: sanguo 格式(sh600000/sz000001),内部转 xtquant 格式。
action: "buy" / "sell"
@@ -149,8 +228,10 @@ class XtGateway:
Raises:
ValueError: action/price_type/code 不合法。
RuntimeError: xtquant 未连接。
RuntimeError: xtquant 未连接 / 重连失败
"""
def _do() -> int:
if not self._connected:
raise RuntimeError("xtquant 未连接")
from xtquant import xtconstant
@@ -177,6 +258,8 @@ class XtGateway:
)
return int(order_id)
return self._retry_with_reconnect(_do, "place_order")
# 模块级单例(bridge.py 启动时调 connect
gateway = XtGateway()
+229
View File
@@ -0,0 +1,229 @@
"""D-2 gateway 稳定性测试:自动重连 + 探活(mock xtquantMac 无 xtquant)。
注入 mock xtquant 模块到 sys.modules,验证:
- connect 成功/失败
- query 失败 → reconnect → 重试一次(query_account / query_positions / place_order
- place_order broker 拒单(order_id<=0) 不触发重连重试
- is_alivequery_stock_asset 非空=TrueNone/异常/未连接=False
- reconnect 显式调用 stop 旧 trader
"""
import sys
import types
from unittest.mock import MagicMock
import pytest
from sanguo_qmt_bridge.xt_gateway import XtGateway
# ===== mock xtquant 注入/清理工具 =====
def _install_mock_xtquant(*, connect_ret=0, asset=None, positions=None, order_id=1):
"""注入 mock xtquant 到 sys.modules,返回 (mock_xt, patches)。"""
mock_xt = MagicMock()
mock_xt.connect.return_value = connect_ret
mock_xt.query_stock_asset.return_value = asset
mock_xt.query_stock_positions.return_value = positions
mock_xt.order_stock.return_value = order_id
xttrader_mod = types.ModuleType("xtquant.xttrader")
xttrader_mod.XtQuantTrader = MagicMock(return_value=mock_xt)
xttype_mod = types.ModuleType("xtquant.xttype")
xttype_mod.StockAccount = MagicMock()
xtconstant_mod = types.ModuleType("xtquant.xtconstant")
xtconstant_mod.STOCK_BUY = 23
xtconstant_mod.STOCK_SELL = 24
xtconstant_mod.FIX_PRICE = 11
xtconstant_mod.LATEST_PRICE = 5
xtquant_mod = types.ModuleType("xtquant")
patches: dict[str, object] = {}
for name, mod in [
("xtquant", xtquant_mod),
("xtquant.xttrader", xttrader_mod),
("xtquant.xttype", xttype_mod),
("xtquant.xtconstant", xtconstant_mod),
]:
patches[name] = sys.modules.get(name)
sys.modules[name] = mod
return mock_xt, patches
def _remove_mock_xtquant(patches):
"""恢复 sys.modules 到注入前状态。"""
for name, mod in patches.items():
if mod is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = mod
@pytest.fixture
def mock_xtquant():
"""注入 mock xtquantyield mock_xt 供测试调整返回值,结束后清理。"""
mock_asset = MagicMock(
cash=100000.0, frozen_cash=0.0, market_value=50000.0, total_asset=150000.0,
)
mock_xt, patches = _install_mock_xtquant(
connect_ret=0, asset=mock_asset, positions=[], order_id=12345,
)
yield mock_xt
_remove_mock_xtquant(patches)
def _make_mock_position(code="600000.SH", volume=100, can_use=100, avg_price=10.5):
"""构造 mock 持仓对象(属性与 xtquant XtPosition 一致)。"""
return MagicMock(
stock_code=code, volume=volume, can_use_volume=can_use, avg_price=avg_price,
)
# ===== connect =====
class TestConnect:
def test_connect_success(self, mock_xtquant):
gw = XtGateway()
assert gw.connect() is True
assert gw.connected is True
def test_connect_failure_returns_false(self, mock_xtquant):
mock_xtquant.connect.return_value = -1
gw = XtGateway()
assert gw.connect() is False
assert gw.connected is False
# ===== reconnect =====
class TestReconnect:
def test_reconnect_stops_old_trader(self, mock_xtquant):
gw = XtGateway()
gw.connect()
assert mock_xtquant.stop.call_count == 0
gw.reconnect()
assert mock_xtquant.stop.call_count == 1
assert gw.connected is True
def test_reconnect_returns_true_on_success(self, mock_xtquant):
gw = XtGateway()
gw.connect()
assert gw.reconnect() is True
# ===== query 重连重试 =====
class TestQueryRetry:
def test_query_account_reconnects_then_succeeds(self, mock_xtquant):
"""首次 query_stock_asset 返回 None → 重连 → 重试成功。"""
mock_asset = MagicMock(
cash=80000.0, frozen_cash=0.0, market_value=40000.0, total_asset=120000.0,
)
mock_xtquant.query_stock_asset.side_effect = [None, mock_asset]
gw = XtGateway()
gw.connect()
data = gw.query_account()
assert data["cash"] == 80000.0
assert data["total"] == 120000.0
assert gw._last_query_ok is True
assert mock_xtquant.stop.call_count == 1 # 确实触发了 reconnect
def test_query_account_fails_after_retry(self, mock_xtquant):
"""重连后仍返回 None → RuntimeError 透出。"""
mock_xtquant.query_stock_asset.return_value = None
gw = XtGateway()
gw.connect()
with pytest.raises(RuntimeError, match="query_stock_asset 返回空"):
gw.query_account()
assert gw._last_query_ok is False
def test_query_positions_reconnects_then_succeeds(self, mock_xtquant):
"""首次 query_stock_positions 返回 None → 重连 → 重试返回持仓列表。"""
pos = _make_mock_position()
mock_xtquant.query_stock_positions.side_effect = [None, [pos]]
gw = XtGateway()
gw.connect()
positions = gw.query_positions()
assert len(positions) == 1
assert positions[0]["code"] == "sh600000"
assert positions[0]["volume"] == 100
assert mock_xtquant.stop.call_count == 1
def test_query_positions_empty_list_is_not_disconnection(self, mock_xtquant):
"""空列表 [](真没持仓)不触发重连。"""
mock_xtquant.query_stock_positions.return_value = []
gw = XtGateway()
gw.connect()
positions = gw.query_positions()
assert positions == []
assert mock_xtquant.stop.call_count == 0 # 没重连
assert gw._last_query_ok is True
def test_place_order_reconnects_on_exception(self, mock_xtquant):
"""order_stock 首次抛异常 → 重连 → 重试成功。"""
mock_xtquant.order_stock.side_effect = [Exception("连接断开"), 99999]
gw = XtGateway()
gw.connect()
order_id = gw.place_order("sh600000", "buy", 10.5, 100)
assert order_id == 99999
assert mock_xtquant.stop.call_count == 1
assert gw._last_query_ok is True
def test_place_order_no_retry_on_broker_reject(self, mock_xtquant):
"""order_id<=0broker 拒单)不触发重连,直接返回。"""
mock_xtquant.order_stock.return_value = -1
gw = XtGateway()
gw.connect()
order_id = gw.place_order("sh600000", "buy", 10.5, 100)
assert order_id == -1
assert mock_xtquant.stop.call_count == 0 # 没重连
# ===== is_alive =====
class TestIsAlive:
def test_is_alive_true_when_asset_returned(self, mock_xtquant):
gw = XtGateway()
gw.connect()
assert gw.is_alive() is True
assert gw._last_query_ok is True
def test_is_alive_false_when_none(self, mock_xtquant):
mock_xtquant.query_stock_asset.return_value = None
gw = XtGateway()
gw.connect()
assert gw.is_alive() is False
assert gw._last_query_ok is False
def test_is_alive_false_when_not_connected(self, mock_xtquant):
gw = XtGateway() # 未 connect
assert gw.is_alive() is False
def test_is_alive_false_on_exception(self, mock_xtquant):
mock_xtquant.query_stock_asset.side_effect = RuntimeError("断线")
gw = XtGateway()
gw.connect()
assert gw.is_alive() is False # 不崩溃
assert gw._last_query_ok is False
def test_is_alive_does_not_trigger_reconnect(self, mock_xtquant):
"""is_alive 保持轻量,探活失败不自动重连。"""
mock_xtquant.query_stock_asset.return_value = None
gw = XtGateway()
gw.connect()
gw.is_alive()
assert mock_xtquant.stop.call_count == 0 # 不重连
+37
View File
@@ -0,0 +1,37 @@
"""D-2 交易日判断单元测试(纯函数,无外部依赖)。"""
from datetime import date
from sanguo_qmt_bridge.trade_calendar import is_trading_day
class TestIsTradingDay:
def test_weekday_is_trading_day(self):
assert is_trading_day(date(2026, 7, 13)) is True # 周一
def test_friday_is_trading_day(self):
assert is_trading_day(date(2026, 7, 17)) is True # 周五
def test_saturday_is_not_trading_day(self):
assert is_trading_day(date(2026, 7, 18)) is False # 周六
def test_sunday_is_not_trading_day(self):
assert is_trading_day(date(2026, 7, 19)) is False # 周日
def test_default_uses_today(self):
"""无参数时用今天,至少返回 bool 不崩溃。"""
result = is_trading_day()
assert isinstance(result, bool)
def test_full_week(self):
"""完整一周:周一到周五 True,周六周日 False。"""
week = [
(date(2026, 7, 13), True), # Mon
(date(2026, 7, 14), True), # Tue
(date(2026, 7, 15), True), # Wed
(date(2026, 7, 16), True), # Thu
(date(2026, 7, 17), True), # Fri
(date(2026, 7, 18), False), # Sat
(date(2026, 7, 19), False), # Sun
]
for day, expected in week:
assert is_trading_day(day) is expected, f"{day} expected {expected}"