cadc59e6dc
- 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自动重连(无需手动重启)
230 lines
7.6 KiB
Python
230 lines
7.6 KiB
Python
"""D-2 gateway 稳定性测试:自动重连 + 探活(mock xtquant,Mac 无 xtquant)。
|
||
|
||
注入 mock xtquant 模块到 sys.modules,验证:
|
||
- connect 成功/失败
|
||
- query 失败 → reconnect → 重试一次(query_account / query_positions / place_order)
|
||
- place_order broker 拒单(order_id<=0) 不触发重连重试
|
||
- is_alive(query_stock_asset 非空=True,None/异常/未连接=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 xtquant,yield 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<=0(broker 拒单)不触发重连,直接返回。"""
|
||
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 # 不重连
|