Files
easy_tdx_max/tests/unit/test_board_mac_hotspot.py
T
Justin Gu e419f911b7 feat: 盘面洞察三栏目 — 热点滚动/大盘日历/涨停生态,移除龙头池
- 热点滚动 /hotspots:交易日×板块涨跌矩阵(行业/概念/风格FG、领涨领跌镜像、今日~30日窗口),
  每日名次徽标+连板/累计/首榜统计,统计卡可点击展开成员板块;后端 /board-mac/hotspot
  两段式数据(历史日K矩阵当日缓存 + 今日实时列,周末 pre_close 未滚动去重),
  单连接约束下后台构建+进度轮询;风格轮动 /styles 为同视图 FG 路由别名
- 大盘日历 /calendar:指数全年红绿热力图,方框大小编码成交额(年内四分位),
  悬停浮框显示收盘/涨跌幅/成交额
- 涨停生态 /limitup:本地 vipdoc 日线离线回算连板天梯/首板二板分布/炸板率/跌停;
  仅统计最后一根 bar 等于全市场最新交易日的股票(防停牌/退市/未下载陈旧文件污染),
  主板 5% ST 判定带低价护栏;名称前端经 symbol-info 懒加载补齐
- 移除龙头池栏目及 /market/core-leaders 端点(screen 模块 universe=core 保留)
- 设计文档:docs/hotspot-rolling-design.md、docs/market-insights-roadmap.md
2026-09-05 02:43:02 +08:00

330 lines
12 KiB
Python
Raw 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"])