mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 19:14:16 +08:00
经三轮代码审计后的综合质量加固版本,覆盖协议核心层、数据正确性、 错误处理、测试真实度与可维护性。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
159 lines
6.8 KiB
Python
159 lines
6.8 KiB
Python
"""TdxClient._execute 的指数退避重连测试(sync + async)。
|
|
|
|
之前 _execute 的 4 次 _RETRY_DELAYS 退避重连路径零测试(审计报告 #9),
|
|
仅 async 有 transport 层的真实重连测试,未覆盖 _execute 自身的退避循环。
|
|
本文件 mock _conn.execute 让前 N 次抛 TdxConnectionError、第 N+1 次成功,
|
|
并 patch time.sleep / asyncio.sleep 验证退避序列。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from easy_tdx.client import _RETRY_DELAYS, AsyncTdxClient, TdxClient
|
|
from easy_tdx.commands.security_count import GetSecurityCountCmd
|
|
from easy_tdx.exceptions import TdxConnectionError
|
|
from easy_tdx.models.enums import Market
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 同步 _execute 重连
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestSyncExecuteReconnect:
|
|
def test_reconnect_succeeds_on_second_attempt(self) -> None:
|
|
"""首次抛 TdxConnectionError,重连后第 1 次重试成功。"""
|
|
with patch("easy_tdx.client.TdxConnection") as mock_conn_cls:
|
|
mock_conn = MagicMock()
|
|
# execute 首次抛错,重连后(第1次重试)成功
|
|
mock_conn.execute.side_effect = [
|
|
TdxConnectionError("disconnected"),
|
|
1000, # 重连后成功
|
|
]
|
|
mock_conn_cls.return_value = mock_conn
|
|
|
|
client = TdxClient("1.1.1.1", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0)
|
|
with patch("easy_tdx.client.time.sleep"): # 跳过真实 sleep
|
|
result = client._execute(GetSecurityCountCmd(Market.SH))
|
|
assert result == 1000
|
|
# 应重连了 1 次(首次失败 + 1 次重试成功)
|
|
assert mock_conn.close.call_count == 1
|
|
|
|
def test_all_retries_exhausted_raises_last(self) -> None:
|
|
"""4 次重试全部失败,应抛出最后一个异常。"""
|
|
with patch("easy_tdx.client.TdxConnection") as mock_conn_cls:
|
|
mock_conn = MagicMock()
|
|
# 首次 + 4 次重试全部失败
|
|
mock_conn.execute.side_effect = TdxConnectionError("always down")
|
|
mock_conn_cls.return_value = mock_conn
|
|
|
|
client = TdxClient("1.1.1.1", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0)
|
|
with patch("easy_tdx.client.time.sleep") as mock_sleep:
|
|
with pytest.raises(TdxConnectionError):
|
|
client._execute(GetSecurityCountCmd(Market.SH))
|
|
# 应 sleep 了 4 次(_RETRY_DELAYS 长度)
|
|
assert mock_sleep.call_count == len(_RETRY_DELAYS)
|
|
|
|
def test_no_reconnect_when_disabled(self) -> None:
|
|
"""auto_reconnect=False 时首次失败立即抛出,不重试。"""
|
|
with patch("easy_tdx.client.TdxConnection") as mock_conn_cls:
|
|
mock_conn = MagicMock()
|
|
mock_conn.execute.side_effect = TdxConnectionError("down")
|
|
mock_conn_cls.return_value = mock_conn
|
|
|
|
client = TdxClient("1.1.1.1", 7709, 1.0, auto_reconnect=False, heartbeat_interval=0)
|
|
with patch("easy_tdx.client.time.sleep") as mock_sleep:
|
|
with pytest.raises(TdxConnectionError):
|
|
client._execute(GetSecurityCountCmd(Market.SH))
|
|
# 禁用重连时不应 sleep
|
|
mock_sleep.assert_not_called()
|
|
|
|
def test_retry_uses_exponential_backoff_delays(self) -> None:
|
|
"""验证 sleep 调用的延迟序列与 _RETRY_DELAYS 一致。"""
|
|
with patch("easy_tdx.client.TdxConnection") as mock_conn_cls:
|
|
mock_conn = MagicMock()
|
|
mock_conn.execute.side_effect = TdxConnectionError("down")
|
|
mock_conn_cls.return_value = mock_conn
|
|
|
|
client = TdxClient("1.1.1.1", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0)
|
|
with patch("easy_tdx.client.time.sleep") as mock_sleep:
|
|
with pytest.raises(TdxConnectionError):
|
|
client._execute(GetSecurityCountCmd(Market.SH))
|
|
actual_delays = [call.args[0] for call in mock_sleep.call_args_list]
|
|
assert tuple(actual_delays) == _RETRY_DELAYS
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 异步 _execute 重连
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestAsyncExecuteReconnect:
|
|
def test_async_reconnect_succeeds_on_second_attempt(self) -> None:
|
|
async def main() -> int:
|
|
with patch("easy_tdx.client.AsyncTdxConnection") as mock_conn_cls:
|
|
mock_conn = MagicMock()
|
|
call_count = [0]
|
|
|
|
async def _execute(cmd: object) -> int:
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
raise TdxConnectionError("down")
|
|
return 2000
|
|
|
|
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 = AsyncTdxClient(
|
|
"1.1.1.1", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0
|
|
)
|
|
with patch("easy_tdx.client.asyncio.sleep", new=AsyncMockSleep()):
|
|
result = await client._execute(GetSecurityCountCmd(Market.SH))
|
|
return result
|
|
|
|
assert asyncio.run(main()) == 2000
|
|
|
|
def test_async_all_retries_exhausted(self) -> None:
|
|
async def main() -> None:
|
|
with patch("easy_tdx.client.AsyncTdxConnection") as mock_conn_cls:
|
|
mock_conn = MagicMock()
|
|
|
|
async def _execute(cmd: object) -> int:
|
|
raise TdxConnectionError("always 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 = AsyncTdxClient(
|
|
"1.1.1.1", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0
|
|
)
|
|
with patch("easy_tdx.client.asyncio.sleep", new=AsyncMockSleep()) as mock_sleep:
|
|
with pytest.raises(TdxConnectionError):
|
|
await client._execute(GetSecurityCountCmd(Market.SH))
|
|
assert mock_sleep.call_count == len(_RETRY_DELAYS)
|
|
|
|
asyncio.run(main())
|
|
|
|
|
|
class AsyncMockSleep:
|
|
"""轻量 async sleep 替身,记录调用次数但不真实等待。"""
|
|
|
|
def __init__(self) -> None:
|
|
self.call_count = 0
|
|
|
|
async def __call__(self, delay: float) -> None:
|
|
self.call_count += 1
|