Files
easy-tdx/tests/unit/test_ex_reconnect.py
T
Justin Gu 1f040afabf feat(reconnect): 引入服务器健康分引擎 + K线空数据故障转移
彻底解决通达信服务器"跳来跳去"问题:

1. 新增 _health.py 健康分引擎:失败乘性降权(×0.5)、连续失败3次进
   120s 冷却、成功加性恢复(+0.2)。rank_by_health 按 latency/score
   (有效延迟)重排,冷却中的剔除。全健康时恒等映射,对既有测试零影响。

2. get_index_bars/get_security_bars 空数据时自动逐台换台(此前直接
   返回空 DataFrame,是日志"指数K线响应在第1/800条处被截断"后用户拿
   不到数据的根因)。复用泛化后的 _find_host_returning_data。

3. select_best_host_*/find_working_host_* 应用 rank_by_health 重排;
   空数据验证失败/异常时调 record_failure,命中调 record_success。

4. 8 个 _execute(A股/MAC/EX/MAC-EX × sync/async)统一注入健康分记录:
   成功 record_success、连接失败 record_failure。

5. security_bars 截断日志区分"首条即空(服务器无数据)"与"末尾截断"。

测试:26 个新增(15 health + 7 failover + 4 ex-client 健康分追踪),
全量 reconnect/failover/decode 回归通过,ruff/mypy 通过。
2026-07-13 15:59:28 +08:00

319 lines
13 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)
# --------------------------------------------------------------------------- #
# MAC/EX client 健康分联动(防 pattern-fix 回归)
# --------------------------------------------------------------------------- #
class TestExClientHealthTracking:
"""锁定修复:MAC/EX 的 _execute 必须像 A 股 client 一样写健康分。
此前只有 A 股 TdxClient/AsyncTdxClient 注入了 record_failure/record_success
MAC/EX 的 6 个 _execute 漏改(它们的服务器 IP 与 A 股不重叠,失败时不会被
降权,功能残缺)。本测试防止再次漏改。
"""
def test_ex_client_failure_records_health(self) -> None:
"""ExTdxClient 连接失败时应调 record_failure 降权当前 host。"""
from easy_tdx._health import reset_health
reset_health()
try:
with (
patch("easy_tdx.ex.client.ExTdxConnection") as mock_conn_cls,
patch("easy_tdx.ex.client.time.sleep"),
patch("easy_tdx.ex.client.select_best_host_sync", return_value=None),
):
mock_conn = MagicMock()
mock_conn.execute.side_effect = TdxConnectionError("down")
mock_conn_cls.return_value = mock_conn
client = ExTdxClient("ex-bad", auto_reconnect=True)
with pytest.raises(TdxConnectionError):
client._execute(GetExMarketsCmd())
from easy_tdx._health import get_score
# ex-bad 经历首次 + 4 次重试共 5 次失败,score 应远低于 1.0
assert get_score("ex-bad") < 1.0
finally:
reset_health()
def test_ex_client_success_records_health(self) -> None:
"""ExTdxClient 首次成功应调 record_successscore 保持 1.0)。"""
from easy_tdx._health import get_score, reset_health
reset_health()
try:
with patch("easy_tdx.ex.client.ExTdxConnection") as mock_conn_cls:
mock_conn = MagicMock()
mock_conn.execute.return_value = ["market"]
mock_conn_cls.return_value = mock_conn
client = ExTdxClient("ex-good", auto_reconnect=True)
result = client._execute(GetExMarketsCmd())
assert result == ["market"]
assert get_score("ex-good") == 1.0
finally:
reset_health()
def test_mac_ex_client_failure_records_health(self) -> None:
"""MacExClient(含 _login 重连路径)连接失败也应降权当前 host。"""
from easy_tdx._health import reset_health
reset_health()
try:
with (
patch("easy_tdx.ex.mac_client.ExTdxConnection") as mock_conn_cls,
patch("easy_tdx.ex.mac_client.time.sleep"),
patch("easy_tdx.ex.mac_client.select_best_host_sync", return_value=None),
):
mock_conn = MagicMock()
mock_conn.execute.side_effect = TdxConnectionError("down")
mock_conn_cls.return_value = mock_conn
client = MacExClient("macex-bad", auto_reconnect=True)
with pytest.raises(TdxConnectionError):
client._execute(GetExMarketsCmd())
from easy_tdx._health import get_score
assert get_score("macex-bad") < 1.0
finally:
reset_health()
def test_async_ex_client_failure_records_health(self) -> None:
"""AsyncExTdxClient 连接失败时也应降权。"""
from easy_tdx._health import reset_health
reset_health()
try:
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("down")
async def _noop() -> None:
return None
mock_conn.execute = _execute
mock_conn.close = _noop
mock_conn.connect = _noop
mock_conn_cls.return_value = mock_conn
client = AsyncExTdxClient("aex-bad", auto_reconnect=True, heartbeat_interval=0)
with (
patch("easy_tdx.ex.client.asyncio.sleep"),
patch(
"easy_tdx.ex.client.select_best_host_async",
new_callable=AsyncMock,
return_value=None,
),
):
with pytest.raises(TdxConnectionError):
await client._execute(GetExMarketsCmd())
asyncio.run(main())
from easy_tdx._health import get_score
assert get_score("aex-bad") < 1.0
finally:
reset_health()