mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
uf_* 因子注册进进程级 _REGISTRY 后未注销, 累积运行时泄入 test_factor_registry 快照与 test_factor_store 断言 (6 条必挂, HEAD 全量复现)。夹具收尾注销本文件注册的 id。 全量: 3372 过 / 0 factor 污染; 余 4 条 assistant_actions 挂 经纯净 worktree 验证为 ext_pull 会话未提交 WIP 引入, 非本分支存量 Signed-off-by: shy3130 <shy3130@qq.com>
145 lines
5.2 KiB
Python
145 lines
5.2 KiB
Python
"""L2 内容寻址因子列缓存 (P2b) 契约测试。
|
|
|
|
验收对象 (docs/ext-architecture-plan.md P2): 新增自定义因子后, 重复求值
|
|
的热路径耗时不变。正确性契约:
|
|
- 命中 (进程内 / 磁盘) 与现算结果逐元素一致;
|
|
- 定义变更 / 输入值变更 → 键变 → 自动失效 (内容寻址即失效传播);
|
|
- 一切缓存故障 (目录只读 / parquet 损坏) 回退现算 (fail-open);
|
|
- 小帧旁路 (哈希+IO 比现算贵, 不缓存)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.config import settings
|
|
from app.factors import materialize_cache
|
|
from app.factors.registry import unregister_factor
|
|
from app.factors.store import register_definition
|
|
|
|
_REGISTERED: list[str] = []
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_cache(tmp_path: Path, monkeypatch) -> None:
|
|
"""缓存写到临时目录; 每个测试从全空缓存开始。
|
|
|
|
收尾同时注销本测试注册的 uf_* 因子 — 注册表是进程级全局,
|
|
残留会污染 test_factor_registry 的快照断言 (累积运行必挂)。"""
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
materialize_cache.invalidate(disk=True)
|
|
yield
|
|
import contextlib
|
|
|
|
for fid in _REGISTERED:
|
|
with contextlib.suppress(ValueError):
|
|
unregister_factor(fid)
|
|
_REGISTERED.clear()
|
|
materialize_cache.invalidate(disk=True)
|
|
|
|
|
|
def _frame(n: int = 60_000, seed: int = 3) -> pl.DataFrame:
|
|
rng = np.random.default_rng(seed)
|
|
return pl.DataFrame({
|
|
"symbol": ["600000.SH"] * n,
|
|
"date": np.arange(n, dtype=np.int64),
|
|
"close": rng.normal(10, 1, n),
|
|
"volume": rng.uniform(1e4, 1e6, n),
|
|
})
|
|
|
|
|
|
def _register(fid: str, formula: str, version: int = 1) -> None:
|
|
import contextlib
|
|
|
|
with contextlib.suppress(ValueError):
|
|
unregister_factor(fid)
|
|
register_definition({
|
|
"id": fid, "label": fid, "kind": "custom",
|
|
"formula": formula, "status": "active", "version": version,
|
|
})
|
|
_REGISTERED.append(fid)
|
|
|
|
|
|
def test_hit_matches_fresh_computation_lru_and_disk() -> None:
|
|
from app.strategy.scoring import materialize_scoring_columns
|
|
|
|
_register("uf_c1", "ts_zscore(close, 20)")
|
|
frame = _frame()
|
|
cold = materialize_scoring_columns(frame, ["uf_c1"])
|
|
assert materialize_cache.stats()["miss"] == 1
|
|
|
|
warm = materialize_scoring_columns(frame, ["uf_c1"])
|
|
assert materialize_cache.stats()["lru_hit"] == 1
|
|
assert warm["uf_c1"].equals(cold["uf_c1"])
|
|
|
|
materialize_cache.invalidate() # 清 LRU, 磁盘保留
|
|
disk_warm = materialize_scoring_columns(frame, ["uf_c1"])
|
|
assert materialize_cache.stats()["disk_hit"] == 1
|
|
assert disk_warm["uf_c1"].equals(cold["uf_c1"])
|
|
|
|
|
|
def test_input_value_change_invalidates() -> None:
|
|
from app.strategy.scoring import materialize_scoring_columns
|
|
|
|
_register("uf_c2", "ts_mean(close, 30)")
|
|
base = materialize_scoring_columns(_frame(seed=1), ["uf_c2"])
|
|
changed = materialize_scoring_columns(_frame(seed=1).with_columns(pl.col("close") * 2.0), ["uf_c2"])
|
|
assert not base["uf_c2"].equals(changed["uf_c2"])
|
|
assert materialize_cache.stats()["miss"] == 2 # 两次都是未命中 (键不同)
|
|
|
|
|
|
def test_formula_change_invalidates() -> None:
|
|
from app.strategy.scoring import materialize_scoring_columns
|
|
|
|
frame = _frame()
|
|
_register("uf_c3", "ts_zscore(close, 20)")
|
|
v1 = materialize_scoring_columns(frame, ["uf_c3"])
|
|
_register("uf_c3", "ts_zscore(close, 60)", version=2)
|
|
v2 = materialize_scoring_columns(frame, ["uf_c3"])
|
|
assert not v1["uf_c3"].equals(v2["uf_c3"])
|
|
|
|
|
|
def test_corrupt_disk_cache_falls_back_to_recompute() -> None:
|
|
from app.strategy.scoring import materialize_scoring_columns
|
|
|
|
_register("uf_c4", "ts_std(close, 20)")
|
|
frame = _frame()
|
|
cold = materialize_scoring_columns(frame, ["uf_c4"])
|
|
materialize_cache.invalidate() # 磁盘上留有 key.parquet
|
|
|
|
root = Path(settings.data_dir) / "cache" / "factor_columns"
|
|
files = list(root.glob("*.parquet"))
|
|
assert len(files) == 1
|
|
files[0].write_bytes(b"broken-not-parquet")
|
|
|
|
recovered = materialize_scoring_columns(frame, ["uf_c4"])
|
|
assert recovered["uf_c4"].equals(cold["uf_c4"]) # 损坏 → 回退现算, 结果一致
|
|
assert materialize_cache.stats()["error"] == 1
|
|
|
|
|
|
def test_small_frame_bypasses_cache() -> None:
|
|
from app.strategy.scoring import materialize_scoring_columns
|
|
|
|
_register("uf_c5", "ts_mean(volume, 10)")
|
|
small = _frame(n=1_000)
|
|
result = materialize_scoring_columns(small, ["uf_c5"])
|
|
assert "uf_c5" in result.columns
|
|
assert materialize_cache.stats()["bypass_small"] == 1
|
|
assert materialize_cache.stats()["miss"] == 0 # 小帧完全没进缓存路径
|
|
|
|
|
|
def test_staged_temp_columns_do_not_leak() -> None:
|
|
"""现算与命中两条路径的输出帧列集合一致 (DSL 分阶段临时列不泄漏)。"""
|
|
from app.strategy.scoring import materialize_scoring_columns
|
|
|
|
# 嵌套窗口公式会生成分阶段临时列
|
|
_register("uf_c6", "ts_mean(ts_zscore(close, 20), 10)")
|
|
frame = _frame()
|
|
cold = materialize_scoring_columns(frame, ["uf_c6"])
|
|
warm = materialize_scoring_columns(frame, ["uf_c6"])
|
|
assert cold.columns == warm.columns
|
|
assert set(warm.columns) - set(frame.columns) == {"uf_c6"}
|