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

167 lines
5.8 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.
"""涨停生态(screen.limitup + /limitup-ecology 端点)单测。
用合成 .day 二进制文件验证:涨停/连板/炸板/跌停判定、20cm 创业板、
主板 5% 疑似 ST、汇总统计与排序;端点侧验证 DictResponse 包装与 60s 缓存。
"""
from __future__ import annotations
import pytest
from easy_tdx.offline.daily_bar import _DAILY_FMT
def _day(date: int, open_: float, high: float, low: float, close: float) -> bytes:
"""按 .day 真实格式打包一根日线(价格 ×100 存 uint,成交额 f32)。"""
return _DAILY_FMT.pack(
date,
round(open_ * 100),
round(high * 100),
round(low * 100),
round(close * 100),
5_000_000.0,
1_000_000,
0,
)
def _write_stock(
vipdoc,
exchange: str,
code: str,
closes: list[float],
highs: list[float] | None = None,
dates: list[int] | None = None,
) -> None:
"""写一只股票的 .day 文件;closes 逐日收盘,highs 缺省=每日收盘。"""
lday = vipdoc / exchange / "lday"
lday.mkdir(parents=True, exist_ok=True)
highs = highs or closes
dates = dates or [20260801 + i for i in range(len(closes))]
data = b"".join(_day(d, c - 0.05, h, c - 0.10, c) for d, c, h in zip(dates, closes, highs))
(lday / f"{exchange}{code}.day").write_bytes(data)
@pytest.fixture
def vipdoc(tmp_path):
"""合成市场(全部股票最后 bar 对齐 20260804,模拟真实"同一交易日")。"""
last4 = [20260801, 20260802, 20260803, 20260804]
# 主板 3 连板:10.00 → 11.00 → 12.10 → 13.31(每根恰为 round(prev×1.1, 2)
_write_stock(tmp_path, "sh", "600100", [10.00, 11.00, 12.10, 13.31])
# 创业板 2 连板(20cm):20.00 → 24.00 → 28.80(首根铺垫同价)
_write_stock(tmp_path, "sz", "300200", [20.00, 20.00, 24.00, 28.80], dates=last4)
# 主板 5%(疑似 ST,前收 ≥3 才启用 ST 判定):10.00 → 10.50
_write_stock(tmp_path, "sh", "600300", [10.00, 10.00, 10.00, 10.50], dates=last4)
# 炸板:前收 10.00,最高触 11.00,收 10.80(离开 5% 价位避免歧义)
_write_stock(
tmp_path,
"sh",
"600400",
[10.00, 10.00, 10.00, 10.80],
highs=[10.20, 10.20, 10.50, 11.00],
dates=last4,
)
# 跌停:10.00 → 9.00
_write_stock(tmp_path, "sz", "000500", [10.00, 10.00, 10.00, 9.00], dates=last4)
# 平盘(无事件)
_write_stock(tmp_path, "sh", "600600", [10.00, 10.00, 10.00, 10.20], dates=last4)
# 陈旧文件:数据停在 20260703,当年的"3连板"不得进入今日生态
_write_stock(
tmp_path,
"sh",
"600700",
[10.00, 11.00, 12.10],
dates=[20260701, 20260702, 20260703],
)
# 低价 ST 护栏:前收 2.00<3)恰收 +5%2.10)不算涨停
_write_stock(tmp_path, "sh", "600800", [2.00, 2.00, 2.00, 2.10], dates=last4)
return tmp_path
def test_limitup_core_detection(vipdoc):
from easy_tdx.screen.limitup import compute_limitup_ecology
eco = compute_limitup_ecology(vipdoc)
assert eco.data_date == 20260804
assert eco.total == 8
up = {e.code: e for e in eco.limit_up}
assert set(up) == {"600100", "300200", "600300"} # 600700 陈旧排除、600800 低价护栏
board3 = up["600100"]
assert board3.streak == 3
assert board3.market == "SH"
assert board3.pct == pytest.approx(10.0, abs=0.01)
assert board3.st is False
cyb = up["300200"]
assert cyb.streak == 2 # 20cm 创业板
assert cyb.pct == pytest.approx(20.0, abs=0.01)
assert up["600300"].streak == 1
assert up["600300"].st is True # 主板 5% → 疑似 ST
# 连板天梯排序:高度降序
assert [e.streak for e in eco.limit_up] == [3, 2, 1]
# 炸板与跌停
assert [e.code for e in eco.blown] == ["600400"]
assert eco.blown[0].pct == pytest.approx(8.0, abs=0.01)
assert [e.code for e in eco.limit_down] == ["000500"]
assert eco.limit_down[0].streak == 1
s = eco.summary()
assert s["limit_up_count"] == 3
assert s["blown_count"] == 1
assert s["limit_down_count"] == 1
assert s["max_streak"] == 3
assert s["first_board"] == 1 # 仅 600300 首板;600100 三板、300200 二板
assert s["blown_rate"] == 25.0 # 3 封住 + 1 炸板
def test_limitup_empty_vipdoc(tmp_path):
from easy_tdx.screen.limitup import compute_limitup_ecology
eco = compute_limitup_ecology(tmp_path / "nonexistent")
assert eco.total == 0
assert eco.data_date == 0
assert eco.summary()["limit_up_count"] == 0
def test_limitup_endpoint_and_cache(vipdoc, monkeypatch):
"""端点返回 DictResponse 包装;60s 内命中缓存(扫描只跑一次)。"""
pytest.importorskip("fastapi")
from fastapi import FastAPI
from fastapi.testclient import TestClient
from easy_tdx.screen import limitup as limitup_mod
from easy_tdx.web.errors import register_exception_handlers
from easy_tdx.web.routers import market as market_mod
calls = {"n": 0}
real = limitup_mod.compute_limitup_ecology
def counting(*a, **kw):
calls["n"] += 1
return real(*a, **kw)
monkeypatch.setattr(limitup_mod, "compute_limitup_ecology", counting)
app = FastAPI()
register_exception_handlers(app)
app.include_router(market_mod.router, prefix="/api/v1")
app.state.tdx_client = object()
with TestClient(app) as client:
r1 = client.get("/api/v1/limitup-ecology", params={"vipdoc": str(vipdoc)})
assert r1.status_code == 200
d1 = r1.json()["data"]
assert d1["summary"]["limit_up_count"] == 3
assert d1["limit_up"][0]["code"] == "600100"
r2 = client.get("/api/v1/limitup-ecology", params={"vipdoc": str(vipdoc)})
assert r2.status_code == 200
assert r2.json()["data"] == d1
assert calls["n"] == 1 # 第二次命中缓存