Files
tick-stock-panel/backend/tests/test_rps_rotation_days_cache.py
T
kevin9327 1e2c7afa93 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 复用。
2026-09-10 07:45:55 +09:00

103 lines
3.3 KiB
Python

"""涨幅轮动矩阵结果缓存必须按 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