Files
easy_tdx_max/tests/unit/test_ex_reconnect.py
T
GitHub 155328df8b release: v1.16.2 — 三轮审计质量加固(B6.9→A7.9)
经三轮代码审计后的综合质量加固版本,覆盖协议核心层、数据正确性、
错误处理、测试真实度与可维护性。761 单测全绿(+58),ruff/mypy 全过。

主要修复:
- 离线 .day 写入原子化(fsync + _repair_tail + 读取校验,CQS 守住)
- 回测止损前视偏差(延迟下一根开盘 + 跳空保护)
- VWAP 权重索引 / bar_time fail-fast / 绩效除零保护
- 闭包绑定 / 路径穿越 / naive datetime 跨时区 / ruff UP038

重构:
- 抽 AsyncHeartbeatMixin 收敛 4 处心跳副本(12→1)
- 统一 _RETRY_DELAYS 退避序列 / scanner 失败可观测性

新增 5 个测试文件 + 公共 API 类型契约,CI 加 Windows 矩阵 +
trusted publishing 签名 + 锁文件。

详见 CHANGELOG.md
2026-07-02 03:37:37 +08:00

194 lines
8.5 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.
"""扩展行情 client 的指数退避重连测试(审计 #2)。
之前 ex 家族(ExTdxClient/MacExClient/AsyncExTdxClient/AsyncMacExClient)的 _execute
只重连 1 次无退避,与 A 股/MAC 的 4 次退避不一致。本测试验证统一后的退避行为,
并确认 MacExClient 重连后会重新 _login()。
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from easy_tdx._reconnect import _RETRY_DELAYS
from easy_tdx.ex.client import AsyncExTdxClient, ExTdxClient
from easy_tdx.ex.commands.get_markets import GetExMarketsCmd
from easy_tdx.ex.mac_client import AsyncMacExClient, MacExClient
from easy_tdx.exceptions import TdxConnectionError
class TestExTdxClientReconnect:
def test_reconnect_succeeds_on_second_attempt(self) -> None:
"""首次抛 TdxConnectionError,重连后第 1 次重试成功。"""
with patch("easy_tdx.ex.client.ExTdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
mock_conn.execute.side_effect = [TdxConnectionError("down"), ["market"]]
mock_conn_cls.return_value = mock_conn
client = ExTdxClient("1.1.1.1", auto_reconnect=True)
with patch("easy_tdx.ex.client.time.sleep"):
result = client._execute(GetExMarketsCmd())
assert result == ["market"]
assert mock_conn.close.call_count == 1 # 重连了 1 次
def test_all_retries_exhausted_raises_last(self) -> None:
"""4 次重试全部失败,应抛出异常,且 sleep 4 次。"""
with patch("easy_tdx.ex.client.ExTdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
mock_conn.execute.side_effect = TdxConnectionError("always down")
mock_conn_cls.return_value = mock_conn
client = ExTdxClient("1.1.1.1", auto_reconnect=True)
with patch("easy_tdx.ex.client.time.sleep") as mock_sleep:
with pytest.raises(TdxConnectionError):
client._execute(GetExMarketsCmd())
assert mock_sleep.call_count == len(_RETRY_DELAYS)
def test_no_reconnect_when_disabled(self) -> None:
with patch("easy_tdx.ex.client.ExTdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
mock_conn.execute.side_effect = TdxConnectionError("down")
mock_conn_cls.return_value = mock_conn
client = ExTdxClient("1.1.1.1", auto_reconnect=False)
with patch("easy_tdx.ex.client.time.sleep") as mock_sleep:
with pytest.raises(TdxConnectionError):
client._execute(GetExMarketsCmd())
mock_sleep.assert_not_called()
class TestMacExClientReconnect:
def test_reconnect_relogs_in(self) -> None:
"""MacExClient 每次重连后必须重新 _login()MAC 协议特有)。"""
with patch("easy_tdx.ex.mac_client.ExTdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
mock_conn.execute.side_effect = [TdxConnectionError("down"), ["market"]]
mock_conn_cls.return_value = mock_conn
client = MacExClient("1.1.1.1", auto_reconnect=True)
with (
patch("easy_tdx.ex.mac_client.time.sleep"),
patch.object(client, "_login") as mock_login,
):
result = client._execute(GetExMarketsCmd())
assert result == ["market"]
# 重连 1 次应触发 1 次 _login
assert mock_login.call_count == 1
def test_all_retries_relogin_each_time(self) -> None:
"""4 次重试全失败时,每次重连都应 _login()(共 4 次)。"""
with patch("easy_tdx.ex.mac_client.ExTdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
mock_conn.execute.side_effect = TdxConnectionError("always down")
mock_conn_cls.return_value = mock_conn
client = MacExClient("1.1.1.1", auto_reconnect=True)
with (
patch("easy_tdx.ex.mac_client.time.sleep"),
patch.object(client, "_login") as mock_login,
):
with pytest.raises(TdxConnectionError):
client._execute(GetExMarketsCmd())
assert mock_login.call_count == len(_RETRY_DELAYS)
class TestAsyncExTdxClientReconnect:
def test_async_all_retries_exhausted(self) -> None:
async def main() -> None:
with patch("easy_tdx.ex.client.AsyncExTdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
async def _execute(cmd: object) -> list[str]:
raise TdxConnectionError("always down")
mock_conn.execute = _execute
async def _noop() -> None:
return None
mock_conn.close = _noop
mock_conn.connect = _noop
mock_conn_cls.return_value = mock_conn
client = AsyncExTdxClient("1.1.1.1", auto_reconnect=True, heartbeat_interval=0)
with patch("easy_tdx.ex.client.asyncio.sleep") as mock_sleep:
with pytest.raises(TdxConnectionError):
await client._execute(GetExMarketsCmd())
assert mock_sleep.call_count == len(_RETRY_DELAYS)
asyncio.run(main())
class TestAsyncMacExClientReconnect:
def test_async_relogin_each_retry(self) -> None:
"""AsyncMacExClient 每次重连后必须重新 _login()(覆盖 async relogin 路径)。"""
async def main() -> None:
with patch("easy_tdx.ex.mac_client.AsyncExTdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
async def _execute(cmd: object) -> list[str]:
raise TdxConnectionError("always down")
mock_conn.execute = _execute
async def _noop() -> None:
return None
mock_conn.close = _noop
mock_conn.connect = _noop
mock_conn_cls.return_value = mock_conn
client = AsyncMacExClient("1.1.1.1", auto_reconnect=True, heartbeat_interval=0)
with (
patch("easy_tdx.ex.mac_client.asyncio.sleep"),
patch.object(client, "_login", new_callable=AsyncMock) as mock_login,
):
with pytest.raises(TdxConnectionError):
await client._execute(GetExMarketsCmd())
# 4 次重连应触发 4 次 _login
assert mock_login.call_count == len(_RETRY_DELAYS)
asyncio.run(main())
class TestBackoffDelayValues:
"""验证退避延迟值序列与 _RETRY_DELAYS 完全一致(防硬编码回归)。"""
def test_sync_ex_uses_exact_delays(self) -> None:
with patch("easy_tdx.ex.client.ExTdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
mock_conn.execute.side_effect = TdxConnectionError("down")
mock_conn_cls.return_value = mock_conn
client = ExTdxClient("1.1.1.1", auto_reconnect=True)
with patch("easy_tdx.ex.client.time.sleep") as mock_sleep:
with pytest.raises(TdxConnectionError):
client._execute(GetExMarketsCmd())
actual = tuple(c.args[0] for c in mock_sleep.call_args_list)
assert actual == _RETRY_DELAYS
class TestMacExLoginRetriedOnConnectionError:
"""登录握手期抛 TdxConnectionError 应继续重试(验证 _login 纳入 inner try)。"""
def test_login_conn_error_triggers_full_retry(self) -> None:
"""_login 抛 TdxConnectionError 时不应逃逸,应跑完 4 次重试。"""
with patch("easy_tdx.ex.mac_client.ExTdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
mock_conn.execute.side_effect = TdxConnectionError("always down")
mock_conn_cls.return_value = mock_conn
client = MacExClient("1.1.1.1", auto_reconnect=True)
# _login 抛 TdxConnectionError(模拟登录握手期连接又断)
with (
patch("easy_tdx.ex.mac_client.time.sleep") as mock_sleep,
patch.object(client, "_login", side_effect=TdxConnectionError("login lost")),
):
with pytest.raises(TdxConnectionError):
client._execute(GetExMarketsCmd())
# 关键:_login 异常被纳入重试,4 次都跑了(而非第 1 次就逃逸)
assert mock_sleep.call_count == len(_RETRY_DELAYS)