Files
tick-stock-panel/backend/scripts/bench_optimizer_prepare.py
T
shy3130 660ce71fca feat(backtest): P2a 面板路径 prepare-once — 参数网格共享特征面板, 调参循环 1.6-1.9×
默认后端 (polars_expr/python_history_legacy) 的优化器此前每组参数都完整
重跑 载盘/指标计算/信号注入/扩展表 join (profile: 占每组成本 ~35-60%);
只有 matrix_native 有 prepare-once。本提交补齐面板路径:

- PreparedPanelBacktest: 逐组解析依赖计划取并集 (参数驱动的列差异由并集
  覆盖), 一次加载全组复用; run 逐组做 签名校验 + 所需列子集校验, 缺列
  显式报错不静默回退 (fail-closed, 掩盖计划漂移比失败更危险)。
- _merge_resolved_feature_plans 放宽为单一后端即可 (composite 的全
  matrix_native 语义不变); _panel_backend 谓词与 run 面板分支同口径。
- 优化器: polars_expr 分支 prepare 一次 + 全部试算与最优复跑复用同一面板;
  进度新增 optimizer_prepare 事件 (shared_panel_bytes)。
- 基准 scripts/bench_optimizer_prepare.py 同进程交错 A/B (消除页缓存
  顺序偏置): 15 组实测 trials 1.72×, 含 prepare 1.62× (6 组 1.92×/1.64×);
  stats 剔除计时字段后与逐组独立加载完全一致, 交易逐笔一致。
- 回归: tests/backtest 408 全过; ruff 零新增 (optimizer/strategy 告警
  与 HEAD 逐条相同)。

下一步 (P2a-2): build_market_matrix 的面板编码 (build_market_data_matrix
+_encode_axes, 参数无关, 占剩余每组成本 ~55%) 提到 prepare 复用, 预期
含 prepare 越过 2×; 其后 P2b L2 内容寻址物化。

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

123 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""P2 基准: 默认后端 (polars_expr) 参数网格优化的调参循环耗时。
对同一策略 + 同一参数网格跑 StrategyOptimizer.optimize, 记录准备/试算/总耗时。
P2a (prepare-once 面板复用) 前后各跑一次同一脚本对比, 验收标准:
调参循环提速 ≥2× (docs/ext-architecture-plan.md P2)。
用法:
cd backend && uv run python scripts/bench_optimizer_prepare.py [--strategy ID]
默认策略 custom_v1_kanban_strict (动态 filter_history, 默认执行后端)。
--json PATH 另存结果。
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from datetime import date
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
logging.getLogger("app").setLevel(logging.WARNING)
from app.backtest.engine import BacktestEngine # noqa: E402
from app.backtest.optimizer import StrategyOptimizer # noqa: E402
from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService # noqa: E402
from app.config import settings # noqa: E402
from app.strategy.engine import StrategyEngine # noqa: E402
from app.tickflow.repository import DataStore, KlineRepository # noqa: E402
STRATEGY_ID = "consolidation_limit_pullback"
PARAM_GRID = {
"box_days": [10, 15, 20, 25, 30],
"box_amplitude": [0.10, 0.20, 0.25],
}
START, END = date(2026, 1, 1), date(2026, 9, 30)
def _build() -> StrategyOptimizer:
from app.strategy import config as strategy_config
data_dir = Path(settings.data_dir)
dirs = [
Path(__file__).resolve().parents[1] / "app" / "strategy" / "research",
data_dir / "strategies" / "custom",
data_dir / "strategies" / "ai",
data_dir / "strategies" / "composite",
]
engine = StrategyEngine(
strategy_dirs=[d for d in dirs if d.exists()],
override_loader=lambda sid: strategy_config.load_override(data_dir, sid),
)
repo = KlineRepository(DataStore(data_dir))
service = StrategyBacktestService(BacktestEngine(repo), engine)
return StrategyOptimizer(service, engine)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--strategy", default=STRATEGY_ID)
parser.add_argument("--json", dest="json_path", default=None)
args = parser.parse_args()
optimizer = _build()
combos = [{"box_days": d, "box_amplitude": a} for d in PARAM_GRID["box_days"] for a in PARAM_GRID["box_amplitude"]]
cfgs = [
StrategyBacktestConfig(
strategy_id=args.strategy, symbols=None, start=START, end=END,
params=combo, mode="fast",
)
for combo in combos
]
# 交错 A/B (同进程, 每组交替先后顺序): 消除页缓存/预热顺序偏置。
# plain 臂 = 基线行为 (每组独立全量准备); shared 臂 = P2a (一次 prepare + 复用)。
t0 = time.perf_counter()
prepared_panel = optimizer.service.prepare_panel_optimization(cfgs)
prepare_s = time.perf_counter() - t0
plain_times: list[float] = []
shared_times: list[float] = []
plain_stats: list = []
shared_stats: list = []
for i, cfg in enumerate(cfgs):
order = ("plain", "shared") if i % 2 == 0 else ("shared", "plain")
for arm in order:
t = time.perf_counter()
res = optimizer.service.run(cfg, prepared_panel=prepared_panel if arm == "shared" else None)
elapsed = time.perf_counter() - t
assert res.error is None, f"combo {i} {arm}: {res.error}"
(plain_times if arm == "plain" else shared_times).append(elapsed)
(plain_stats if arm == "plain" else shared_stats).append(res.stats or {})
plain_total = sum(plain_times)
shared_total = sum(shared_times)
row = {
"strategy": args.strategy,
"combos": len(cfgs),
"panel_shape": list(prepared_panel.panel.shape),
"prepare_s": round(prepare_s, 2),
"plain_trials_s": round(plain_total, 2),
"shared_trials_s": round(shared_total, 2),
"plain_plus_prepare_s": round(plain_total, 2),
"shared_plus_prepare_s": round(prepare_s + shared_total, 2),
"trials_speedup": round(plain_total / shared_total, 2) if shared_total else None,
"incl_prepare_speedup": round(plain_total / (prepare_s + shared_total), 2) if shared_total else None,
"stats_identical": (
[{k: v for k, v in st.items() if k != "timing_ms"} for st in plain_stats]
== [{k: v for k, v in st.items() if k != "timing_ms"} for st in shared_stats]
),
"per_combo_plain_s": [round(t, 2) for t in plain_times],
"per_combo_shared_s": [round(t, 2) for t in shared_times],
}
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()