Files
tick-stock-panel/backend/tests/test_enriched_history_span.py
T
richardandClaude 5123274fb0 feat(watchlist): 新增「加入日期」「加入以来」两列与日K加入自选日标注
自选 parquet 一直记着 added_at, 但前端从未消费过, 用户看不到加入时间也算不出加入以来收益。
本次在 /api/watchlist/enriched 读时计算并下发 (与涨跌停价同属现算、不落盘):

- added_at 原样透传 (naive UTC), 前端按 UTC 解析后转北京时间展示
- pct_since_added 以「加入日或之前最近交易日」的前复权收盘价为基准, 小数口径 (与 momentum_*
  一致); 超出内存历史缓存窗口 (约 300 自然日) 与 ETF/指数显式降级为 null —— 该端点位于
  SSE 行情失效前缀, 每几秒被重拉一次, 故不做逐只 scan 兜底
- 新增 KlineRepository.get_enriched_history_span(): 纯读 O(1), 用于区分「预热中」与「日期超窗」
  (get_enriched_range 对两者都返回 None)

前端两列默认隐藏 (自定义列 → 自选分组); K线弹窗日K主图用 custom series 画蓝色竖虚线 +
带框「自选」标签, 从该根K线下沿连到网格底边, 缩放后自动重绘, 工具栏提供独立开关。
日期不在可视区间 / 周末节假日加入时竖线不画 (不做 snap), 由工具栏文字兜底。

验证: 后端定向 pytest 45 passed, ruff 相对改动前基线零新增; 前端 pnpm build 通过, vitest 20 passed

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 16:35:26 +08:00

77 lines
2.7 KiB
Python

"""get_enriched_history_span 的区间判定测试。
该方法供自选 enriched 端点判定「加入日是否在可查询历史范围内」, 必须满足:
- 纯读 O(1): 缓存冷/预热中不得触发 _refresh_enriched (端点随行情 tick 被反复调用);
- 缓存不可用时返回 None, 而不是把「预热中」和「日期超窗」混为一谈。
"""
from __future__ import annotations
from datetime import date
import polars as pl
from app.tickflow.repository import KlineRepository
def _bare_repo(cache=None, start=None, cache_date=None) -> KlineRepository:
"""跳过 __init__ (避免 DataStore/目录依赖), 只装配区间访问涉及的属性。"""
repo = KlineRepository.__new__(KlineRepository)
repo._enriched_history_cache = cache
repo._enriched_history_start = start
repo._enriched_cache_date = cache_date
return repo
def _spy_refresh(repo):
calls: list[int] = []
repo._refresh_enriched = lambda: calls.append(1) # type: ignore[method-assign]
return calls
def test_span_none_when_cache_cold():
repo = _bare_repo()
calls = _spy_refresh(repo)
assert repo.get_enriched_history_span() is None
assert calls == [], "缓存冷时不得触发全量重算"
def test_span_none_when_cache_empty():
repo = _bare_repo(cache=pl.DataFrame(schema={"symbol": pl.Utf8, "date": pl.Date}),
start=date(2026, 1, 1), cache_date=date(2026, 8, 14))
assert repo.get_enriched_history_span() is None
def test_span_none_when_start_missing():
"""预热降级路径会写入 cache_date 但不装配历史起点, 此时不得报出区间。"""
cache = pl.DataFrame({"symbol": ["600000.SH"], "date": [date(2026, 8, 14)]})
repo = _bare_repo(cache=cache, start=None, cache_date=date(2026, 8, 14))
assert repo.get_enriched_history_span() is None
def test_span_returns_bounds_without_refresh():
cache = pl.DataFrame({
"symbol": ["600000.SH", "600000.SH"],
"date": [date(2026, 1, 1), date(2026, 8, 14)],
})
repo = _bare_repo(cache=cache, start=date(2026, 1, 1), cache_date=date(2026, 8, 14))
calls = _spy_refresh(repo)
assert repo.get_enriched_history_span() == (date(2026, 1, 1), date(2026, 8, 14))
assert calls == [], "命中缓存时必须纯读, 不得触发 _refresh_enriched"
def test_span_falls_back_to_column_max():
"""cache_date 缺失时回退取日期列最大值 (仍不触发刷新)。"""
cache = pl.DataFrame({
"symbol": ["600000.SH", "600000.SH"],
"date": [date(2026, 1, 1), date(2026, 8, 14)],
})
repo = _bare_repo(cache=cache, start=date(2026, 1, 1), cache_date=None)
calls = _spy_refresh(repo)
assert repo.get_enriched_history_span() == (date(2026, 1, 1), date(2026, 8, 14))
assert calls == []