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

267 lines
10 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.
"""涨停生态(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
# ── 涨跌停价舍入(回归:浮点 floor(x*100+0.5) 在半分边界错 1 分)──────────────
def test_limit_price_matches_exchange_rounding_all_range():
"""_limit_price 与交易所 ROUND_HALF_UP 对 1.00~600.00 全价位零差异。
旧实现(float 乘后 floor)在 ±10% 档 67/318 个价位、±5% 档 90/884 个
价位算低 1 分(如 prev=1.15:涨停价应 1.27,旧算 1.26)。
"""
from decimal import ROUND_HALF_UP, Decimal
from easy_tdx.screen.limitup import _limit_price
for pct in (10, 5, 20, -10, -5, -20):
for cents in range(100, 60001):
prev = Decimal(cents).scaleb(-2)
expected = (
int(
(Decimal(cents) * (100 + pct) / 100).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
/ 100
)
got = _limit_price(float(prev), pct)
assert got == expected, (prev, pct, got, expected)
def test_exchange_boundary_prices_detected(tmp_path):
"""半分边界价位的真实涨跌停不因浮点舍入漏判。
选点依据:.day 读回(raw×0.01)的浮点误差会抵消部分边界,33.05→36.36
与 2.65→2.39 是经读回仿真验证后旧实现(floor 浮点版)仍漏判的价位。
"""
from easy_tdx.screen.limitup import compute_limitup_ecology
# prev=33.05 → 交易所涨停价 36.36(旧实现误算 36.35 → 漏判涨停)
_write_stock(tmp_path, "sh", "600901", [33.05, 36.36])
# prev=2.65 → 交易所跌停价 2.39(旧实现误算 2.38 → 漏判跌停)
_write_stock(tmp_path, "sz", "000902", [2.65, 2.39])
eco = compute_limitup_ecology(tmp_path)
up = {e.code: e for e in eco.limit_up}
down = {e.code: e for e in eco.limit_down}
assert "600901" in up, f"33.05→36.36 应判涨停,实际 limit_up={up}"
assert up["600901"].streak == 1
assert "000902" in down, f"2.65→2.39 应判跌停,实际 limit_down={down}"
def test_history_boundary_prices_counted(tmp_path):
"""历史回补同样按交易所口径计涨跌停(33.05→36.36 / 2.65→2.39)。"""
from easy_tdx.screen.limitup import compute_limitup_history
_write_stock(tmp_path, "sh", "600901", [33.05, 36.36])
_write_stock(tmp_path, "sz", "000902", [2.65, 2.39])
rows = {r["date"]: r for r in compute_limitup_history(tmp_path, days=5)}
assert rows[20260802]["limit_up"] == 1
assert rows[20260802]["limit_down"] == 1
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 # 第二次命中缓存
def test_limitup_endpoint_cache_key_includes_vipdoc(vipdoc, tmp_path, monkeypatch):
"""缓存键须含 vipdoc:不同 vipdoc 的请求在 TTL 内不互相命中。
旧实现 _limitup_cache 是单值缓存,先到的 vipdoc=A 结果会被 vipdoc=B
的请求在 60s TTL 内复用。
"""
pytest.importorskip("fastapi")
from fastapi import FastAPI
from fastapi.testclient import TestClient
from easy_tdx.web.errors import register_exception_handlers
from easy_tdx.web.routers import market as market_mod
other = tmp_path / "vipdoc_other"
(other / "sh" / "lday").mkdir(parents=True)
(other / "sh" / "lday" / "sh600100.day").write_bytes(
_day(20260801, 9.95, 11.0, 9.9, 11.0) + _day(20260802, 11.0, 12.1, 10.9, 12.1)
)
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
r2 = client.get("/api/v1/limitup-ecology", params={"vipdoc": str(other)})
assert r2.status_code == 200
# 同 vipdoc 的第二次请求才命中缓存;不同 vipdoc 必须各自扫描
with TestClient(app) as client:
client.get("/api/v1/limitup-ecology", params={"vipdoc": str(vipdoc)})
client.get("/api/v1/limitup-ecology", params={"vipdoc": str(vipdoc)})
d = client.get("/api/v1/limitup-ecology", params={"vipdoc": str(other)}).json()["data"]
# other 目录只有 600100 一只 2 连板,不含 vipdoc 目录的 3 连板数据
assert d["summary"]["limit_up_count"] == 1