Files
easy_tdx_max/tests/unit/test_board_mac_overview.py
T
Justin Gu e374a0da28 release: v1.32.6 — 两周改动深度审查全面修复(回测口径三件套/LLM 安全加固/涨停价舍入/时区统一/缓存与竞态等 58 处)
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现:

回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标
被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、
组合体检品种费率、寻优端点费率透传。

安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、
错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。

数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/
provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、
baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作)
+ 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。

Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、
submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。

公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。

前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、
空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。

CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、
CI 超时与缓存、spec 补 baostock 前提。

约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
2026-09-06 22:16:48 +08:00

294 lines
9.9 KiB
Python

"""/board-mac/overview 聚合端点单测(离线,mock MAC 客户端)。
覆盖:多排序键归并、当日涨跌幅口径(price/pre_close-1)、缺失指标置 null、
TTL 缓存命中、无效指标 400、空列表。
"""
from __future__ import annotations
import pytest
def _board_df(rows: list[dict]) -> object:
import pandas as pd
return pd.DataFrame(rows)
def _board_row(
code: str,
name: str,
price: float,
pre_close: float,
sort_value: float = 0.0,
leader: tuple[str, str, float, float] | None = None,
) -> dict:
leader = leader or ("600000", "领涨股", price * 1.05, price)
return {
"market": 1,
"code": code,
"name": name,
"price": price,
"sort_value": sort_value,
"pre_close": pre_close,
"symbol_market": 1,
"symbol_code": leader[0],
"symbol_name": leader[1],
"symbol_price": leader[2],
"symbol_pre_close": leader[3],
}
class _FakeOverviewMacClient:
"""按 BoardSortColumn 名称返回预置 DataFrame 的替身客户端。"""
def __init__(self, frames: dict[str, object]):
import pandas as pd
self._frames = frames
self._empty = pd.DataFrame()
self.calls: list[str] = []
async def get_board_list(self, board_type=None, count=10000, sort_column=None):
name = getattr(sort_column, "name", None) or "CHANGE_PCT" # 与真客户端默认一致
self.calls.append(name)
return self._frames.get(name, self._empty)
def _overview_app(mac_client):
from fastapi import FastAPI
from easy_tdx.web.errors import register_exception_handlers
from easy_tdx.web.routers import board_mac
app = FastAPI()
register_exception_handlers(app)
app.include_router(board_mac.router, prefix="/api/v1")
app.state.tdx_client = object()
app.state.mac_client = mac_client
return app
@pytest.fixture(autouse=True)
def _clean_cache():
from easy_tdx.web.routers import board_mac
board_mac._overview_cache.clear()
yield
board_mac._overview_cache.clear()
def _get_overview(client, board_type="HY", metrics="SPEED,CHANGE_20D"):
return client.get(
"/api/v1/board-mac/overview",
params={"board_type": board_type, "metrics": metrics},
)
def test_overview_merge_and_change_pct():
"""基表 + 各排序键归并;涨跌幅按 price/pre_close-1 计算。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
frames = {
"CHANGE_PCT": _board_df(
[
_board_row(
"881106", "种植业", 1039.93, 1031.20, leader=("600100", "A股票", 11.0, 10.0)
),
_board_row(
"881101", "煤炭开采", 2200.0, 2244.0, leader=("600200", "B股票", 9.5, 10.0)
),
]
),
"SPEED": _board_df(
[
_board_row("881106", "种植业", 1039.93, 1031.20, sort_value=0.52),
_board_row("881101", "煤炭开采", 2200.0, 2244.0, sort_value=-0.11),
]
),
"CHANGE_20D": _board_df(
[
_board_row("881106", "种植业", 1039.93, 1031.20, sort_value=6.3),
_board_row("881101", "煤炭开采", 2200.0, 2244.0, sort_value=-2.4),
]
),
}
fake = _FakeOverviewMacClient(frames)
with TestClient(_overview_app(fake)) as client:
resp = _get_overview(client)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["board_type"] == "HY"
assert data["count"] == 2
rows = {r["code"]: r for r in data["rows"]}
hy = rows["881106"]
# 1039.93/1031.20-1 = +0.8465%
assert hy["change_pct"] == pytest.approx(0.846, abs=0.01)
assert hy["speed"] == 0.52
assert hy["chg_20d"] == 6.3
assert hy["chg_5d"] is None # 未请求的指标置 null
assert hy["leader_name"] == "A股票"
assert hy["leader_change_pct"] == pytest.approx(10.0, abs=0.01)
mt = rows["881101"]
assert mt["change_pct"] == pytest.approx(-1.961, abs=0.01)
assert mt["leader_change_pct"] == pytest.approx(-5.0, abs=0.01)
# 基表(涨跌幅排序) + SPEED + CHANGE_20D 共 3 次调用
assert sorted(fake.calls) == ["CHANGE_20D", "CHANGE_PCT", "SPEED"]
def test_overview_cache_hit_within_ttl():
"""TTL 内命中缓存,不再触发 MAC 调用;时间推进后重新拉取。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from easy_tdx.web.routers import board_mac
fake = _FakeOverviewMacClient(
{"CHANGE_PCT": _board_df([_board_row("881001", "软件服务", 5000.0, 4900.0)])}
)
clock = {"t": 100.0}
board_mac._now = lambda: clock["t"] # type: ignore[assignment]
try:
with TestClient(_overview_app(fake)) as client:
_get_overview(client)
_get_overview(client)
assert fake.calls.count("CHANGE_PCT") == 1
clock["t"] += board_mac._OVERVIEW_TTL + 1
with TestClient(_overview_app(fake)) as client:
_get_overview(client)
assert fake.calls.count("CHANGE_PCT") == 2
finally:
board_mac._now = board_mac.time.monotonic # type: ignore[assignment]
def test_overview_cache_key_separates_board_type():
"""不同 board_type 的缓存相互独立。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
frames = {
"CHANGE_PCT": _board_df([_board_row("881001", "软件服务", 5000.0, 4900.0)]),
"GN": None,
}
fake = _FakeOverviewMacClient(frames)
with TestClient(_overview_app(fake)) as client:
_get_overview(client, board_type="HY")
_get_overview(client, board_type="GN")
assert fake.calls.count("CHANGE_PCT") == 2
def test_overview_invalid_metric_returns_400():
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
fake = _FakeOverviewMacClient({})
with TestClient(_overview_app(fake)) as client:
resp = _get_overview(client, metrics="SPEED,NOT_A_METRIC")
assert resp.status_code == 400
assert "NOT_A_METRIC" in resp.json()["detail"]
def test_overview_empty_base_list():
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
fake = _FakeOverviewMacClient({})
with TestClient(_overview_app(fake)) as client:
resp = _get_overview(client)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["count"] == 0
assert data["rows"] == []
def test_overview_zero_pre_close_change_pct_null():
"""pre_close 为 0(无行情)时涨跌幅为 null 而非异常/除零。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
frames = {"CHANGE_PCT": _board_df([_board_row("881999", "空数据板块", 0.0, 0.0)])}
fake = _FakeOverviewMacClient(frames)
with TestClient(_overview_app(fake)) as client:
resp = _get_overview(client)
assert resp.status_code == 200
row = resp.json()["data"]["rows"][0]
assert row["change_pct"] is None
assert row["leader_change_pct"] is None
def test_overview_cache_key_includes_count():
"""缓存键须含 count:不同 count 的请求在 TTL 内不互相命中。
旧实现缓存键只有 (board_type, metrics),先到的小 count 请求会把大 count
的响应"污染"成少数行(15s TTL 内)。
"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
frames = {
"CHANGE_PCT": _board_df(
[
_board_row("881001", "软件服务", 5000.0, 4900.0),
_board_row("881002", "半导体", 3000.0, 2950.0),
]
),
}
class _CountingFake(_FakeOverviewMacClient):
"""尊重 count 参数(与真实客户端一致地截断行数)。"""
async def get_board_list(self, board_type=None, count=10000, sort_column=None):
df = await super().get_board_list(
board_type=board_type, count=count, sort_column=sort_column
)
return df.head(count) if df is not None else df
fake = _CountingFake(frames)
with TestClient(_overview_app(fake)) as client:
r_small = client.get("/api/v1/board-mac/overview", params={"board_type": "HY", "count": 1})
assert r_small.status_code == 200
assert r_small.json()["data"]["count"] == 1
r_big = client.get("/api/v1/board-mac/overview", params={"board_type": "HY", "count": 2})
assert r_big.status_code == 200
# 不允许命中 count=1 的缓存
assert r_big.json()["data"]["count"] == 2
assert fake.calls.count("CHANGE_PCT") == 2 # 两个 count 各拉一次
def test_overview_nan_payload_cleaned_before_cache():
"""坏值(NaN)行不产生 500,且写入缓存前已清洗(缓存里不留 NaN)。
旧实现:sort_value=NaN → payload 带 NaN → Starlette allow_nan=False
序列化 500,且带毒 payload 先入 15s 缓存,TTL 内持续 500。
"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from easy_tdx.web.routers import board_mac
nan = float("nan")
frames = {
"CHANGE_PCT": _board_df([_board_row("881001", "软件服务", 5000.0, 4900.0)]),
"SPEED": _board_df([_board_row("881001", "软件服务", 5000.0, 4900.0, sort_value=nan)]),
}
fake = _FakeOverviewMacClient(frames)
with TestClient(_overview_app(fake)) as client:
r1 = _get_overview(client)
assert r1.status_code == 200
assert r1.json()["data"]["rows"][0]["speed"] is None
# 坏 payload 不得入缓存:缓存里的 speed 应已是 None
cached = board_mac._overview_cache[("HY", ("SPEED", "CHANGE_20D"), 2000)][1]
assert cached["rows"][0]["speed"] is None
r2 = _get_overview(client) # 命中缓存也不再 500
assert r2.status_code == 200
assert r2.json()["data"]["rows"][0]["speed"] is None
assert fake.calls.count("SPEED") == 1