mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 15:44:15 +08:00
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:
@@ -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=95(low=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():
|
||||
|
||||
@@ -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 - total;drawdown_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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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 == 0x01,bit 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"
|
||||
@@ -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_hosts,save_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()
|
||||
@@ -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)
|
||||
@@ -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.isclass(client / 枚举 / 数据模型 / 异常)
|
||||
# - "func" → 必须 callable 且非 class(ping_* / 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} 应是类"
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user