Files
sanguo_vnpy_v2/tests/trader/test_gateway.py
T
claude_dev cadc59e6dc 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自动重连(无需手动重启)
2026-07-11 07:32:23 +08:00

230 lines
7.6 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.
"""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 # 不重连