Files
tick-stock-panel/backend/tests/test_screener_jit_turnover.py
T
shy3130 4cb30e48aa fix: screener JIT 透传 turnover_rate (#187) + PullScheduler 线程安全 (#203)
- #187: _compute_enriched_full/_load_enriched_history 的 warmup 读取
  白名单补上存储列 turnover_rate —— instruments 不可用无从重算时,
  自定义 SQL 用该列做条件会 Binder Error 被吞成静默空结果
- #203: refresh 的 _tasks 增删 diff 从调用方线程移进主循环闭包,
  消除 TOCTOU 窗口; 行为测试覆盖增删/幂等

#187 测试已在修复前代码上验证会失败 (instruments 为空时列丢失)。
2026-09-03 13:45:12 +08:00

79 lines
3.3 KiB
Python

"""#187 回归: screener JIT 即时计算路径不得丢失 turnover_rate 存储列。
历史日期走 _compute_enriched_full (scan_parquet + compute_indicators),
warmup 读取白名单漏掉 turnover_rate 时, 即时计算后该列丢失 —— 自定义 SQL
用 turnover_rate 做条件的请求在 DuckDB 注册视图里找不到列, Binder Error
被 except 吞掉返回空结果 (无任何报错提示)。
"""
from __future__ import annotations
from datetime import date, timedelta
from unittest.mock import MagicMock
import polars as pl
from app.services.screener import ScreenerService
def _write_enriched(tmp_path, days: int, turnover_by_day: dict[str, float]) -> None:
base = tmp_path / "kline_daily_enriched"
base.mkdir(parents=True, exist_ok=True)
start = date(2026, 9, 1) - timedelta(days=days)
for i in range(days + 1):
d = start + timedelta(days=i)
part = base / f"date={d.isoformat()}"
part.mkdir(exist_ok=True)
pl.DataFrame(
{
"symbol": ["600000.SH", "000001.SZ"],
"date": [d, d],
"open": [10.0, 20.0],
"high": [11.0, 21.0],
"low": [9.0, 19.0],
"close": [10.5, 20.5],
"volume": [100.0, 200.0],
"amount": [1050.0, 4100.0],
"raw_close": [10.5, 20.5],
"raw_high": [11.0, 21.0],
"raw_low": [9.0, 19.0],
"turnover_rate": [turnover_by_day.get(d.isoformat(), 1.0), 2.0],
"consecutive_limit_ups": [0, 0],
"consecutive_limit_downs": [0, 0],
}
).write_parquet(part / "part.parquet")
def _service(tmp_path, instruments: pl.DataFrame | None = None) -> ScreenerService:
repo = MagicMock()
repo.store.data_dir = tmp_path
# 最新日缓存与 repo 级历史缓存均未命中 → 走 scan_parquet + 即时计算慢路径
repo.get_enriched_latest_asset.return_value = (pl.DataFrame(), None)
repo.get_enriched_history.return_value = None
# instruments 不可用 (空维表) 时 turnover_rate 无从重算, 只能靠存储列透传
repo.get_instruments_asset.return_value = instruments or pl.DataFrame()
repo.get_historical_shares.return_value = pl.DataFrame()
return ScreenerService(repo, asset_type="stock")
def test_historical_jit_frame_keeps_turnover_rate(tmp_path) -> None:
target = date(2026, 9, 1)
_write_enriched(tmp_path, 30, {target.isoformat(): 5.5})
svc = _service(tmp_path) # 无 instruments → 无重算路径, 纯存储列透传
df = svc._load_enriched_for_date(target)
assert not df.is_empty()
assert "turnover_rate" in df.columns, "JIT 即时计算后 turnover_rate 列不应丢失"
# 目标日的值来自存储列透传, 不是置 null
row = df.filter(pl.col("symbol") == "600000.SH").row(0, named=True)
assert row["turnover_rate"] == 5.5
def test_custom_sql_can_filter_on_turnover_rate(tmp_path) -> None:
target = date(2026, 9, 1)
_write_enriched(tmp_path, 30, {target.isoformat(): 5.5})
svc = _service(tmp_path)
result = svc.run(target, ["turnover_rate > 3"], limit=10)
# 600000 (5.5) 命中; 000001 恒为 2.0 被过滤 — 条件真正生效而非空结果
assert [r["symbol"] for r in result.rows] == ["600000.SH"]