Files
easy_tdx_max/tests/unit/test_board_mac_hotspot.py
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

418 lines
16 KiB
Python
Raw Permalink 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.
"""/board-mac/hotspot 热点滚动端点单测(离线,mock MAC 客户端)。
覆盖:后台构建状态机(building→ready / error 稳定 + retry 重建)、涨跌矩阵口径
(close 逐日环比)、每日排名与行集合并集(top/bottom 镜像)、行元数据
days_in/streak/best_rank/复利 sum_pct/first_date)、今日列实时合并与
休市(全市场未移动)去重、当日缓存复用(不重拉日K)、无效 mode 400。
"""
from __future__ import annotations
import time
import pandas as pd
import pytest
# ── 测试数据:21 个交易日(首根为窗口前锚点),三个板块涨跌幅恒定 ──────────────
_DATES = [d.strftime("%Y-%m-%d") for d in pd.bdate_range("2026-07-31", periods=21)]
# 轴 = 首根之后的 20 个交易日
_AXIS = _DATES[1:]
def _kline_df(start_close: float, daily: float) -> pd.DataFrame:
closes = [start_close * (daily**i) for i in range(len(_DATES))]
return pd.DataFrame({"datetime": pd.to_datetime(_DATES), "close": closes})
_BOARDS = [
{"market": 1, "code": "881106", "name": "存储器", "price": 0.0, "pre_close": 0.0},
{"market": 1, "code": "881105", "name": "CPO", "price": 0.0, "pre_close": 0.0},
{"market": 1, "code": "881101", "name": "房地产开发", "price": 0.0, "pre_close": 0.0},
]
class _FakeHotspotMacClient:
"""按 code 返回恒定日涨跌幅 K 线的替身客户端。
存储器 +5%/日、CPO +2%/日、房地产开发 -1%/日;实时报价由 live_prices
提供(price/pre_close),缺省全部未移动(休市口径)。
"""
def __init__(self, live_prices: dict[str, tuple[float, float]] | None = None):
self.klines = {
"881106": _kline_df(100.0, 1.05),
"881105": _kline_df(200.0, 1.02),
"881101": _kline_df(300.0, 0.99),
}
self.live_prices = live_prices or {}
self.kline_calls = 0
self.list_calls = 0
async def get_board_list(self, board_type=None, count=5000, sort_column=None):
self.list_calls += 1
rows = []
for b in _BOARDS:
row = dict(b)
price, pre = self.live_prices.get(b["code"], (0.0, 0.0))
row["price"], row["pre_close"] = price, pre
rows.append(row)
return pd.DataFrame(rows)
async def get_stock_kline(
self, market=1, code="", period=None, start=0, count=800, times=1, adjust=None, **_
):
self.kline_calls += 1
return self.klines.get(code, pd.DataFrame())
def _hotspot_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(monkeypatch):
from easy_tdx.web.routers import board_mac
board_mac._hotspot_history_cache.clear()
board_mac._hotspot_builds.clear()
# 默认把"今天"钉在远期:不在 K 线轴内且实时报价未移动 → 不追加今日列
monkeypatch.setattr(board_mac, "_today_str", lambda: "2030-01-01")
yield
board_mac._hotspot_history_cache.clear()
board_mac._hotspot_builds.clear()
def _get(client, client_obj, **params):
query = {"board_type": "HY", "days": 10, "per_day": 2, **params}
resp = client.get("/api/v1/board-mac/hotspot", params=query)
assert resp.status_code == 200, resp.text
return resp.json()["data"], client_obj
def _wait_ready(client, client_obj, timeout=10.0, **params):
"""轮询直至构建结束,返回最终 payload。"""
deadline = time.time() + timeout
data = None
while time.time() < deadline:
data, client_obj = _get(client, client_obj, **params)
if data["status"] != "building":
return data, client_obj
time.sleep(0.02)
raise AssertionError(f"热点矩阵构建超时: {data}")
def test_hotspot_build_matrix_and_metadata():
"""building→ready;矩阵口径、每日排名、行集合并集、行元数据全量校验。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
fake = _FakeHotspotMacClient()
with TestClient(_hotspot_app(fake)) as client:
data, fake = _wait_ready(client, fake)
assert data["status"] == "ready"
assert data["dates"] == _AXIS[-10:]
assert data["today_index"] is None # 实时未移动 → 不追加今日列
assert data["total_boards"] == 3
rows = {r["code"]: r for r in data["rows"]}
# 每日 +5%/+2% 恒定 → 前 2 名恒为存储器、CPO;房地产开发从不上榜
assert set(rows) == {"881106", "881105"}
mem = rows["881106"]
assert mem["pct"] == [5.0] * 10
assert mem["rank"] == [1] * 10
assert mem["days_in"] == 10
assert mem["streak"] == 10
assert mem["best_rank"] == 1
assert mem["first_date"] == _AXIS[-10]
assert mem["sum_pct"] == pytest.approx(((1.05**10) - 1) * 100, abs=0.01)
cpo = rows["881105"]
assert cpo["rank"] == [2] * 10
assert cpo["sum_pct"] == pytest.approx(((1.02**10) - 1) * 100, abs=0.01)
def test_hotspot_mode_bottom_mirrors_selection():
"""mode=bottom:每日最弱入选,排名语义镜像(1=跌幅最大)。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
fake = _FakeHotspotMacClient()
with TestClient(_hotspot_app(fake)) as client:
data, _ = _wait_ready(client, fake, mode="bottom")
rows = {r["code"]: r for r in data["rows"]}
# 跌幅最深(-1%/日)与次深(+2%/日弱于 +5%)入选
assert set(rows) == {"881101", "881105"}
assert rows["881101"]["rank"] == [1] * 10
assert rows["881101"]["days_in"] == 10
assert rows["881101"]["sum_pct"] == pytest.approx(((0.99**10) - 1) * 100, abs=0.01)
assert rows["881105"]["rank"] == [2] * 10
def test_hotspot_live_today_column_merged():
"""实时报价有移动 → 追加今日列:日期=今天、涨跌=price/pre_close、计入排名与连榜。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from easy_tdx.web.routers import board_mac
today = "2026-08-31"
board_mac._today_str = lambda: today # type: ignore[assignment]
# 存储器 +3%、CPO 大跌 -3%(跌出当日前2)、地产 -0.5%(挤进当日前2)
pre_a = 100.0 * (1.05**20)
pre_b = 200.0 * (1.02**20)
pre_c = 300.0 * (0.99**20)
live = {
"881106": (round(pre_a * 1.03, 4), round(pre_a, 4)),
"881105": (round(pre_b * 0.97, 4), round(pre_b, 4)),
"881101": (round(pre_c * 0.995, 4), round(pre_c, 4)),
}
fake = _FakeHotspotMacClient(live_prices=live)
try:
with TestClient(_hotspot_app(fake)) as client:
data, _ = _wait_ready(client, fake)
finally:
board_mac._today_str = lambda: "2030-01-01" # type: ignore[assignment]
assert data["dates"][-1] == today
assert data["today_index"] == len(data["dates"]) - 1
assert len(data["dates"]) == 11
rows = {r["code"]: r for r in data["rows"]}
mem = rows["881106"]
assert mem["pct"][-1] == 3.0
assert mem["rank"][-1] == 1 # +3% 强于地产 -0.5% 与 CPO -3%
assert mem["days_in"] == 11 # 窗口 10 日 + 今日列
assert mem["streak"] == 11
assert mem["sum_pct"] == pytest.approx(((1.05**10) * 1.03 - 1) * 100, abs=0.01)
# CPO 今日大跌跌出前2 → 今日列计入排名但断连
cpo = rows["881105"]
assert cpo["pct"][-1] == -3.0
assert cpo["rank"][-1] == 3
assert cpo["days_in"] == 10
assert cpo["streak"] == 0
# 房地产开发仅今日上榜 → 进入行集合,首榜=今日
estate = rows["881101"]
assert estate["pct"][-1] == -0.5
assert estate["rank"][-1] == 2
assert estate["days_in"] == 1
assert estate["first_date"] == today
def test_hotspot_market_idle_no_live_column():
"""全市场无一移动(盘前/休市)→ 不追加全 0 假列。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
# live_prices 为空 → 全部 price=pre_close=0 → any_moved=False
fake = _FakeHotspotMacClient()
with TestClient(_hotspot_app(fake)) as client:
data, _ = _wait_ready(client, fake)
assert data["today_index"] is None
assert data["dates"] == _AXIS[-10:]
assert data["session"] == "closed"
def test_hotspot_weekend_duplicate_live_suppressed():
"""周末隔夜 pre_close 未滚动:实时涨跌与历史末列重合 → 不追加重复的假今日列。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from easy_tdx.web.routers import board_mac
board_mac._today_str = lambda: "2026-08-29" # type: ignore[assignment] # 周六,不在轴内
# price=最后一根 close、pre_close=前一根 close → 实时涨跌 == 历史末列(最后一个交易日的涨幅)
live = {
"881106": (100.0 * (1.05**20), 100.0 * (1.05**19)),
"881105": (200.0 * (1.02**20), 200.0 * (1.02**19)),
"881101": (300.0 * (0.99**20), 300.0 * (0.99**19)),
}
fake = _FakeHotspotMacClient(live_prices=live)
try:
with TestClient(_hotspot_app(fake)) as client:
data, _ = _wait_ready(client, fake)
finally:
board_mac._today_str = lambda: "2030-01-01" # type: ignore[assignment]
assert data["today_index"] is None
assert data["dates"] == _AXIS[-10:] # 仍是 10 列窗口,无 08-29 重复列
def test_hotspot_history_cache_reused():
"""当日缓存复用:二次请求不重拉日K,仅刷新实时列表。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
fake = _FakeHotspotMacClient()
with TestClient(_hotspot_app(fake)) as client:
_wait_ready(client, fake)
kline_calls_after_build = fake.kline_calls
list_calls_after_build = fake.list_calls
data, _ = _get(client, fake)
assert data["status"] == "ready"
assert fake.kline_calls == kline_calls_after_build # 日K零重复拉取
assert fake.list_calls == list_calls_after_build + 1 # 实时列每次现取
def test_hotspot_error_stable_until_retry():
"""全部板块日K失败 → error 状态稳定(轮询不冲掉错误),retry=1 触发重建。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
class _EmptyKlineClient(_FakeHotspotMacClient):
async def get_stock_kline(self, **_): # noqa: D102 — 全部返回空
self.kline_calls += 1
return pd.DataFrame()
fake = _EmptyKlineClient()
with TestClient(_hotspot_app(fake)) as client:
data, _ = _wait_ready(client, fake)
assert data["status"] == "error"
assert "日K" in data["error"]
# 不带 retry 的再次请求:错误稳定(不再重拉日K)
fake2 = fake
with TestClient(_hotspot_app(fake2)) as client:
data, _ = _get(client, fake2)
assert data["status"] == "error"
# retry=1 → 重新构建(仍失败,但状态机走 building)
with TestClient(_hotspot_app(fake2)) as client:
resp = client.get(
"/api/v1/board-mac/hotspot",
params={"board_type": "HY", "days": 10, "per_day": 2, "retry": "true"},
)
assert resp.status_code == 200
# 任务刚启动:building 或(极快完成后的)error 均合法
assert resp.json()["data"]["status"] in ("building", "error")
def test_hotspot_invalid_mode_returns_400():
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
fake = _FakeHotspotMacClient()
with TestClient(_hotspot_app(fake)) as client:
resp = client.get(
"/api/v1/board-mac/hotspot",
params={"board_type": "HY", "mode": "sideways"},
)
assert resp.status_code == 400
assert "mode" in resp.json()["detail"]
def test_hotspot_missing_kline_board_excluded():
"""个别板块无日K:不参与排名,其余板块矩阵不受影响。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
fake = _FakeHotspotMacClient()
del fake.klines["881101"] # 房地产开发缺日K
with TestClient(_hotspot_app(fake)) as client:
data, _ = _wait_ready(client, fake)
assert data["total_boards"] == 2
assert all(r["code"] != "881101" for r in data["rows"])
def test_hotspot_correlation_matrix_ready():
"""缓存就绪:相关矩阵直接可算,完全同向的两板块相关系数 = 1。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from easy_tdx.web.routers import board_mac
board_mac._hotspot_history_cache["HY"] = (
"2030-01-01",
{
"axis": ["2026-08-10", "2026-08-11", "2026-08-12"],
"pct": {
"881100": {"2026-08-10": 5.0, "2026-08-11": 3.0, "2026-08-12": 1.0},
"881200": {"2026-08-10": 4.0, "2026-08-11": 2.0, "2026-08-12": 0.0},
},
"names": {"881100": "甲板块", "881200": "乙板块"},
},
)
fake = _FakeHotspotMacClient()
try:
with TestClient(_hotspot_app(fake)) as client:
resp = client.get(
"/api/v1/board-mac/hotspot-correlation",
params={"board_type": "HY", "days": 5, "per_day": 2},
)
finally:
board_mac._hotspot_history_cache.clear()
assert resp.status_code == 200
data = resp.json()["data"]
assert data["status"] == "ready"
assert [b["code"] for b in data["boards"]] == ["881100", "881200"]
assert data["matrix"][0][0] == 1.0
assert data["matrix"][0][1] == pytest.approx(1.0, abs=0.01) # 完全线性同向
assert data["matrix"][1][0] == data["matrix"][0][1]
def test_hotspot_correlation_building_passthrough():
"""无缓存:与 hotspot 相同的 building 状态透传,前端轮询即可。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
fake = _FakeHotspotMacClient()
with TestClient(_hotspot_app(fake)) as client:
resp = client.get("/api/v1/board-mac/hotspot-correlation", params={"board_type": "HY"})
assert resp.status_code == 200
body = resp.json()["data"]
assert body["status"] in ("building", "error", "ready") # 单机假客户端极快时可能已完成
if body["status"] == "building":
assert 0.0 <= body["progress"] <= 1.0
# ── 时区统一(v1.32.6):日历日一律取沪市时区,与主机时区无关 ────────────────
# 模块导入时捕获真实实现(autouse fixture 会把 _today_str 换成钉死的 lambda
_board_mac_mod = pytest.importorskip("easy_tdx.web.routers.board_mac")
_REAL_TODAY_STR = _board_mac_mod._today_str
def test_today_str_uses_shanghai_tz(monkeypatch):
"""_today_str 必须用 SHANGHAI_TZ 取"今天"(旧实现用主机本地时区)。
海外机器(如 UTC-5)上北京时间 09-06 02:00 时本地还是 09-05
旧实现会把热点矩阵的"今日"判定错一天。
"""
from datetime import datetime
pytest.importorskip("fastapi")
from easy_tdx.realtime.session import SHANGHAI_TZ
from easy_tdx.web.routers import board_mac
# 恢复被 autouse fixture 钉住的真实现
monkeypatch.setattr(board_mac, "_today_str", _REAL_TODAY_STR)
captured: dict = {}
class _FakeDatetime:
@classmethod
def now(cls, tz=None):
captured["tz"] = tz
return datetime(2026, 9, 6, 2, 0, tzinfo=tz) if tz else datetime(2026, 9, 6, 2, 0)
monkeypatch.setattr(board_mac, "datetime", _FakeDatetime)
assert board_mac._today_str() == "2026-09-06"
assert captured["tz"] is SHANGHAI_TZ