设计草案:热启动配置继承 + 分阶段 result 合并(issue #282 二轮反馈)¶
状态:第 1 部分(配置持久化)已实现 · 第 2 部分(result 合并)已实现(
merge_results/MergedResult,metrics 为核心子集) · 第 3 部分(API 重命名,破坏性)已实现 · 日期:2026-07-20 · 范围:python/akquant/checkpoint.py、python/akquant/backtest/engine.py、python/akquant/backtest/result.py+ 示例/文档 · 对应 issue:#282 二轮反馈第 3、4 点 · 性质:配置继承缺陷(已根治)+ 体验增强(result 合并)+ API 命名演进(热启动→checkpoint/resume)
0. 结论先行¶
issue #282 二轮反馈实跑核实结果:
| 点 | 内容 | 结论 |
|---|---|---|
| 1 | 二阶段重放一阶段 on_before_trading |
不可复现。#329 已保证钩子去重;用户看到的是策略实例累加器随快照 pickle 保存的历史残留,非重新触发。建议文档提示在 on_resume 重置统计。 |
| 2 | 二阶段 on_after_trading 变每日首 bar 触发 |
不可复现。precise / 默认模式冷热启动钩子序列结构一致。同上,累加器残留误导。 |
| 3 | 滑点/佣金未在热启动继承 | 滑点真 bug,已在 run_warm_start 补 set_slippage/set_volume_limit(最小移植);佣金半通(走 _resolve_stock_fee_rules)。本 RFC 提出根治:快照持久化配置。 |
| 4 | 缺 result 合并函数 | 功能缺失,本 RFC 提出 merge_results / ResultMerger。 |
本 RFC 只覆盖第 3 点的根治方案与第 4 点。第 3 点的最小热修已单独落地(不依赖本 RFC)。
1. 第 3 点根治:快照持久化回测配置¶
1.1 现状缺陷链(已对照源码核实)¶
save_snapshot(checkpoint.py:86-97)只存engine_state(Rust 二进制)+strategy+strategy_topology+snapshot_features,不含任何回测配置(滑点、量比、fill_policy、市场模型、费率)。EngineSnapshot(src/engine/python.rs:589-639)序列化 portfolio/order_manager/instruments/risk_state 等,不含 timers、slippage、fee rules、fill_policy——load_state_bytes后这些全为引擎默认值。- 因此
run_warm_start必须把每一项配置重新推导并 set 一遍。团队已重配费率(engine.py:5451-5528)、fill_policy(5539-5555),却漏了滑点/量比(已由最小热修补上)。 - 根因:"热启动重配所有配置"这件事没有单一数据源,靠
run_warm_start逐项手工补,漏一项就静默失效。滑点只是暴露出来的第一个;任何未来新增的引擎配置都可能重蹈覆辙。
1.2 目标与非目标¶
目标
- 让"分阶段回测继承完整回测配置"成为默认且自动的行为,用户只需关心真正变化的量:
data(下阶段数据)、symbols(换月新代码)、instruments(资产属性)。 - 消除逐项手工重配的遗漏面:配置有单一权威来源(快照),
run_warm_start从中恢复。 - 显式传入的入参优先级最高,允许用户按阶段覆盖(如只改起止时间)。
非目标
- 不改 Rust
EngineSnapshot(避免二进制格式版本迁移成本);配置存 Python 侧 pickle 层。 - 不强制用户改现有代码——旧快照(无配置字段)必须能继续 warm start(降级到当前逐项推导逻辑)。
1.3 提案:快照增加 backtest_config 字段¶
A. save_snapshot 侧
save_snapshot(engine, strategy, filepath) 目前签名不含 config。两个方案:
- A1(推荐):新增可选参
save_snapshot(engine, strategy, filepath, *, config=None, resolved_runtime=None)。当run_backtest结束把已解析的运行时配置(归一化后的 slippage policy、volume_limit_pct、fill_policy、fee rules、market model、timezone、history_depth 等)挂到result上,用户save_snapshot(result.engine, result.strategy, path, config=result.resolved_config)即可。 - A2:
run_backtest在result上暴露result.resolved_config(一个 dataclass/dict),save_snapshot自动从engine/strategy反查——但引擎不留存原始 policy,反查不可靠。故取 A1。
快照结构扩展(向后兼容,新增键):
snapshot = {
"engine_state": ...,
"strategy": ...,
"strategy_topology": {...},
"snapshot_features": {"history_buffer_snapshot": True},
"backtest_config": { # 新增,可选
"slippage": {"type": "percent", "value": 0.0001},
"volume_limit_pct": 0.25,
"fill_policy": {"price_basis": "open", "temporal": "next_event", "bar_offset": 1},
"commission_policy": {"type": "percent", "value": 0.0003},
"stamp_tax_rate": 0.0,
"transfer_fee_rate": 0.0,
"min_commission": 5.0,
"t_plus_one": False,
"market_model": "simple" | "china",
"timezone": "Asia/Shanghai",
"history_depth": 50,
},
"version": _VERSION,
}
B. run_warm_start 侧 —— 配置解析优先级
统一成一条链,自上而下取第一个非 None:
实现:在现有各配置解析点前,先从 snapshot["backtest_config"] 读默认值填充局部变量,再让现有"显式入参优先"逻辑覆盖。这样:
- 用户什么都不传 → 完全继承一阶段配置(修复根因)。
- 用户只改起止时间 → 其余继承,符合"注意力放在真正变化的量"。
- 用户显式传 slippage → 覆盖快照值(保留逐阶段调参能力)。
C. 向后兼容
snapshot.get("backtest_config") 为 None(旧快照)时,走当前逐项推导逻辑(已含滑点最小热修),行为不回退。
1.4 落地顺序(已完成)¶
- ✅
result.resolved_config暴露(run_backtest收尾经_build_resolved_backtest_config填充 slippage/volume_limit/commission/stamp_tax/transfer_fee/min_commission/t_plus_one/timezone/history_depth/fill_policy)。 - ✅
save_snapshot(..., config=result|dict)经_extract_resolved_config写入快照backtest_config字段。 - ✅
warm_start把backtest_config挂到strategy._warm_start_backtest_config;run_warm_start建立显式入参 > config.strategy_config > 快照配置 > 默认优先级链(slippage/volume_limit/fee/fill_policy/t_plus_one)。 - ✅ 回归测试:
test_run_warm_start_inherits_config_from_snapshot(零显式入参继承)、test_run_warm_start_explicit_arg_overrides_snapshot_config(显式覆盖)、test_run_warm_start_without_snapshot_config_is_backward_compatible(旧快照降级)。前两者经git stash验证在未实现时失败、实现后通过。
实现说明:配置存 Python pickle 层(快照 dict 的 backtest_config 键),未改 Rust EngineSnapshot,旧快照 snapshot.get("backtest_config") 为 None 时降级到逐项推导,完全向后兼容。
2. 第 4 点:分阶段 result 合并¶
2.1 需求¶
完整回测 5 年 → 一条 5 年 equity_curve;按月分阶段 → 每段 result 只含当月曲线。用户需手动拼接。目标:官方 merge_results 自动处理曲线/交易/指标拼接,并防资产数量爆炸(清理已退市合约)。
2.2 可合并 / 需重算 / 需丢弃 三分¶
| 类别 | 字段 | 处理 |
|---|---|---|
| 可直接拼接(时间序列) | equity_curve、cash_curve、margin_curve、positions(快照序列)、orders、trades、executions、indicator_outputs |
按时间戳排序去重拼接;相邻阶段边界时间戳去重(热启动首 bar 可能与上阶段末 bar 语义重叠) |
| 必须重算(不可简单相加) | metrics(total_return、max_drawdown、sharpe、win_rate...) |
由合并后的 equity_curve + trades 重新计算;不能按阶段平均或相加。initial_cash 取第一阶段的,end_market_value 取最后阶段的 |
| 需丢弃/裁剪 | 已退市合约的 position snapshot、instrument 元数据 | 合并后清理配置了 expiry_date 且已过期的合约(用户明确要求防爆炸) |
2.3 API 设计¶
def merge_results(
*results: BacktestResult,
drop_expired_instruments: bool = True,
dedupe_boundary: bool = True,
) -> MergedResult:
"""按时间顺序合并多段分阶段回测结果。
- 曲线/交易/订单/执行按时间戳拼接去重;
- metrics 由合并后曲线+交易重算核心子集(非阶段简单相加);
- drop_expired_instruments=True 时清理已退市合约快照,防资产爆炸;
- dedupe_boundary=True 时去除相邻阶段边界重叠时间戳。
"""
已落地决策(与草案的差异):
- 返回类型 = 新建
MergedResult类,而非BacktestResult。原因:BacktestResult包装 Rust_raw对象(被 16 种方式访问),无法在 raw 层重建;MergedResult用纯 pandas 帧鸭子类型复刻只读视图(equity_curve/*_df/daily_returns/to_quantstats/metrics/metrics_df),不侵入BacktestResult的 Rust 契约。代价:plot()/report_quantstats()等依赖 Rust 对象的方法在合并结果上不可用。 - 配套链式便利:
merge_results(r1, r2)支持任意段数;单段merge_results(r)退化为恒等视图。
2.4 关键难点¶
- metrics 重算依赖:现在 metrics 在 Rust
generate_backtest_result(src/statistics/mod.rs:157)里算。合并发生在 Python 侧,需要纯 Python 的 metrics 计算路径(或复用to_quantstats+ quantstats)。建议:合并 result 的metrics走 Python 重算(基于拼接 equity_curve/daily_returns),与 Rust 单段 metrics 语义对齐但独立实现,并加测试锁定两者在单段场景数值一致。 - 边界去重语义:热启动首个 bar 的 equity 点可能与上阶段快照点时间戳相同,
upsert_timestamped_value(Rust 侧已有 upsert 语义)提示"同戳取后者"。Python 合并沿用"同戳保留后一阶段值"。 - positions 连续性:分阶段的持仓快照本身连续(热启动恢复了 portfolio),拼接即可;去重同戳。
2.5 落地顺序(已完成)¶
- ✅
merge_results+MergedResult(python/akquant/backtest/merge.py):曲线/orders/trades/executions/positions 拼接去重(纯 pandas)。 - ✅ Python metrics 核心子集重算(
_compute_core_metrics)+ 与单段完整回测的 golden 一致性测试(total_return_pct/end_market_valueapprox对齐)。 - ✅ 退市合约清理(依据 instrument snapshot
expiry_date)。 - ✅ 时间重叠→
ValueError;边界同戳去重(保留后段)。 - ✅ 导出(
akquant.merge_results/akquant.MergedResult)+.pyi存根。 - ✅ 文档:热启动专题(zh+en)补"多阶段结果合并"小节 +
reference/api.md签名 + 测试tests/test_merge_results.py(9 例)。
metrics 范围决策:全部 60 项指标由 Rust 引擎在完整回测中计算,无"从权益曲线算指标"的可复用 #[pyfunction] 入口。在 Python 全量复刻 60 项风险大(口径漂移)、收益低。故只重算能从合并曲线+交易明细无歧义推导的核心子集,口径对齐 src/analysis/result.rs(sharpe 用日收益算术均值×dpy、drawdown 用 cummax 峰值回撤、calmar=年化/最大回撤);其余字段访问时抛 AttributeError 并提示去单段 BacktestResult 读取。
3. API 命名演进:热启动 → checkpoint / resume(破坏性更新)¶
3.1 问题:一个名字扛了两种语义¶
调研主流框架后确认,AKQuant 的"热启动"存在概念混淆——它借用了 warm-up 的词,做的却是 checkpoint-resume 的事:
| 语义 | 做的事 | LEAN | NautilusTrader |
|---|---|---|---|
| A. 指标预热(warm-up) | 开跑前喂历史数据填满滚动窗口/指标,让策略起手即 IsReady |
SetWarmUp(period) / IsWarmingUp |
history bootstrap(catalog 喂数据) |
| B. 状态续跑(checkpoint/resume) | 保存引擎+策略完整状态(持仓/现金/订单/风控),从断点继续跑下一段 | 无内建(靠外部编排) | State Persistence & Recovery(Cache + Event Sourcing) |
run_warm_start 本质是 B,却叫了 A 的名,还顺带做了一点 A(_bootstrap_incremental_indicators)。一个函数扛两种语义,正是用户困惑与 #282 边角问题(滑点漏配、钩子累加器误判)的温床。
参考出处:LEAN Warm Up Periods、NautilusTrader State Persistence and Recovery、NautilusTrader Event Sourcing。
3.2 提案:直接重命名(不保留别名)¶
业界 B 语义的标准词是 checkpoint / resume。本 RFC 采破坏性更新,不保留 deprecated 别名(避免长期双名维护与文档歧义):
| 现名 | 新名 | 语义归位 |
|---|---|---|
run_warm_start() |
run_from_checkpoint() |
B:从断点续跑 |
save_snapshot() |
save_checkpoint() |
B:落盘断点(现实现就是 checkpoint,非只读 snapshot) |
checkpoint.warm_start() |
checkpoint.load_checkpoint() |
B:恢复引擎+策略 |
warm_up / warmup_period / history_depth / infer_warmup_period |
不变 | A:指标预热,语义本就正确,保持独立 |
命名优先级(B 场景):resume / checkpoint restore 优于 warm start——后者与 warm-up 天然混淆,新命名不再沿用。
3.3 迁移面(已勘定)¶
去除 __pycache__ 后的真实引用面:
- 代码:
python/akquant/__init__.py(__all__导出 4 处)、backtest/engine.py、backtest/__init__.py+.pyi、checkpoint.py、backtest/result.py;测试tests/test_strategy_extras.py。 - 示例(4 个):
examples/21_warm_start_demo.py、22_strategy_runtime_config_demo.py、56_functional_warm_start_demo.py、57_functional_multi_slot_warm_start_demo.py——仅改内部 API 符号,文件名保留(避免级联改动tests/test_examples_regression.py的硬编码路径与done_*哨兵、README、doc 链接);按约定逐个实跑exit 0。 - 文档(zh+en):批量替换 API 符号;
warm_start.md专题页文件名保留(改名会级联 mkdocs nav 与全部内链),仅更新正文。reference/api.md签名同步。
3.4 checkpoint 契约化(顺带根治"配置项漂移")¶
第 1 部分把配置塞进散装 dict 的 backtest_config 键,长期仍靠 snapshot.get(...) 逐项取,易漏。建议把 checkpoint 定义成显式 dataclass作为单一契约:
@dataclass
class Checkpoint:
engine_state: bytes
strategy: Any
strategy_topology: dict
resolved_config: dict # 第 1 部分的 backtest_config,契约化
snapshot_features: dict
version: str
save_checkpoint/load_checkpoint都引用此 dataclass,新增引擎配置只在一处加字段,结构上杜绝遗漏。- 版本校验:现有
version字段仅记录、未校验。跨 AKQuant 版本恢复旧 checkpoint 时应显式告警/报错(schema 不兼容),而非静默行为漂移。 - pickle 脆弱性:
save_snapshot直接 pickle 策略实例,遇不可 pickle 对象即抛(如cannot pickle Order,调试时已撞到)。短期不上 Nautilus 式事件溯源,但应在__getstate__更主动剔除运行时瞬态,并对残留累加器在文档提示on_resume重置(#282 第 1、2 点误判来源)。
3.5 落地顺序¶
- Rust 无关,纯 Python 重命名 +
.pyi同步 +__all__更新。 - checkpoint dataclass 契约化 + 版本校验。
- 示例/文档批量改名并实跑。
__engine_rule_version__无需动(行为不变,仅 API 名变);发版说明标注破坏性变更。
4. 三部分关系¶
第 1 部分(配置持久化,已实现)、第 2 部分(result 合并)、第 3 部分(API 重命名)互相正交,可独立发版。建议顺序:第 1(已完成)→ 第 3(破坏性,趁早合并进下一个 major)→ 第 2(体验增强,随时跟进)。滑点最小热修已落地,不阻塞任何一部分。
5. 风险与开放问题¶
- 快照体积:
backtest_config是小 dict,可忽略。 - 配置项漂移:未来新增引擎配置需同步进
backtest_config白名单——建议以resolved_configdataclass 为单一定义点,save_snapshot/run_warm_start都引用它,减少再次遗漏。 - metrics 双实现:Python 重算与 Rust 单段实现的一致性需 golden 级测试守护,否则合并 metrics 与单段 metrics 口径漂移。
- 开放问题:
merge_results是否需要支持非连续时间段(跳空)?建议首版要求时间递增、允许 gap,不允许重叠段(重叠报错)。