mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
fix(rps): 轮动矩阵缓存按覆盖天数复用, 切到更长窗口不再少列
打开「概念分析 → 涨幅RPS轮动」时先看 7 日再切到 30 日, 矩阵只有 25 列。
build_rps_rotation 按 days 换算日历窗口读 enriched (days=7 只读 24 个自然日),
但结果缓存键是 "{kind}|{level}|{latest}", 不含 days。120s TTL 内第二次请求
命中第一次那份按 7 日窗口算出来的矩阵, _slice_cached 又因为 len(dates) <= days
原样返回, 于是请求 30 列拿到 25 列。
记录每个缓存条目实际覆盖的天数, 只在覆盖天数 >= 请求天数时复用; 反方向
(宽窗缓存服务窄请求) 仍按原样 slice 复用。
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
"""涨幅轮动矩阵结果缓存必须按 days 区分。
|
||||
|
||||
build_rps_rotation 只按 days 换算出的日历窗口读 enriched (days=7 只读 24 个
|
||||
自然日), 但结果缓存键是 "{kind}|{level}|{latest}" —— 不含 days。先看 7 日
|
||||
再切到 30 日, 120s TTL 内会命中那份按 7 日窗口算出来的矩阵, 前端拿到的列数
|
||||
比请求的少。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
from datetime import date, timedelta
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from app.services import rps_rotation
|
||||
|
||||
_LATEST = date(2026, 6, 30)
|
||||
_HISTORY_DAYS = 80
|
||||
_MEMBERS = ("人工智能", "芯片")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_caches():
|
||||
rps_rotation.invalidate_cache()
|
||||
rps_rotation._map_cache.clear()
|
||||
rps_rotation._map_ts.clear()
|
||||
yield
|
||||
rps_rotation.invalidate_cache()
|
||||
rps_rotation._map_cache.clear()
|
||||
rps_rotation._map_ts.clear()
|
||||
|
||||
|
||||
def _history() -> pl.DataFrame:
|
||||
"""两只票在 80 个连续自然日上的 change_pct(小数制)。"""
|
||||
rows = []
|
||||
for offset in range(_HISTORY_DAYS):
|
||||
day = _LATEST - timedelta(days=offset)
|
||||
rows.append({"symbol": "S1.SH", "date": day, "change_pct": 0.01})
|
||||
rows.append({"symbol": "S2.SH", "date": day, "change_pct": -0.01})
|
||||
return pl.DataFrame(rows)
|
||||
|
||||
|
||||
def _fake_repo() -> types.SimpleNamespace:
|
||||
history = _history()
|
||||
|
||||
def get_enriched_range(start, end, columns=None):
|
||||
df = history.filter((pl.col("date") >= start) & (pl.col("date") <= end))
|
||||
return df.select(columns) if columns else df
|
||||
|
||||
return types.SimpleNamespace(
|
||||
_enriched_history_cache=history,
|
||||
get_enriched_range=get_enriched_range,
|
||||
store=types.SimpleNamespace(data_dir=None),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(monkeypatch) -> types.SimpleNamespace:
|
||||
map_df = pl.DataFrame(
|
||||
{"_sym_up": ["S1.SH", "S2.SH"], "concept": list(_MEMBERS)},
|
||||
schema={"_sym_up": pl.Utf8, "concept": pl.Utf8},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rps_rotation, "_load_concept_map_df", lambda _repo, kind: (map_df, len(_MEMBERS))
|
||||
)
|
||||
return _fake_repo()
|
||||
|
||||
|
||||
def test_widening_days_after_a_narrow_request_returns_all_days(repo):
|
||||
"""先请求 7 日再请求 30 日, 第二次必须拿到 30 列。
|
||||
|
||||
旧实现: 缓存键不含 days, 第二次命中第一次那份只有 25 列的矩阵。
|
||||
"""
|
||||
narrow = rps_rotation.build_rps_rotation(repo, days=7)
|
||||
assert len(narrow["dates"]) == 7
|
||||
|
||||
wide = rps_rotation.build_rps_rotation(repo, days=30)
|
||||
assert len(wide["dates"]) == 30
|
||||
assert len(wide["columns"]) == 30
|
||||
|
||||
|
||||
def test_narrowing_days_after_a_wide_request_still_slices(repo):
|
||||
"""反方向仍要按请求截断 (宽窗缓存可以复用, 但只返回请求的天数)。"""
|
||||
wide = rps_rotation.build_rps_rotation(repo, days=30)
|
||||
assert len(wide["dates"]) == 30
|
||||
|
||||
narrow = rps_rotation.build_rps_rotation(repo, days=7)
|
||||
assert len(narrow["dates"]) == 7
|
||||
assert narrow["dates"] == wide["dates"][:7]
|
||||
|
||||
|
||||
def test_same_days_request_hits_cache(repo, monkeypatch):
|
||||
"""同一 days 的重复请求仍走缓存, 不重新读 enriched。"""
|
||||
rps_rotation.build_rps_rotation(repo, days=12)
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise AssertionError("缓存未命中: 又读了一次 enriched")
|
||||
|
||||
monkeypatch.setattr(repo, "get_enriched_range", _boom)
|
||||
again = rps_rotation.build_rps_rotation(repo, days=12)
|
||||
assert len(again["dates"]) == 12
|
||||
Reference in New Issue
Block a user