mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
自定义 (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>
138 lines
4.8 KiB
Python
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()
|