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
This commit is contained in:
GitHub
2026-07-02 03:37:37 +08:00
parent bc83ffa4ac
commit 155328df8b
40 changed files with 1546 additions and 209 deletions
+37 -3
View File
@@ -435,13 +435,19 @@ def test_stop_loss_triggers_sell():
def test_take_profit_triggers_sell():
"""Test take-profit triggers auto SELL when price rises above target."""
"""Test take-profit triggers auto SELL when price rises above target.
注意(审计 #4):止盈信号延迟到下一根开盘成交(消除前视偏差)。
当下一根开盘价低于触发价时(跳空回落),SELL 取更不利的实际开盘价。
"""
df = _make_flat_df(n=30)
# Bar 12 rises above take_profit=110.0
df.loc[12, "high"] = 112.0
df.loc[12, "low"] = 108.0
df.loc[12, "close"] = 111.0
df.loc[12, "open"] = 109.0
# Bar 13 开盘回落到 100(跳空),止盈延迟成交应取更不利的 100 而非触发价 110
df.loc[13, "open"] = 100.0
engine = BacktestEngine(TakeProfitStrategy, cash=100000)
result = engine.run(df)
@@ -451,8 +457,36 @@ def test_take_profit_triggers_sell():
# Should have at least one SELL triggered by take-profit
assert len(sell_trades) >= 1, "Expected take-profit sell"
# Sell price should be at take_profit price (110.0)
assert sell_trades.iloc[0]["price"] == 110.0
# 延迟到下一根(bar 13)开盘成交,跳空回落取更不利的实际价 100(非触发价 110)
assert sell_trades.iloc[0]["price"] == 100.0
def test_stop_loss_gap_down_fills_at_worse_price():
"""SL 信号延迟到下一根开盘成交;若跳空下跌,取更不利的开盘价(审计 #4)。
构造当根触及止损、但下一根开盘远低于止损价的跳空场景,
断言实际成交价取更不利的开盘价,回测净值低于"触发价成交"基线。
"""
df = _make_flat_df(n=30)
# Bar 12 触及 stop_loss=95low=93
df.loc[12, "low"] = 93.0
df.loc[12, "high"] = 96.0
df.loc[12, "close"] = 94.0
df.loc[12, "open"] = 97.0
# Bar 13 跳空低开到 90(远低于止损价 95),应取 90 而非 95
df.loc[13, "open"] = 90.0
df.loc[13, "low"] = 89.0
df.loc[13, "high"] = 91.0
df.loc[13, "close"] = 90.5
engine = BacktestEngine(StopLossStrategy, cash=100000)
result = engine.run(df)
trades = result.trades[~result.trades["rejected"]]
sell_trades = trades[trades["direction"] == "SELL"]
assert len(sell_trades) >= 1, "Expected stop-loss sell"
# 跳空下跌:SELL 取 min(next_open=90, trigger=95) = 90(更不利)
assert sell_trades.iloc[0]["price"] == 90.0
def test_stop_loss_not_triggered_when_price_stays_above():
+77
View File
@@ -430,3 +430,80 @@ def test_calmar() -> None:
# 卡玛比率 = annual_return / max_drawdown
# 由于 max_drawdown 很小,calmar 会很大
assert metrics["calmar"] > 0
# ---------------------------------------------------------------------------
# 除零边界回归(审计复审 N2 / 首轮 #11)
#
# performance.py 在计算日收益率时对 total[:-1]==0 的位置做了 safe_prev 守卫
# (记为 NaN 后 np.isfinite 过滤),并对 total[0]==0 的总收益率做了 0.0 兜底。
# 若有人不慎改回旧的 np.diff(total)/total[:-1],这些测试应当红灯。
# ---------------------------------------------------------------------------
def _metrics_from_total(values: list[float]) -> dict[str, float]:
"""从一组 total 值构造最小资金曲线并计算指标。"""
total = np.array(values, dtype=float)
peak = np.maximum.accumulate(total)
# 与生产回测一致:drawdown = peak - totaldrawdown_pct = drawdown / peak
drawdown = peak - total
drawdown_pct = np.divide(drawdown, peak, out=np.zeros_like(drawdown), where=(peak != 0))
equity = pd.DataFrame(
{
"datetime": np.arange(len(total)),
"total": total,
"drawdown": drawdown,
"drawdown_pct": drawdown_pct,
}
)
return PerformanceAnalyzer(equity, _make_trades()).compute()
def test_metrics_handles_zero_intermediate_equity() -> None:
"""中间净值出现 0 时,日收益率除零不抛异常、返回有限值(审计复审 N2)。
total=[100, 0, 105, 0, 110]:第 1、3 根前值为 0,旧实现 diff/total[:-1]
会得到 inf,进而污染均值/方差计算或触发 RuntimeWarning。修复后这些位置
被 safe_prev 记为 NaN 并由 isfinite 过滤。
"""
metrics = _metrics_from_total([100, 0, 105, 0, 110])
# 所有数值型指标必须有限(非 inf、非 NaN)
finite_keys = {
"total_return",
"annual_return",
"max_drawdown",
"sharpe",
"sortino",
"calmar",
"volatility",
"win_rate",
"profit_factor",
}
for key in finite_keys:
val = metrics[key]
assert np.isfinite(val), f"{key} 不是有限值: {val}"
def test_metrics_handles_zero_first_equity() -> None:
"""首根净值为 0 时 total_return 兜底为 0.0 而非除零(审计复审 N2)。
total[0]==0 时 (total[-1]/total[0]) - 1 会除零;修复后直接记 0.0。
"""
metrics = _metrics_from_total([0, 100, 105, 110, 115])
# total_return 走 total[0]==0 分支,应为有限值
assert np.isfinite(metrics["total_return"]), f"total_return 非有限值: {metrics['total_return']}"
# 不抛异常即说明 max_drawdown 等也未受影响
assert np.isfinite(metrics["max_drawdown"])
def test_metrics_all_zero_equity_does_not_raise() -> None:
"""全 0 资金曲线不应产生 inf/nan,也不应抛异常(审计复审 N2 极端场景)。"""
# total 全 0 → safe_prev 全 NaN → daily_ret 过滤后为空 → 走 _empty_metrics
metrics = _metrics_from_total([0, 0, 0, 0, 0])
# 全 0 资金曲线收益率数据不足,应安全返回有限值(多数为 0)
assert np.isfinite(metrics["total_return"])
assert np.isfinite(metrics["max_drawdown"])
assert np.isfinite(metrics["sharpe"])
+158
View File
@@ -0,0 +1,158 @@
"""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
+126
View File
@@ -0,0 +1,126 @@
"""codec/bitmap.py 单元测试 —— MAC 协议字段位图编解码。
之前 bitmap.py~490 行)零测试(审计报告 #9)。本文件覆盖:
FieldBit 字段属性、PresetField 组合、FieldSelection 去重、
build_bitmap 20 字节输出、get_active_fields 往返解析。
风格参照 test_codec_frame.py:纯函数式、无 mock、struct 构造输入。
"""
from __future__ import annotations
from easy_tdx.codec.bitmap import (
FieldBit,
FieldSelection,
PresetField,
build_bitmap,
build_exclude_flags,
get_active_fields,
normalize_fields,
)
class TestFieldBit:
def test_field_name_is_lower(self) -> None:
assert FieldBit.PRE_CLOSE.field_name == "pre_close"
assert FieldBit.OPEN.field_name == "open"
def test_fmt_and_desc_attached(self) -> None:
assert FieldBit.OPEN.fmt == "<f"
assert FieldBit.OPEN.desc == "开盘价"
assert FieldBit.VOL.fmt == "<I"
def test_value_is_bit_position(self) -> None:
assert FieldBit.PRE_CLOSE == 0x00
assert FieldBit.OPEN == 0x01
class TestPresetField:
def test_ohlc_contains_four_fields(self) -> None:
names = {f.name for f in PresetField.OHLC.value}
assert names == {"OPEN", "HIGH", "LOW", "CLOSE"}
def test_chain_plus_combines(self) -> None:
combined = PresetField.OHLC + FieldBit.VOL
sel = normalize_fields(combined)
bits = {b for b in sel}
assert FieldBit.VOL in bits
assert FieldBit.OPEN in bits
def test_chain_or_combines(self) -> None:
combined = PresetField.OHLC | PresetField.VOLUME
sel = normalize_fields(combined)
bits = {b for b in sel}
assert FieldBit.VOL in bits
assert FieldBit.AMOUNT in bits
class TestFieldSelection:
def test_dedup_preserves_order(self) -> None:
sel = FieldSelection(FieldBit.OPEN, FieldBit.OPEN, FieldBit.HIGH)
bits = list(sel)
assert bits == [FieldBit.OPEN, FieldBit.HIGH]
def test_empty_selection(self) -> None:
assert list(FieldSelection()) == []
class TestBuildBitmap:
def test_single_field_sets_correct_bit(self) -> None:
# FieldBit.OPEN == 0x01bit 1 应被置位
ba = build_bitmap(FieldBit.OPEN)
assert len(ba) == 20
assert ba[0] == 0b0000_0010 # bit 1
# 控制区 4 字节默认 0
assert bytes(ba[16:20]) == b"\x00\x00\x00\x00"
def test_multiple_fields_or(self) -> None:
ba = build_bitmap(PresetField.OHLC)
assert len(ba) == 20
# OPEN(1)+HIGH(2)+LOW(3)+CLOSE(4) → bit 1,2,3,4 → 0b11110 = 30
assert ba[0] == 0b0001_1110
def test_exclude_flags_appended(self) -> None:
ba = build_bitmap(FieldBit.OPEN, exclude_flags=0x1234)
assert len(ba) == 20
assert bytes(ba[16:20]) == b"\x34\x12\x00\x00"
def test_debug_preset_all_ff(self) -> None:
ba = build_bitmap(PresetField.DEBUG)
assert ba == bytearray(b"\xff" * 20)
class TestGetActiveFields:
def test_roundtrip(self) -> None:
original = PresetField.OHLC
ba = build_bitmap(original)
active = get_active_fields(bytes(ba[:16]))
active_names = {f.name for f, _ in active}
assert active_names == {"OPEN", "HIGH", "LOW", "CLOSE"}
def test_empty_bitmap(self) -> None:
active = get_active_fields(b"\x00" * 16)
assert active == []
def test_fmt_returned(self) -> None:
ba = build_bitmap(FieldBit.VOL)
active = get_active_fields(bytes(ba[:16]))
assert len(active) == 1
field, fmt = active[0]
assert field == FieldBit.VOL
assert fmt == "<I"
def test_sorted_by_bit_position(self) -> None:
# 故意逆序传入
ba = build_bitmap([FieldBit.CLOSE, FieldBit.OPEN, FieldBit.HIGH])
active = get_active_fields(bytes(ba[:16]))
positions = [f.value for f, _ in active]
assert positions == sorted(positions)
class TestBuildExcludeFlags:
def test_zero(self) -> None:
assert build_exclude_flags(0) == b"\x00\x00\x00\x00"
def test_value(self) -> None:
assert build_exclude_flags(0xFF) == b"\xff\x00\x00\x00"
+128
View File
@@ -0,0 +1,128 @@
"""config.py 单元测试 —— 覆盖环境变量覆盖、config.json 原子读写、save_best_host 补全逻辑。
之前这三块(env 覆盖 / config.json 读写 / save_best_host 合并)零测试,
本文件补齐该缺口(审计报告 #9)。
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from easy_tdx import config as cfg
# --------------------------------------------------------------------------- #
# 辅助:把 config 模块的 _CONFIG_FILE / _CONFIG_DIR 重定向到临时目录
# --------------------------------------------------------------------------- #
@pytest.fixture
def isolated_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""把 config 模块的重定向到 tmp_path,测试间互不影响。"""
monkeypatch.setattr(cfg, "_CONFIG_DIR", tmp_path)
monkeypatch.setattr(cfg, "_CONFIG_FILE", tmp_path / "config.json")
return tmp_path
# --------------------------------------------------------------------------- #
# 环境变量覆盖(EASY_TDX_HOST / PORT / TIMEOUT
# --------------------------------------------------------------------------- #
class TestEnvOverride:
def test_env_host_overrides_config(
self, isolated_config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# config.json 写入一个 host,但 env 应优先
(isolated_config / "config.json").write_text(json.dumps({"best_host": "1.1.1.1"}), "utf-8")
monkeypatch.setenv("EASY_TDX_HOST", "9.9.9.9")
assert cfg.get_best_host() == "9.9.9.9"
def test_env_port_overrides_config(
self, isolated_config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("EASY_TDX_PORT", "8888")
assert cfg.get_port() == 8888
def test_env_timeout_overrides_config(
self, isolated_config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("EASY_TDX_TIMEOUT", "42.5")
assert cfg.get_timeout() == 42.5
def test_env_known_hosts_csv(
self, isolated_config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("EASY_TDX_KNOWN_HOSTS", "a.com, b.com ,,c.com")
assert cfg.get_known_hosts() == ["a.com", "b.com", "c.com"]
# --------------------------------------------------------------------------- #
# config.json 读写 + 默认兜底
# --------------------------------------------------------------------------- #
class TestConfigReadWrite:
def test_no_config_file_uses_fallback(self, isolated_config: Path) -> None:
# 无 config.json 时返回内嵌默认值
assert cfg.get_best_host() == cfg._FALLBACK_HOSTS[0]
assert cfg.get_port() == cfg._FALLBACK_PORT
assert cfg.get_known_hosts() == list(cfg._FALLBACK_HOSTS)
def test_config_json_host(self, isolated_config: Path) -> None:
(isolated_config / "config.json").write_text(
json.dumps({"best_host": "203.0.0.1", "port": 7709, "timeout": 12.0}),
"utf-8",
)
assert cfg.get_best_host() == "203.0.0.1"
assert cfg.get_port() == 7709
assert cfg.get_timeout() == 12.0
def test_load_corrupt_json_returns_empty(self, isolated_config: Path) -> None:
# 损坏的 JSON 不应崩溃,应回退到默认
(isolated_config / "config.json").write_text("{not valid json", "utf-8")
assert cfg.get_best_host() == cfg._FALLBACK_HOSTS[0]
# --------------------------------------------------------------------------- #
# save_best_host 首次写入补全逻辑
# --------------------------------------------------------------------------- #
class TestSaveBestHost:
def test_first_write_completes_defaults(self, isolated_config: Path) -> None:
cfg.save_best_host("180.153.18.170")
data = json.loads((isolated_config / "config.json").read_text("utf-8"))
assert data["best_host"] == "180.153.18.170"
# 首次写入应补全所有默认字段
assert data["known_hosts"] == list(cfg._FALLBACK_HOSTS)
assert data["calc_hosts"] == list(cfg._FALLBACK_CALC_HOSTS)
assert data["mac_hosts"] == list(cfg._FALLBACK_MAC_HOSTS)
assert data["ex_hosts"] == list(cfg._FALLBACK_EX_HOSTS)
assert data["mac_ex_hosts"] == list(cfg._FALLBACK_MAC_EX_HOSTS)
assert data["port"] == cfg._FALLBACK_PORT
assert "best_host_updated_at" in data
def test_second_write_preserves_existing(self, isolated_config: Path) -> None:
# 预置已存在的 known_hostssave_best_host 不应覆盖它
existing = {
"known_hosts": ["custom.host"],
"port": 9999,
}
(isolated_config / "config.json").write_text(json.dumps(existing), "utf-8")
cfg.save_best_host("new.host")
data = json.loads((isolated_config / "config.json").read_text("utf-8"))
assert data["best_host"] == "new.host"
# 已有字段应保留,不被默认值覆盖
assert data["known_hosts"] == ["custom.host"]
assert data["port"] == 9999
# 但缺失的字段应补全
assert "calc_hosts" in data
def test_atomic_write(self, isolated_config: Path) -> None:
# 写入后不应残留 .tmp 文件(原子替换)
cfg.save_best_host("x.host")
assert not (isolated_config / "config.json.tmp").exists()
assert (isolated_config / "config.json").exists()
+193
View File
@@ -0,0 +1,193 @@
"""扩展行情 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)
+137
View File
@@ -0,0 +1,137 @@
"""公共 API 导出完整性测试 —— 防止 __all__ 与实际导出漂移(审计 #13)。
确保 easy_tdx.__all__ 中每个名字都能从顶层包成功导入,
且文档中描述的模型(FundFlow/MarketStat 等)确实可访问。
复审补充(L3):进一步断言导出对象的**类型**,避免类名被意外绑成模块、
None、或常量。仅"可导入"不足以守住类型契约。
"""
from __future__ import annotations
import inspect
import easy_tdx
# 期望的导出契约:每个公共名字应对应的对象类型。
# - "class" → 必须 inspect.isclassclient / 枚举 / 数据模型 / 异常)
# - "func" → 必须 callable 且非 classping_* / save_best_*
# - "constant" → 兜底(KNOWN_HOSTS / XDXR_CATEGORY_NAMES 等映射表或常量)
_EXPECTED_KIND: dict[str, str] = {
# client 类
"TdxClient": "class",
"AsyncTdxClient": "class",
"MacClient": "class",
"AsyncMacClient": "class",
"MacExClient": "class",
"AsyncMacExClient": "class",
"ExTdxClient": "class",
"AsyncExTdxClient": "class",
"UnifiedTdxClient": "class",
"AsyncUnifiedTdxClient": "class",
# 枚举
"Market": "class",
"KlineCategory": "class",
"Adjust": "class",
"BoardType": "class",
"Category": "class",
"ExMarket": "class",
"FilterType": "class",
"Period": "class",
"SortOrder": "class",
"SortType": "class",
# 数据模型
"SecurityBar": "class",
"SecurityQuote": "class",
"SecurityInfo": "class",
"MinuteBar": "class",
"TransactionRecord": "class",
"XdxrRecord": "class",
"FinanceInfo": "class",
"CompanyInfoCategory": "class",
"FinancialFileInfo": "class",
"FinancialRecord": "class",
"TdxBlock": "class",
"MarketStat": "class",
"FundFlow": "class",
"HistoricalFundFlow": "class",
# 异常
"TdxError": "class",
"TdxConnectionError": "class",
"TdxDecodeError": "class",
"TdxCommandError": "class",
# 函数
"ping_all": "func",
"ping_mac_all": "func",
"save_best_host": "func",
"save_best_ex_host": "func",
# 常量 / 映射表
"KNOWN_EX_HOSTS": "constant",
"KNOWN_HOSTS": "constant",
"CALC_HOSTS": "constant",
"MAC_HOSTS": "constant",
"XDXR_CATEGORY_NAMES": "constant",
}
def test_all_names_are_importable() -> None:
"""__all__ 里每个名字都必须能从 easy_tdx 顶层获取到非 None 对象。"""
missing = [name for name in easy_tdx.__all__ if getattr(easy_tdx, name, None) is None]
assert missing == [], f"__all__ 中以下名字无法从 easy_tdx 导入: {missing}"
def test_expected_kind_contract_is_complete() -> None:
"""_EXPECTED_KIND 必须覆盖 __all__ 的每个名字,否则契约会悄悄漂移(审计复审 L3)。"""
covered = set(_EXPECTED_KIND)
exported = set(easy_tdx.__all__)
missing_kind = exported - covered
extra_kind = covered - exported
assert not missing_kind, f"以下导出未在 _EXPECTED_KIND 中声明类型契约: {sorted(missing_kind)}"
assert not extra_kind, f"_EXPECTED_KIND 含未导出的名字(已移除?): {sorted(extra_kind)}"
def test_exported_objects_have_expected_type() -> None:
"""断言每个导出对象的类型符合契约(审计复审 L3)。
防止类名被绑成模块/None/常量——仅"可导入"不足以守住类型。
"""
wrong: list[str] = []
for name, kind in _EXPECTED_KIND.items():
obj = getattr(easy_tdx, name, None)
if obj is None:
wrong.append(f"{name}: 不应为 None")
continue
if kind == "class":
if not inspect.isclass(obj):
wrong.append(f"{name}: 期望 class,实际 {type(obj).__name__}")
elif kind == "func":
# callable 但不能是 class(避免类被当成函数)
if not callable(obj) or inspect.isclass(obj):
wrong.append(f"{name}: 期望 function,实际 {type(obj).__name__}")
# "constant" 兜底,不做严格断言
assert wrong == [], "导出对象类型契约违反: \n" + "\n".join(wrong)
def test_documented_models_exported() -> None:
"""api_reference.md 文档描述的模型必须在公共导出中(审计 #13)。"""
for name in ("FundFlow", "MarketStat", "HistoricalFundFlow", "TdxBlock"):
assert name in easy_tdx.__all__, f"{name} 应在 easy_tdx.__all__ 中"
assert inspect.isclass(getattr(easy_tdx, name)), f"{name} 应是类"
def test_core_clients_exported() -> None:
"""8 个 client 类与门面都应导出且确实是类(审计 #13 + 复审 L3)。"""
for name in (
"TdxClient",
"AsyncTdxClient",
"MacClient",
"AsyncMacClient",
"ExTdxClient",
"AsyncExTdxClient",
"MacExClient",
"AsyncMacExClient",
"UnifiedTdxClient",
"AsyncUnifiedTdxClient",
):
assert name in easy_tdx.__all__, f"{name} 应在 easy_tdx.__all__ 中"
assert inspect.isclass(getattr(easy_tdx, name)), f"{name} 应是类"
+54
View File
@@ -210,3 +210,57 @@ class TestIncrementalScan:
codes2 = sorted(r.code for r in results2)
# 结果可能相同 (策略没变), 但不应崩溃
assert len(codes2) >= 1
class TestScanFailureLogging:
"""扫描失败日志回归(审计复审 L2)。
首轮 #6 将扫描循环的 ``except Exception: continue`` 评为"系统性失败被静默
吞掉"。复审 L2 修复:单股失败记录 warning + 失败计数,失败率超阈值时
循环结束发出 summary。这些测试用 monkeypatch 让 ``_scan_one`` 抛错模拟
损坏 .day / 策略异常等场景,断言失败被记录(read_daily_bars 本身对短文件
容错返回 0 条,不会抛错,故用 monkeypatch 构造确定性失败)。
"""
def test_serial_scan_logs_per_stock_failure(
self, vipdoc: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""单股 _scan_one 抛错时,串行扫描应记录 warning(审计复审 L2)。"""
scanner = SignalScanner(AlwaysBuyStrategy, vipdoc_path=vipdoc)
def _boom(self: SignalScanner, filepath: Path, market: str, code: str) -> None:
raise RuntimeError(f"simulated corrupt day for {code}")
monkeypatch.setattr(SignalScanner, "_scan_one", _boom)
with caplog.at_level("WARNING", logger="easy_tdx.screen.scanner"):
results = scanner.scan(universe="all", workers=0)
# 全部抛错 → 无结果,但不崩溃(容错语义:跳过继续)
assert results == []
# 每个被扫描的 A 股都应有一条 warning
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
assert len(warnings) >= 1, "单股失败应触发 warning 日志"
assert any("失败" in r.getMessage() for r in warnings)
def test_serial_scan_high_failure_rate_emits_summary(
self, vipdoc: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""失败率超阈值时应发出汇总告警(审计复审 L2)。
全部 A 股 _scan_one 抛错(失败率 100% > 50% 阈值),断言扫描完成后
有一条 summary warning。
"""
scanner = SignalScanner(AlwaysBuyStrategy, vipdoc_path=vipdoc)
def _boom(self: SignalScanner, filepath: Path, market: str, code: str) -> None:
raise RuntimeError(f"simulated corrupt day for {code}")
monkeypatch.setattr(SignalScanner, "_scan_one", _boom)
with caplog.at_level("WARNING", logger="easy_tdx.screen.scanner"):
scanner.scan(universe="all", workers=0)
# 应有汇总告警提到"失败率过高"
summary_msgs = [r.getMessage() for r in caplog.records if "失败率" in r.getMessage()]
assert summary_msgs, "失败率过高时应发出汇总 warning"