mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
自选 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>
77 lines
2.7 KiB
Python
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 == []
|