Files
tick-stock-panel/backend/scripts/bench_factor_materialize.py
T
shy3130 6d51bb52de feat(factors): P2b 内容寻址因子列缓存 — +10 因子热路径增量 0.1ms, P2 两项验收达成
自定义 (DSL) 因子此前每次求值都全量重算 frame_transform (132 万行帧上
每因子 ~36ms, 随因子数线性增长 — 回测/守护复验/实验室批量的共同热路径)。

- app/factors/materialize_cache.py: 键 = sha256(定义 + 依赖列内容指纹 +
  行数) — 内容指纹即失效传播 (公式变/输入任何值变 → 键变), 无需数据版本
  号。两层: 进程内 LRU (字节上限) + 磁盘 parquet (跨进程, 文件数上限,
  目录可整删)。fail-open: 任何故障回退现算。列指纹按帧记忆化 (weakref
  finalize 回收), 同帧 K 个因子每列只哈希一次 (~8ms/百万行)。
- materialize_scoring_columns 两段式: 命中列一次批量附着 (成本与因子数
  无关); 未命中逐个现算 (依赖顺序)。DSL 分阶段临时列不再泄漏进下游帧
  (与命中路径列集合完全一致)。
- 顺带修复存量 bug: winsorize(x) 省略 k 时 DSL 编译器直接 IndexError
  (该写法此前完全不可用), 补回归测试。
- 基准 (132 万行, K3→K13): 热路径 0.0ms→0.1ms (+10 因子绝对增量 0.1ms,
  基线 +400ms 线性); 冷启动 480.7ms vs 无缓存 478ms (开销 ~0.5%)。
  端到端优化器复测 trials 4.23×/含 prepare 3.16×, stats 完全一致。
- 回归: 552 个相关测试全过 (含 6 个缓存契约测试: 命中等价/输入与定义
  变更失效/损坏回退/小帧旁路/临时列不泄漏); ruff 新增文件全清。

Signed-off-by: shy3130 <shy3130@qq.com>
2026-10-26 14:36:58 +08:00

138 lines
4.8 KiB
Python

"""P2b 基准: 自定义因子物化的热路径成本随因子数量的增长。
场景: 同一帧上反复物化 K 个自定义 (DSL) 因子 — 回测/筛选/评分的每次运行
都走 materialize_scoring_columns。验收 (docs/ext-architecture-plan.md P2):
新增 10 个自定义指标后热路径耗时不变 (内容寻址缓存命中时应近似平坦)。
用法: cd backend && uv run python scripts/bench_factor_materialize.py [--json PATH]
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import logging
logging.getLogger("app").setLevel(logging.WARNING)
import polars as pl # noqa: E402
from app.config import settings # noqa: E402
from app.factors.registry import unregister_factor # noqa: E402
from app.factors.store import register_definition # noqa: E402
N_SYMBOLS = 4000
N_DAYS = 330
def _frame() -> pl.DataFrame:
base = pl.datetime_range(
__import__("datetime").date(2025, 1, 1),
__import__("datetime").date(2026, 12, 31),
"1d",
eager=True,
).dt.date()
days = base.head(N_DAYS)
df = pl.DataFrame({"symbol": [f"{600000 + i:06d}.SH" for i in range(N_SYMBOLS)]}).join(
pl.DataFrame({"date": days}), how="cross"
)
n = df.height
import random
rng = random.Random(7)
closes = [10.0 + rng.uniform(-1, 1) + (i % N_DAYS) * 0.01 for i in range(n)]
return df.with_columns(
pl.Series("close", closes, dtype=pl.Float64),
pl.Series("open", [c * 0.999 for c in closes], dtype=pl.Float64),
pl.Series("volume", [rng.uniform(5e4, 5e6) for _ in range(n)], dtype=pl.Float64),
pl.Series("amount", [rng.uniform(5e5, 5e7) for _ in range(n)], dtype=pl.Float64),
pl.Series("turnover_rate", [rng.uniform(0.1, 15.0) for _ in range(n)], dtype=pl.Float64),
).sort(["symbol", "date"])
_FORMULAS = {
"uf_bench_1": "ts_zscore(close, 20)",
"uf_bench_2": "ts_mean(volume, 10) / ts_mean(volume, 60)",
"uf_bench_3": "close / ts_mean(close, 60) - 1",
"uf_bench_4": "ts_std(close, 20) / ts_mean(close, 20)",
"uf_bench_5": "rank(ts_delta(close, 5))",
"uf_bench_6": "ts_corr(close, volume, 20)",
"uf_bench_7": "ts_sum(sign(close - ts_delay(close, 1)), 20)",
"uf_bench_8": "ts_max(close, 20) / close - 1",
"uf_bench_9": "ts_mean(turnover_rate, 5) / ts_mean(turnover_rate, 60)",
"uf_bench_10": "ts_rank(close, 120)",
"uf_bench_11": "winsorize(ts_zscore(amount, 20))",
"uf_bench_12": "decay_linear(ts_delta(close, 5), 10)",
"uf_bench_13": "zscore(ts_mean(volume, 20))",
}
def _register(names: list[str]) -> None:
import contextlib
for name in names:
with contextlib.suppress(ValueError):
unregister_factor(name)
register_definition({
"id": name, "label": name, "kind": "custom",
"formula": _FORMULAS[name], "status": "active",
})
def _time_materialize(frame: pl.DataFrame, names: list[str], repeats: int) -> list[float]:
from app.strategy.scoring import materialize_scoring_columns
out = []
for _ in range(repeats):
t = time.perf_counter()
work = frame
# 逐个物化 (与 scoring/backtest 调用同一条路径)
result = materialize_scoring_columns(work, names)
out.append(time.perf_counter() - t)
assert all(name in result.columns for name in names)
del result
return out
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--json", dest="json_path", default=None)
args = parser.parse_args()
from app.factors.materialize_cache import invalidate as _cache_invalidate
_cache_invalidate(disk=True) # 真冷启动 (磁盘缓存也清空)
frame = _frame()
row = {"rows": frame.height, "data_dir": str(settings.data_dir), "sets": {}}
for label, names in (
("K3", list(_FORMULAS)[:3]),
("K13", list(_FORMULAS)),
):
_register(names)
times = _time_materialize(frame, names, repeats=4)
row["sets"][label] = {
"n_factors": len(names),
"per_call_ms": [round(t * 1000, 1) for t in times],
"warm_call_ms": round(sorted(times)[1] * 1000, 1), # 第2-4次的代表值
}
# 清掉物化列, 下一组从干净帧开始
frame = frame.select([c for c in frame.columns if not c.startswith("uf_")])
for name in names:
unregister_factor(name)
row["warm_delta_ms_K3_to_K13"] = round(
row["sets"]["K13"]["warm_call_ms"] - row["sets"]["K3"]["warm_call_ms"], 2
) # +10 因子的热路径绝对增量 (验收: 应近零)
print(json.dumps(row, ensure_ascii=False, indent=2))
if args.json_path:
Path(args.json_path).write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()