All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
akquant.chart新增to_d3kline_options()/to_raw_panes():指标定义与点位到 d3KlineIndicatorOption的转换,作为回测服务与实盘服务共用的单一实现。此前 backtest_server3.0 与 live_server 各自维护了一份语义必须一致的转换代码(前端用同一套渲染逻辑消费两边数据),任何一边漂移都会让前端收到结构不一致的指标。现在两个服务都改为from akquant.chart import to_d3kline_options,契约只有一个出处。 这是 AKQuant 面向一个具体前端的适配层,与indicator_stream产出"前端友好消息"同一性质,不改变"指标数据生产者不耦合前端"的定位——to_raw_panes()同时保留 akquant 原生语义供其它消费方使用。输入刻意用Mapping[str, Any]而非 pydantic 模型:两个消费方一个传 sqlite Row 转的裸 dict、一个传model_dump(),最低公分母是 Mapping;缺失字段按契约默认值处理(pane=0、render_type="line"),pane可为字符串(sqlite/JSON 常见)。 五条刻意为之的转换规则均有测试钉住:同一 pane 的多个指标打包成一个 option(缓解前端MAX_SUB_INDICATORS=3的静默丢弃);主图不带style.height(避免挤压价格图);无颜色时省略整个style字段而非传null(前端_mergeIndicator用...s.style合并,null会覆盖默认色导致无色);render_type七值降级到 d3Kline 的line|bar且降级时保留原值;seriesname用indicator_key而非展示名(前端以 name 做 merge 身份)。新增tests/test_chart_d3kline.py16 条。
- 纯减仓卖单的手续费不再要求现金预付,费用从成交款净额中抵扣(
__engine_rule_version__升至 1.5.5,issue #400):满仓策略在现金趋零后,所有卖单被Risk: Insufficient margin拒绝——实测 100,100 元账户以 10 元买入 10,000 股后余现金 68 元,全量卖出费用合计 132 元,卖单提交即被拒(Required: 132.00000, Available: 67.9932)。后果是账户彻底冻结:卖不出去 ⇒ 没有资金回款 ⇒ 后续调仓全部失效。这与真实 A 股规则相悖:卖出的佣金、印花税、过户费都从成交款净额中扣除,卖出永远不需要预先持有现金。 报告归因为"手续费被计入成交前现金需求",但根因在一层之下:required = margin_delta + commission中的margin_delta在算出后被.max(0)夹断,把减仓释放的保证金/现金整个丢掉,于是纯减仓单的资金需求退化成"裸手续费",再去和成交前的可用资金比较。三处夹断分别位于risk/common.rs::stock_margin_delta(两融股票/基金路径)、risk/common.rs::calc_required_margin_delta(现金账户路径)与execution/simulated.rs的margin_required(成交时闸门)。因此修法不是"给减仓豁免手续费",而是不夹断增量、只夹断最终和:内层返回带符号的增量(减仓为负),外层取(margin_delta + commission).max(0)。现金账户股票的占用保证金恰等于名义额,该增量天然就是现金流,买入侧的验资数值一分不变。 提交闸门与成交闸门是两道彼此独立的检查,必须同步修改:实测只修risk/common.rs时,拒单会原地转移到撮合器(Risk: Insufficient margin at execution)。 同时修掉一个独立的、更容易撞上的缺陷:成交闸门的判定新增total_required > 0前置条件。可用资金允许为负(两融账户水下),此前任何零成本订单都满足required > free_margin——包括此时唯一的自救手段减仓卖单——被以Required: 0, Available: -19943这种自相矛盾的理由永久拒绝。该场景与手续费无关,且所有费率取库默认值 0 即可复现。 豁免的边界刻意不放宽:语义是"费用从成交款净额中扣除",而非"平仓一律放过"。卖出所得盖不住费用时(如持 100 股 0.01 元的股票,所得 1 元而最低佣金 5 元),required仍为正值并照常验资——放过它等于让现金透支,而只有check_cash=False才允许透支(#280)。另有两点保持原状:卖出超过持仓的订单仍是全单拒绝而非拆成"减仓部分放过 + 反向开仓部分验资"(现金账户由StockAvailablePositionRule兜住,不会裸空);risk/manager.rs的提交时自动缩量仍为买入侧独有。 Golden 四个基线数值零变化,即本次改动未波及既有的期货、期权、撤单与 T+1 路径。新增 5 个 Python 端到端测试(主场景成交、水下账户减仓成交、费用超所得仍拒、买入侧仍拒、现金账户不裸空)与 3 个 Rust 单测(现金账户与两融账户两条 margin 路径各一,外加费用超所得的拒单护栏)。四处回退验证均确认测试非恒真:恢复common.rs夹断、恢复simulated.rs夹断、去掉total_required > 0、以及"卖单一律放过"的过度修复,各自都被对应测试抓出失败。 - 指标事件流补齐
timestamp_ms,三个消费出口的字段口径重新一致(STREAM_SCHEMA_VERSION升至1.2):record_indicator的契约是同一次调用在三个出口——stream 事件、indicator_df()DataFrame、export_indicators()JSON/parquet——字段与编码保持一致。采集层四个发射点(回测indicator_recording.py的 point/snapshot、实盘live/_stream_sink.py的 point/snapshot)都已填timestamp_ms,但桥接函数to_indicator_message()的两个分支在组装消息时双双漏掉了这个字段,只透出纳秒timestamp。于是 DataFrame 与 export 两个出口有毫秒、stream 出口没有,契约在流式路径上是破的。 影响面正落在这个字段存在的唯一理由上:timestamp_ms就是为"前端图表库直接可用、无需换算单位"而加的,而需要它的恰恰是 stream 这条实时路径(批量路径读 DataFrame 本来就有)。因此每个消费 WebSocket / SSE 指标流的前端都得自己写一次// 1_000_000,而这正是该字段想消除的样板代码;纳秒值在 JavaScript 里还超出Number.MAX_SAFE_INTEGER,换算前后都有精度隐患。 修法是在 point 与 snapshot 两个分支各透出timestamp_ms,并对只带纳秒的 payload 自动派生而不是填 0:早期版本抓下来的事件、以及第三方IndicatorSink实现都可能只填timestamp,派生口径与BacktestResult._indicator_points_df给历史 payload 兜底的fillna(timestamp // 1_000_000)完全一致。payload 里有显式值时以显式值为准。snapshot 的items[]内不重复该字段——一个 snapshot 内所有 item 共享同一时刻,snapshot 级别给一次即可。STREAM_SCHEMA_VERSION按既有约定升 MINOR(1.1→1.2,与fill.position_effect那次同类):前端靠信封里的schema_version协商可选字段,不升版则无法判断timestamp_ms是否该存在。纯新增字段、向后兼容,旧消费者继续读纳秒timestamp即可,无需改动。 新增 4 个回归测试:回测流的 point/snapshot 均带该字段且等于timestamp // 1_000_000、旧 payload 能派生、显式值优先于派生、schema 版本不低于1.2。前三个均已确认在修复前复现失败(KeyError: 'timestamp_ms'),非恒真断言。另用run_live(broker="replay", trading_mode="paper")实跑实盘路径,确认实盘桥接消息同样带该字段、且与回测同一批 bar 的取值一致。 - 回测结果确定性修复:相同数据不同 symbol 顺序现在产生相同的回测结果(issue #211):当以
Dict[str, DataFrame]形式传入data_map时,不同的标的遍历顺序会导致相同时间戳的 bar 以不同顺序进入引擎,虽然按时间戳排序是稳定的(保持插入顺序),但相同时间戳的不同标的 bar 的处理顺序不同,在有资金/持仓约束时可能导致不同的撮合结果和指标。修复方式是在BarColumns::sort_by_timestamp()和SimulatedDataClient::sort()中增加二级排序键(symbol 字典序):时间戳相等时按 symbol 排序,确保回测结果的完全确定性。此修复确保了:① 无论data_map中 key 的顺序如何,相同数据总是产生完全相同的回测指标(total_return、sharpe_ratio、max_drawdown等);② 成交记录的数量和内容完全一致;③ 不影响单标的回测或已按 symbol 排序的多标的数据。 - T+1 下
NextOpen卖单不再按下单时刻校验可用持仓,改为按实际成交时刻(__engine_rule_version__升至 1.5.4,issue #391):t_plus_one=True+ 默认NextOpen成交策略下,在on_cross_section(当根 bar 收盘时刻)提交的卖单会在下一根 bar 开盘成交,但可用持仓风控读的是下单时刻的账户快照。若卖的是前一交易日买入的持仓,该快照里它仍被 T+1 锁定(Available: 0),卖单当场被拒——尽管等到它真正成交的次日开盘,日终结算早已把这笔持仓解禁。这也与真实 A 股规则相悖:T 日收盘后为 T+1 交易日挂的卖单,对 T 日买入的股票是合法委托。 影响面不是边缘场景而是整条日频调仓链路:on_cross_section+rebalance_weights/rebalance_to_topn只要有换手,就会有大量标的只持有一天,每个这样的标的在卖出时都被拒;卖出腿被拒 ⇒ 没有资金回款 ⇒ 配对的买入腿紧接着以Insufficient margin被拒,组合直接无法调仓。实测复现脚本里t_plus_one=True产生 2 笔拒单(1 卖 + 1 连锁买),t_plus_one=False则 0 笔——这个"关掉 T+1 就好了"的现象正是问题被发现的方式。 修法是把该规则的校验时点对齐到成交时点,而不是放宽规则:RiskRule新增defer_for_delayed_execution(),StockAvailablePositionRule声明选入;提交时若订单是股票/基金的卖单且成交策略为延迟撮合(bar_offset >= 1),跳过提交时校验,改由execution::simulated在撮合时按成交时点的可用持仓复查。撮合侧维护一份 slice 内逐笔推进的projected_available_positions,同一 slice 内的多笔卖单会累计扣减,因此延后校验不会退化成"不校验"——裸卖(从未买入)与累积超卖(一份持仓卖两次)都仍会被拒,只是拒单时点从当根收盘挪到次日开盘。 三处边界刻意不放宽:①bar_offset == 0(CurrentClose等同周期成交)提交即成交、中间没有结算,仍按下单时刻校验,日内 T+0 转卖照旧被拒;② 买单不查可用持仓,不进延后路径;③ 实盘不延后——柜台在报单时就要校验,且实盘没有模拟撮合器来复查,故延后开关仅由回测通道(!execution_model.is_live())开启,公开的RiskManager.check_and_adjust保持提交时校验语义不变。 一处行为变更需注意:受影响卖单的拒单时点后移一根 bar,on_reject回调因此更晚触发,拒单原因文案也从Insufficient available position for X. Available: ...变为带at execution标记的Insufficient available position for X at execution. Available: ...——按原文案做字符串匹配的策略需同步调整。 Golden 基线stock_t1相应更新:该用例原有 2 笔拒单中,「01-02 挂单 / 01-03 成交」与「01-04 挂单 / 01-05 成交」两笔转为成交(持仓在成交时点确已解禁),而策略注释明确期望失败的那笔日内 T+0 转卖仍为拒单——关键不变量未被破坏。另外三个基线(futures_margin/option_basic/order_cancel)仅版本元数据变化,total_commission/net_pnl/end_market_value全部数值不变,即本次改动未波及期货、期权与撤单路径。新增 4 个 Python 回归测试(调仓不再被拒、裸卖仍拒、累积超卖仍拒、同周期策略仍在提交时拒)与 4 个 Rust 单测(公开 API 保持提交时校验、延后路径放行、同周期不延后、买单不延后);前三个 Python 测试均已用"改坏实现"验证过可复现失败,非恒真断言。 daily_returns不再给非交易日补出 0.0 伪收益,Python 侧口径与 Rustmetrics对齐(__engine_rule_version__升至 1.5.3):日频收益此前用resample("D").last().ffill(),把周末与节假日也补成一个权益点,pct_change()后产生大量 0.0 的伪收益。实测一年期日线回测:262 个交易日被拉成 366 个点,其中 28.7% 是人造零,年化波动率从 0.194 稀释到 0.164、Sharpe 从 0.662 掉到 0.560(偏差 15.5%)。 这不是"少数场景的精度问题",而是同一份回测里存在两个互相矛盾的 Sharpe:Rust 侧src/analysis/result.rs::calculate用BTreeMap<NaiveDate, _>按实际出现的本地自然日聚合权益,非交易日根本不进 map、从来没有伪零,所以metrics.sharpe_ratio一直是对的;而 HTML 报告里的滚动 Sharpe、收益率分布图、QuantStats 报告全部走 Python 的daily_returns,读到的是被稀释的那一套。报告顶部摘要卡(读metrics)与报告体内的图(读daily_returns)因此此前就对不上,本次修改是消除这个既存分歧,不是引入新口径。 同时修正另外三处相同写法:build_daily_returns_from_equity()(喂 HTML 报告全部收益图与结构化基准分析)、MergedResult.daily_returns(该文件_recompute_metrics早已按dropna对齐 Rust,同文件内自相矛盾)、plot_dashboard的月度热力图(仅影响外观,月度值因连乘(1+x).prod()不受影响),以及examples/01_quickstart.py里演示"Manual vs Rust"对比的手算波动率——该示例此前恰好在教一个比 Rust 低 15% 的错误数字。 两项行为变更需注意:一是首日不再产生收益点(对齐 Rust 侧从第二日开始的循环),因此序列长度为「交易日数 − 1」,benchmark_analysis()的meta.aligned_points、meta.start_date与series[0].date都会相应后移一格;二是只含单个交易日的回测,daily_returns从「1 个值为 0.0 的点」变为空序列(下游plot_*/to_quantstats/build_benchmark_analysis均有 empty 守卫,不会报错,退化为"暂无数据")。plot_rolling_metrics的 126 窗口从此覆盖真实的约 6 个月交易日,与其文档声明一致。 Golden 基线零影响(其中sharpe_ratio取自 Rustmetrics,不经由本序列)。新增两个回归测试锁定口径:一个用跨周末的 bar 序列断言周末不进索引且点数为交易日数 − 1,另一个直接断言由daily_returns手算的 Sharpe 与metrics.sharpe_ratio数值一致(回退实现可复现失败:旧口径 2.050 vs Rust 2.620)。
-
run_live()新增session_tag=参数,支持多任务交易同一标的时的委托回报隔离:0824 反馈第 1 点——平台多个任务交易同一标的时,各任务此前会收到彼此的on_order/on_trade/on_execution_report推送(v0.3.50 已解决「交易不同标的」的隔离,同标的这条此前判定为「需柜台在推送帧补任务标识才能解决」,该判定是错的)。条件其实已经具备:本框架生成的client_order_id格式是{broker}-{session_tag}-{run_salt}{seq},session_tag默认随机 8 位 hex;中间件推送帧本就带client_order_id回传——比对前缀即可判定归属,不需要改中间件。传入session_tag(推荐传平台任务 ID)后替代随机值作为前缀段,且同时启用严格拒绝:前缀不匹配的推送会被主动丢弃,计入新的foreign_task计数(与既有的foreign_symbol分开计,见下方 Changed)。校验 fail-fast:只允许[A-Za-z0-9_]、长度 ≤ 32、不能含-(它是client_order_id的字段分隔符),违反直接raise ValueError并给出可照抄的正确写法,不做静默归一化改写。 序号段({run_salt}{seq})仍每次run_live独立生成 4 位随机盐:固定session_tag会让前缀跨重启保持稳定(这正是隔离要的),但序号本身每次运行都从 0 起——若序号段不带盐,重启后第一笔单会生成与上一轮完全相同的client_order_id,一是与柜台里同名的历史委托撞号(把client_order_id当幂等键的柜台直接拒单,实测中间件返回 409 Conflict),二是若上一轮该 id 仍在本地映射里指向一笔非终态挂单,本地去重判定会直接拒绝提交、单据根本到不了柜台。加盐后前缀识别、跨重启认旧单、序号唯一性三者不再互斥。 自定义 broker 要支持该能力,必须在推送帧里原样回传client_order_id(不截断、不改写字符),详见docs/zh/advanced/adding_a_broker.md「委托回报的标的过滤」一节;内置的ctpbroker 不满足这个前提——其client_order_id是本地order_ref反查出来的,不会发给柜台,外来单推回来时该字段恒为空,因此严格拒绝对ctp实际是空操作(不会报错,也不会拒绝任何外来单),传了session_tag也不会产生隔离效果,务必据此设定预期。 -
新增
examples/70_csv_multi_symbol_import_demo.py:从 CSV 文件导入多品种数据的回测示例。补的是平台测试反馈里「品种通过文件导入需要补充示例」这一缺口——docs/zh/guide/data.md早有「从 CSV 加载」的文字说明,但examples/73 个脚本里此前零个覆盖文件导入这条链路,尤其缺平台实际在用的那个固定 7 列格式(date/symbol/open/high/low/close/volume,symbol去后缀纯数字、date为按东八区解析后再去时区的 naive datetime、多标的pd.concat成一张表后run_backtest(symbols=[...])显式传白名单)。示例自带临时 CSV(跑完清理),覆盖读文件到回测的完整链路,并记录了一个实测坑:pd.read_csv对纯数字symbol列(如"002202")默认推断成int64会丢前导 0,须显式dtype={"symbol": str}。docs/zh/guide/data.md「2.1 从 CSV 加载」与examples/README.md已同步登记指向该示例。 -
成交事件流新增
position_effect(开平仓标志),前端可以区分开仓与平仓箭头:to_trade_message()产出的fill此前只有side(buy/sell),拿不到开平语义 —— 前端只能画统一的买卖箭头,画不出「开仓 / 平仓」的区别(实测反馈是「开仓并没有画箭头」)。数据其实两侧都已就绪:回测侧 RustTrade结构本来就带position_effect,实盘侧UnifiedTrade.position_effect也早有(middleware 已填真实值),缺的只是流出口没把它透出来。现在补上,取值是与下单入参同一套规范词:auto/open/close/close_today/close_yesterday。 两侧词表统一小写。回测侧走PositionEffect::as_canonical_str()而不是format!("{:?}").to_lowercase():后者会把CloseToday粘连成closetoday,而入参与全仓其余口径都是close_today,按规范词筛选会静默匹配不到(src/model/types.rs里对这个写法有专门的警告注释)。实盘侧出口保留一次小写归一,兜住 middleware 字符串路径。老 broker 不填该字段时落auto。STREAM_SCHEMA_VERSION同步升到1.1(新增可选字段,向后兼容加法),前端可据此协商识别新字段。 -
实盘会话收尾摘要新增事件丢弃计数与被挡标的名单:
Dropped (foreign symbol)/Dropped (duplicate order)/Dropped (foreign task),分别对应标的过滤、委托去重、会话标记严格拒绝丢掉的事件数。这些过滤本身按标的点名一次 WARNING 后就降级成 DEBUG,日志里很快看不见 —— 而万一标的匹配出错把本会话自己的回报也挡掉了,现象是「下单成功却收不到回调」这种极难排查的形态。计数挂在不会被日志级别关掉的地方,并在Dropped (foreign symbol)行后列出被挡掉的标的代码:三个计数在稳态下都会持续增长(计数点在事件入队处、早于去重,而柜台的全量 sync 返回全账户数据 ⇒ 同一笔委托每轮重复 +1),因此数值大小不是故障判据,判据是名单里有没有本任务挂载的标的。计数读取与格式化都做了异常隔离,不会因为一个可观测性字段而拖掉摘要主体(收益率、回撤、成交数)。 -
UnifiedOrderStatus新增EXPIRED,实盘终态口径与回测对齐(__engine_rule_version__升至 1.5.2):回测侧 RustOrderStatus早有Expired(日内单收盘未成、柜台把单判废),strategy_order_events的终态集也一直含它;但实盘侧的UnifiedOrderStatus只有六个值,柜台报来的expired无处可落,只能被各 broker 的映射表兜底成SUBMITTED(非终态)。后果不是"少显示一个状态":该委托会一直出现在sync_open_orders()的结果里,BrokerExecution.cancel_all_orders每轮恢复周期都对它撤一次,柜台回「委托状态错误不能撤单」,告警刷屏且永不收敛;get_order()读到的也是一张永远活着的单。现在expired有了终态归属,live/_payload_utils._TERMINAL_STATUSES、broker_event_adapter(→OrderStatus.Expired)、create_default_mapper()的状态表、以及 ctp/miniqmt/ptrade 三个内置 broker 的_is_terminal_status()全部对齐同一口径。自定义 broker 请检查自己的状态映射表:柜台若有"过期/作废"这类状态,现在应映射到UnifiedOrderStatus.EXPIRED而不是继续兜底。不产生该状态的 broker 行为完全不变。 -
docs/zh/advanced/adding_a_broker.md新增「委托状态映射:终态与非终态」一节:列全两类状态的落点,并写明三条踩过的坑——未识别状态要兜底成非终态但必须记 warning(静默兜底会让柜台的新终态码值永远卡在挂单表里);不要用status == Filled判成交(IOC / 最优五档即成剩撤类委托正常收尾就是Cancelled,成交量在filled_quantity上);不要复用create_default_mapper()的单字符码值(那是 CTP 口径,与恒生《数据词典》1203「委托状态」的数字码完全冲突,0在两边分别是"全部成交"和"未报")。 -
run_live()新增strategy_runtime_config=/runtime_config_override=,与run_backtest逐字对称(__engine_rule_version__升至 1.5.1):runtime_config的五个开关在实盘本来就生效——消费点直读strategy.runtime_config,与运行模式无关,所以策略内部写self.runtime_config = StrategyRuntimeConfig(...)在实盘照常工作。缺的是从入口统一下发的能力:回测有run_backtest(strategy_runtime_config=...),实盘此前零参数,于是「同一份策略代码在两侧用不同口径」做不到——典型场景是回测用error_mode="raise"让问题尽早暴露、实盘用"continue"避免单个回调异常中断交易,此前只能改策略代码或在策略里按运行模式写分支。现在配置会下发到主策略与每个槽位子策略(对齐回测),冲突检测、runtime_config_override取舍语义与告警去重都与回测共用同一份实现。下发时点刻意排在任何on_start之前:runtime_config含indicator_mode,它决定指标走增量还是预计算,而指标在on_start里注册。不传该参数时行为完全不变,策略自设值原样保留。与回测的一处差异:实盘入口没有 config 对象参数,因此没有回测那条从strategy_config.indicator_mode的兜底注入链路,实盘要改indicator_mode必须显式传strategy_runtime_config。 -
symbols里零数据的标的会发出告警:白名单内的标的若全程没有任何行情事件(标的代码写错、数据源未覆盖、或所选时间范围内无交易),此前完全静默——回测照常跑完,结果里也看不出区别(BacktestResult没有symbols/instruments属性,trades_df为空时「没成交」与「没数据」无法区分)。现在能枚举输入集合的形态(DataFrame、Dict[str, DataFrame]、List[Bar]/List[Tick])在运行前就逐标的告警;DataFeed对象这种运行前无法枚举的形态由会话结束时的检查兜底;同一标的只报一次(运行前报过的不会在会话末重复报)。用 warning 不用 error:某标的在所选时间范围内确实无交易是合理场景。 -
on_bar与on_tick可同时触发(回测 + 实盘):引擎把 bar 与 tick 拆成两条并列的历史序列,此前二者共用一个缓冲区,tick 以退化 OHLC 写入会把历史 bar 挤出窗口——双流下get_history会静默返回混入 tick 价的错误序列。回测侧run_backtest(data=[Tick, ...], freq="1min")在照常投递 tick 的同时把它们聚合成 bar 投给on_bar(freq只在data为含Tick的列表时有意义,DataFrame 传freq会报错而非静默忽略)。实盘侧 klinedata 与 CTP 网关均新增emit_ticks/emit_bars(use_aggregator保留为兼容别名,按参数逐个回退——只显式传其一不会静默关掉另一路),双流下合成 bar 打区间结束戳以保证与 tick 混推时时间戳单调不倒退。 -
get_history/get_history_map/get_history_multi/get_history_df/get_rolling_data新增freq参数:取值'tick'/'bar'/None。粒度做成参数而非新增get_tick_history方法,与run_backtest(freq=...)术语一致,并为将来的多周期(如'5min')留开取值域。省略freq时按当前所处的回调自动定档(on_bar里取 bar、on_tick里取 tick,见下方「双流下on_bar/on_tick内省略freq不再报歧义错误」);行情回调之外的双流场景仍要求显式指定——不静默选一条序列。未识别的freq取值同样报错,不会兜底成'bar'。纯 bar / 纯 tick 单流下行为不变(None时照旧取该 symbol 唯一存在的那条序列)。 -
新增策略只读属性
self.freq:策略终于能知道自己跑在什么周期上。run_backtest(freq=)此前只用于 tick→bar 聚合、用完即弃,klinedata 网关的period是纯网关内部参数,从未传给run_live/StrategyContext/Strategy——策略没有任何途径读到数据周期,只能把周期写死在策略代码里或从外部参数重复传一遍。def on_bar(self, bar): if self.freq == "1d": ... # 日线逻辑 elif self.freq is None: ... # 周期未知:显式处理,不要假设
取值统一用回测侧口径(
"1min"/"5min"/"1d"):klinedata 的"M1"由网关侧转换后注入,同一个策略在回测与实盘读到的是同一个值,策略代码因此不必按 broker 写兼容分支。回测侧由run_backtest(freq=)注入,实盘侧由行情网关经GatewayBundle.metadata["freq"]声明(broker='replay'也支持gateway_options={"freq": "1min"})。拿不到周期时为
None,常见于:纯 bar 回测未传freq、CTP 等只有逐笔源的网关、trader-only broker(无行情通道)、klinedata 的周线(回测侧freq只认整数分钟,周线没有对应写法,故声明None而不是编一个"10080min")。此时刻意不从数据推断——相邻 bar 的时间戳差会被停牌、跨日、午休误导,而一个错误的周期比未知的周期更危险(按周期折年化会静默错一个数量级)。属性只读,写入抛AttributeError并指向run_backtest(freq=)/gateway_options={"period": ...}:数据粒度由数据源决定,允许策略写只会制造「我设了但没生效」的幻觉。 -
新增委托价 tick 对齐工具与股票侧校验开关。配合下面「股票/基金委托价开始校验 tick 对齐」的行为变更,提供显式对齐手段与逃生开关:
Strategy.round_to_tick(symbol, price, direction):按标的的tick_size对齐委托价,direction取"down"(买入侧保守)/"up"(卖出侧保守)/"nearest"(默认)。底层工具函数为akquant.utils.price.round_to_tick(price, tick, direction),不依赖策略上下文,可单测直接调用。ChinaStockConfig.enforce_tick_size(缺省True):关掉股票/基金的 tick 校验。与ChinaFuturesConfig.enforce_tick_size对称。
# 策略内:买入向下取整,卖出向上取整,都不朝不利方向滑价 price = self.round_to_tick(symbol, raw_price, "down") self.buy(symbol, 100, price=price)
详见 tick size 对齐指南。
-
orders_df/executions_df现在导出开平语义。#361 争的就是position_effect,而这两张最常用的表原先都不导出它,使用者只能自行遍历Order/Trade对象才看得到。现在:executions_df新增position_effect、timestamp_isoorders_df新增position_effect、reduce_only、created_at_iso、updated_at_iso
委托表比成交表更早暴露拆腿结果——
auto拆腿在下单时就定了,无需等成交。reduce_only与position_effect成对:为真时不产生开仓腿,只看 effect 会看不懂为什么少了一条腿。ISO 列是 UTC 归一的稳定字符串,与已本地化的timestamp/created_at并存,便于跨系统对账与导出。导出词表与下单入参同一套(
auto/open/close/close_today/close_yesterday),可直接用于筛选。这一点是刻意的:若沿用仓库既有的format!("{:?}").to_lowercase()惯例,CloseToday会导出成closetoday,而 API 接受的是close_today,df[df.position_effect == "close_today"]会静默匹配不到。status那类只读列可以这么做,但position_effect是可回传值,入参与导出必须同词表,故新增PositionEffect::as_canonical_str()作为唯一词表源。 -
StrategyContext新增get_closable_position(symbol)与get_projected_position(symbol),分别是「还能平多少」与「仓位将落在哪」两个口径,供开平推断与目标仓位使用(详见 Fixed 中 #361 条目)。同时ExecutionBackend协议新增这两个方法;协议是公开且runtime_checkable的,故调用点按名取值、缺失则退回get_position(),第三方自定义后端不会因此崩溃(代价是维持旧行为,需自行实现新方法才能获得修复)。broker_live下这两个方法退回结算仓:柜台挂单快照UnifiedOrderSnapshot只有position_effect与filled_quantity,没有side和quantity,算不出剩余量也判不了方向。 -
实盘
subscribe()现在真正下发到行情网关。MarketGateway协议一直定义着subscribe(symbols),各内置网关(ctp/replay/miniqmt/ptrade)也都实现了它,但LiveRunner从不调用——订阅链路只接了一半:instruments进得去,Strategy.subscribe()出不来。此前用户在on_start里subscribe()一个标的只会拿到一条 warning。现在run_live会给主策略与各 slot 策略装上转发器,subscribe()调用即刻把订阅集下发到行情网关。下发的是
instruments与全部 slot 的_subscriptions的并集:各网关的subscribe()是整集替换语义(self.symbols = list(symbols)),只下发增量会把其余标的静默退订。转发在subscribe()发生的当刻进行而非装配期——行情网关先于策略on_start启动,装配时还不知道策略要订什么。没有行情网关的 broker(
market_gateway=None)不装转发器,subscribe()退回记录 warning(措辞已更新为「该 broker 未提供行情网关」),因为那类 broker 确实无处可下发。回测语义完全不变。 -
行情源与交易源可分开指定:
create_gateway_bundle与run_live新增market_broker/trader_broker两个可选参数。两种模式二选一——只传broker时由它同时提供两侧(原语义不变);同时传market_broker与trader_broker时各供一侧,broker完全不参与构建。只传其中一个会报错,要求把另一侧也写明:若让broker兼任缺失的那侧,它就一词双义了(读broker='qmf', market_broker='replay'必须先知道「qmf 只有交易通道」才能推出broker在此指交易源)。这补齐了两类「单边 broker」的组合——
GatewayBundle的market_gateway/trader_gateway本就是两个独立可选字段,replay只有行情(不能下单),而某些券商/柜台插件只有交易通道(收不到任何行情、current_tick恒为None),此前二者无法拼接:run_live(..., market_broker="replay", trader_broker="my_trade_only_broker")
要点:
gateway_options同时传给两个 builder;两侧同名时只构建一次(builder 可能连柜台、起线程);metadata记录market_broker/trader_broker便于排障,且行情侧声明的会话级信息(如replay的bounded_event_total,LiveRunner据此在事件放完后结束会话)不会因分开指定而丢失;未注册的名字会报错并点名具体参数,而非静默缺失某一侧通道。副产品是「回放行情 + 真实柜台下单」的联调组合现在可行。另外,
create_gateway_bundle的broker参数由必填改为可选(分开指定时无需传)。所有既有调用点都以关键字传参,不受影响。 -
get_account()账户快照新增free_margin字段(=equity - used_margin),表示真正可用于新开仓的可用保证金,与下单因保证金不足被拒时日志里的Available口径一致;期货保证金账户下cash(现金余额)通常大于free_margin,股票现金账户下二者相等。同时修正了cash在文档与 docstring 中「可用资金」的误导性表述,明确其为「现金余额」。 -
BacktestConfig新增days_per_year(年化天数因子,默认 252;数字货币 24/7 市场可设 365)与risk_free_rate(年化无风险利率,默认 0.0)两个字段,用于参数化 Sharpe/Sortino/波动率等风险指标的年化口径。risk_free_rate默认 0 不改变任何现有数值。 -
新增内置 broker
replay:确定性回放行情源。用于在没有真实柜台、也没有openctp-ctp等可选依赖的环境下跑通实盘数据通路(DataFeed→ 引擎 →on_bar/on_tick),此前这条通路没有任何测试覆盖。行情数据经gateway_options传入,支持list[Bar]、list[Tick]与DataFrame(DataFrame 走normalize.dataframe_to_bars,多品种需提供股票代码列,否则退化为单标的):run_live( strategy_cls=MyStrategy, instruments=[...], broker="replay", trading_mode="paper", gateway_options={"bars": bars}, # 或 {"bars": df} / {"ticks": ticks} )
事件按时间戳升序推送(多品种全局交错——live feed 无法排序,推送顺序即引擎所见顺序),数据放完后会话自行结束,通常无需依赖
duration。限制:该 broker 只提供行情,trader_gateway=None,不模拟撮合成交,因此不能用于trading_mode="broker_live";并且不覆盖 timer 语义——回放数据带历史时间戳,而 live 引擎用墙钟判定 timer 到期,两条时间线错位,on_timer/schedule_daily在回放会话中的行为不作保证。build_replay_bundle会在构建时校验每条事件的时间戳为正数(非正时间戳会被引擎静默丢弃,导致声明的事件总数永远达不到、会话挂死),常见诱因是数据源日期列存在pd.to_datetime(errors="coerce")无法解析的值;若数据本身不受控,仍建议保留duration作为安全网。examples/38_live_functional_strategy_demo.py已改用该 broker(并显式传入duration作为安全网),现可离线实跑。 -
run_backtest支持 tick 输入。data=现接受list[Tick]与list[Bar | Tick]混合列表(此前只接受list[Bar],传 Tick 会在 Rust 层抛TypeError)。纯 tick 回测下get_history()与单值增量指标(SMA/EMA/RSI 等)现在可用——tick 以退化 bar 写入历史(open=high=low=close=price),故get_history(sym, "close", n)返回成交价序列:run_backtest(data=ticks, strategy=MyStrategy(), symbols=["600000"], ...)
限制:tick 的 OHLC 恒等,因此需要真实最高/最低价的指标模式(
input_mode为"hl"/"hlc"/"ohlc",如 ATR、振幅类)在有 bar 来源(混合输入,或配合freq把 tick 聚合成 bar)时由 bar 驱动正常工作,tick 本身对它们静默跳过;只有当某个标的全程只有 tick、从未有过任何 bar 时,才会在会话结束时抛StrategyConfigurationError(ValueError子类)而非静默返回 0。source单值模式(open/high/low/close均映射为成交价,volume为单笔量)与close_volume模式正常可用。 -
run_backtest新增freq参数:把 tick 聚合成 bar。传入后原始 tick 仍照常投递给on_tick,同时额外合成 bar 投递给on_bar,从而拿到完整 OHLC 语义与全部指标(含 ATR 等 H/L 类):run_backtest(data=ticks, freq="1min", strategy=MyStrategy(), symbols=["600000"], ...)
freq词汇与akquant.feed_adapter的resample(freq=...)一致,但聚合走BarAggregator,只支持整数分钟("1min"/"5min"/"1h");"30s"之类会抛ValueError并指向feed_adapter.resample,不会静默取整。成交量口径:回测中Tick.volume是单笔量,适配层显式声明单笔口径(volume_is_cumulative=False)并把每笔量原样交给聚合器,由聚合器直接求和,故合成 bar 的成交量等于区间内 tick 量之和、一笔不落。时间戳口径:合成 bar 打在区间结束(下一区间起点前 1 纳秒)而非区间起点——回测因果顺序要求:合成 bar 与源 tick 会被放进同一个 feed 再按时间戳排序,若打区间起点,bar 会排到形成它的 tick 之前,策略读到的是尚未发生的未来数据。末尾未满周期不产生 bar。freq在数据不含 tick 时抛ValueError(参数无意义)。 -
策略参数可迁移性改善(非破坏性):仍在用已废弃的构造函数签名写法(
def __init__(self, fast=10))声明参数的策略,现在在类定义期就会收到UserWarning点名参数与迁移路径,不必等到跑回测才发现参数没生效;run_backtest/run_grid_search的Unknown strategy param(s)报错按成因分流——未声明任何内联字段时给出完整迁移写法,键名不匹配时列出该策略可用的字段清单。参数语义未变,内联字段仍是唯一入口。 -
新增
ListParam:补上列表型参数的声明入口。此前run_backtest的symbols自动注入要求策略把symbols声明为参数字段,但 DSL 没有任何能声明 list 的函数,该机制实际不可达。 -
官方示例全部迁移:
examples/下 17 个仍在演示构造函数签名写法的示例已改为内联字段声明(含教材配套 ch05/ch10/ch12)。 -
迁移提醒:
__init__搬到on_start会改变热启动(resume)下的执行次数。「把派生初始化从__init__移到on_start」是本次给出的推荐迁移写法,但两者生命周期不同——__init__每个对象只跑一次,on_start每次运行都会跑一次,包括从快照恢复(resume)(即self.is_restored为真时也会调用)。这带来两点实际影响:- 构造后立即可读的属性(如
warmup_period)迁移后不再能在__init__期读到,需等on_start执行完。 - 更容易踩坑的是累积状态——计数器、缓存、已建仓记录等 per-object 状态若直接搬进
on_start顶层,热启动时会被静默重新初始化,破坏跨阶段的连续性。这类初始化请收在if not self.is_restored:分支内;与运行历史无关的纯重建逻辑(如重新注册指标对象本身)才适合放在on_start顶层无条件执行。完整用法见examples/21_warm_start_demo.py、docs/zh/guide/strategy.md§6.4。
- 构造后立即可读的属性(如
-
委托回报的标的过滤新增会话标记判据,优先级高于原有的订单归属/标的判据:
gateway/broker_event_bridge.py::_accepts_symbol之前只有两级(订单归属映射 → 标的白名单),现在前面加了两层,仅作用于order/trade/execution_report三类事件(account事件没有client_order_id概念,不经过该判据,恒由原有逻辑放行)——client_order_id前缀命中本会话{broker}-{session_tag}-一律放行(即便标的不在挂载列表内,覆盖BrokerOrderSink经外部信号源直调下单能合法报出挂载集合之外标的的场景),前缀不匹配且显式传了run_live(session_tag=...)(严格模式)时主动拒绝,计入独立的foreign_task计数(与foreign_symbol分开计:前者是多任务稳态下必然增长的正常工作量,后者异常增长才指向标的配置错误)。未传session_tag时严格拒绝完全不生效,但行为并非与升级前逐字一致——前缀命中本会话时(第 1 层)仍会跳过标的判据直接放行,这在「自己的单 + 订单归属映射已被清理(如已pop)+ 标的不在挂载集」这一具体组合下,会让升级前本会丢弃的回报改为放行;方向上只会让本会话更不容易丢自己的回报(不会更严格),但严格来说不是逐字不变。会话收尾摘要新增一行Dropped (foreign task);盘中周期性丢弃计数汇总(_report_dropped_event_counts_if_changed)的触发条件不变,仍只看foreign_symbol,刻意不把foreign_task纳入,避免重现此前刚修掉的「稳态刷屏」。 -
实盘恢复循环从单一 1 秒节奏拆成三层,不再每秒全量轮询柜台:原先 heartbeat、
sync_open_orders()、sync_today_trades()、query_account()四个调用共用同一个 1 秒节拍,每轮无条件全跑 —— 挂单存在时就是每秒四次柜台请求,实测反馈是「后端在频繁调用接口」。现在按各自的必要频率分开:heartbeat 每拍(便宜,要快速发现断线)、query_account默认 5 秒(策略要读get_account(),不能太陈旧)、两个全量 sync 默认 30 秒兜底(恢复的本意是断线补齐,平时靠推送)。三档都可由gateway_options覆盖:recovery_interval_sec/recovery_account_interval_sec/recovery_sync_interval_sec—— 柜台的限流阈值是部署期才知道的外部约束,各家不同,硬编码不合适。越界只钳制并告警(约束是 tick ≤ account ≤ sync),非法值(负数、非数字、nan、inf)回退默认并告警,都不抛异常:让运维在启动时吃ValueError是最差的处理方式。 两个配套行为:断线重连成功后本轮立刻跑一次全量 sync,不等 30 秒兜底 —— 这才是「断线补齐」真正该触发的时机;30 秒那档带 ±10% 抖动,避免平台并发起多个任务时各自的全量 sync 对齐到同一秒打柜台。触发判断用单调时钟而非取模计数(循环 sleep 有漂移,取模会累积误差,也不受系统时间回拨影响)。降频只针对重复轮询,没有给非交易时段加闸门 —— 盘后有合法事件(撤单确认、批量拒单、日终结算态变化、隔夜委托状态更新),粗暴挡掉会吞真实回报。 -
UnifiedOrderSnapshot.timestamp_ns现在可以正常填写真实时间:委托事件的去重键此前含timestamp_ns,于是「插件不要给委托快照填真实时间」成了一条靠插件作者自觉遵守的脆弱约定 —— 柜台的update_time每帧都变,填进去就等于让去重彻底失效(历史上「盘后还在推on_order」正是这么来的)。现在键不再含该字段(跨轮去重改由状态指纹承担),这条约定随之解除,插件可以照常填真实时间,对订单审计有价值。 -
(破坏性)实盘柜台拒单不再从
buy()/sell()/order_target_*抛异常:改为返回空回执并触发on_reject(与回测口径一致)。原先靠try/except捕获柜台错误的策略需改用on_reject。 -
(破坏性)
run_backtest(symbols=...)/run_from_checkpoint(symbols=...)从「注册哪些合约」改为「只跑哪些标的」(__engine_rule_version__升至 1.5.0):此前不同数据输入形态对symbols的处理并不一致——DataFrame(含symbol列)、Dict[str, DataFrame]、DataFeedAdapter三种形态本就按symbols过滤数据;但List[Bar]/List[Tick]与直接传入的DataFeed对象两种形态完全不过滤,数据里出现的任何标的都会正常触发on_bar/on_tick,与传的symbols无关。这不只是「多算了几笔」——合约注册循环只遍历symbols,于是这两种形态下数据里多出来的标的会带着默认合约参数参与撮合:期货乘数按 1 算(真实常为 10~300,持仓市值与权益错出几个数量级)、期货风控被整个跳过。极端情形是把全市场List[Bar]数据喂给策略而symbols只列了几个标的,其余标的照样建仓、照样计入统计。现在传了symbols就只有白名单内的标的进入引擎,五种输入形态行为统一;不传symbols时行为完全不变(沿用「数据即订阅」)。白名单为symbols∪config.instruments∪__init__阶段subscribe()已订阅的标的。影响:传了symbols的多标的回测结果会变(原先混进来的标的不再参与撮合与统计)。显式传空集合改为报错(那样会得到一个不放行任何标的的空回测,而不是静默退化为不过滤)。实盘不受影响——网关只推送已订阅的标的,「数据即订阅」在实盘天然成立。迁移提示:改动前symbol/symbols的签名默认值就是字面量"BENCHMARK"(本仓库个别热启动示例也这么显式写过)。升级后显式传入的值一律按真实过滤条件处理,不再等价于「未传」,且不同数据形态下会向相反方向出错——List[Bar]/DataFeed形态因数据里没有标的字面量等于"BENCHMARK",白名单谁都不放行,回测直接空跑(只有一条 WARNING);DataFrame/Dict[str, DataFrame]形态则反而完全不过滤。升级方式:把显式的symbols="BENCHMARK"直接删掉(省略该参数)。 -
(破坏性)传了
symbols后,on_start里subscribe()白名单外的标的会抛ValueError:时序上无法自动并入——Engine::run内部先调on_start,而数据加载与add_data发生在run()之前,前置过滤执行时_subscriptions尚为空。且「声明只跑 X,又订阅 Y」本身自相矛盾,报错比静默择一更清晰。迁移:把该标的加进symbols,或去掉这次subscribe。不传symbols时subscribe()不受任何约束;实盘的subscribe()是正常的动态订阅手段,同样不校验。 -
(破坏性)实盘网关
Tick.volume语义改为单笔量,与回测口径对齐:CTP、klinedata 两个行情网关此前把柜台/上游推的当日累计成交量原样塞进Tick.volume,而回测侧Tick.volume一直是单笔量——同一策略读tick.volume,回测和实盘拿到的量级能差出几个数量级,且是静默的,不报错也不告警。现在两个网关都按 symbol 记录上一次累计量并换算成单笔量(差分规则:delta = 本次累计量 - 上一次累计量;跨日重置/断线重连导致的负差分退回delta = 本次累计量,因为此时它本身就约等于当日第一笔的单笔量)。若你此前为了对齐语义已经自行加了「累计量转单笔量」的临时补丁,升级后必须移除,否则会被双重换算。 分工未变:喂给内部BarAggregator(freq聚合 tick 为 bar 时用到)的仍是原始累计量(其volume_is_cumulative=True自己会处理),只有网关推给on_tick/add_tick的Tick.volume改成单笔量。已知代价:进程盘中启动时,某 symbol 收到的第一帧无法得知上一次累计量,换算规则记delta = 0(而非误用累计量冒充单笔量),若策略里有「volume == 0即跳过」式的防御逻辑,会连带跳过每个 symbol 的第一笔行情。 -
(破坏性)
freq='tick'时get_history系列的field限price/close/volume:tick 没有open/high/low,此前请求这些字段会静默返回退化 OHLC(price冒充high),现改为显式抛ValueError。get_history_df/get_rolling_data固定取 OHLCV 五字段,故它们在freq='tick'下必然报错,报错文案会指向get_history(freq='tick', field='price')取成交价序列。仅影响显式传freq='tick'的调用;freq='bar'与省略freq(单流场景)不受影响。 -
股票/基金委托价开始校验 tick 对齐(行为变更,
__engine_rule_version__升至 1.4.0):此前只有期货侧校验最小变动价位,股票/基金的非对齐委托价(如tick_size=0.01却传2.8314)在回测里会照常成交,到了实盘却被柜台风控拒单(RISK_PRICE_TICK_INVALID)——同一笔单回测通过、实盘失败。现在两侧口径统一:- 回测:
StockMatcher对非对齐委托价 reject(与FuturesMatcher一致),不是静默取整。 - 实盘:
broker_live路径在报单前本地校验,报错文案直接给出可用的对齐价与round_to_tick用法,不必等柜台回一个难解的 400。 - 作用范围:仅
stock/fund资产类型;标的未登记、拿不到tick_size或tick_size<=0时跳过校验(柜台自己也会校验,总比让没配instruments的用户完全下不了单好)。市价单(不传price)不受影响。
不是「悄悄把价格取整」——那会让回测拒单、实盘成交的分裂反向出现一次。需要自动对齐请显式调用
Strategy.round_to_tick();要完全关掉校验用ChinaStockConfig(enforce_tick_size=False)。golden 基线零漂移(既有基线用的都是对齐价),但规则版本已 bump,自建基线请重新生成。 - 回测:
-
标的未登记的报错文案给出成因与登记入口:
get_instrument()原先只说Instrument config not found for symbol: X,看不出成因,用户普遍误判为「broker 侧缺该标的配置」(实测反馈即如此),而真实原因几乎总是该标的没进run_live(instruments=[...])/BacktestConfig(instruments_config=[...])。现在报错会附上已登记标的清单(超过 20 个截断)与登记入口。 -
trader-only broker 无行情时启动阶段给出告警:
broker='middleware'/'qmf'这类只有交易通道的 broker 会让bundle.market_gateway is None,于是on_bar/on_tick永不触发、current_tick恒为None。这是预期行为(run_livedocstring 早已写明),但此前启动阶段完全静默,用户只看到「策略没反应」,无从判断是配置问题还是当时没行情。现在会记录一条 warning,并给出补救办法(run_live(market_broker='<行情源>', trader_broker='<交易源>')的分离写法)。显式配置了market_broker时不告警。 -
策略参数声明改为内联字段(破坏性):策略参数的单一事实来源现在是类体内联字段——直接用
IntParam/FloatParam/BoolParam/ChoiceParam/DateRangeParam赋值(如fast = IntParam(10, ge=2, le=200)),经self.params.fast只读访问;self.params在实例构造期即已校验就绪且 frozen,不支持运行期赋值。- 移除:构造函数签名参数风格(
__init__(self, fast=10): self.fast=fast不再作为参数声明入口)、PARAM_MODEL = XxxParams间接层(不再需要单独定义ParamModel子类)、适配层内部的_validate_with_signature/_build_signature_schema签名回退路径。get_strategy_param_schema/validate_strategy_params现在只读取内联字段,不再回退到__init__签名推断。 - 行为变更:
start_time/symbols/end_time现在仅在策略显式声明为对应字段时才会被注入,不再隐式兜底;strict_strategy_params=True(默认)下,param_grid或运行期 payload 中的未知键、越界取值(超出ge/le、不在choices内)会直接报错,不再静默忽略;strict_strategy_params=False时未知键会回退到字段默认值构造。 - 迁移指引:
def __init__(self, fast=10): self.fast = fast改为类体字段fast = IntParam(10);原先读取self.fast的地方改为self.fast->self.params.fast;派生初始化(如指标构造)从__init__移到on_start。示例见examples/02_parameter_optimization.py、docs/zh/guide/strategy.md§6.4「参数声明」。
- 移除:构造函数签名参数风格(
-
策略钩子收敛(破坏性,硬改名/移除,不保留兼容别名):(0) 移除
on_session_start/on_session_end——调研显示二者近乎零真实用例(无任何真实策略/plugin 使用),且同类框架均不向策略暴露会话级回调;session 概念在引擎内部仍承重,需要按会话(如期货日/夜盘)分支的策略请在on_bar/on_tick内读取self.ctx.session(TradingSession枚举)。连带回滚了为on_session_end正确时序而加的"会话终定时器"机制。(1) 移除on_daily_rebalance——它与on_before_trading同阶段、同"前一交易日信息可见"窗口,属于按用户意图(调仓)而非按触发时刻命名的冗余钩子;调仓逻辑请迁入on_before_trading。(2)on_daily_rebalance_after_bar更名为on_cross_section——按语义(当日首个跨标的完整 bar 切片就绪后触发的横截面同周期调仓)命名,避免daily隐含周/月频钩子家族的伪需求(频率应在回调内用日历判断)、以及旧名过长;触发时机、可见窗口与成交语义不变。函数式入口、LiveRunner与内部 timer payload(Python 与 Rustsrc/**同步)一并更新。详见docs/zh/meta/hooks-rfc.md。 -
buy/sell/submit_order返回类型变更(破坏性):三者现统一返回OrderReceipt(原为str订单号),回测与实盘(broker_live)两种模式返回类型一致;实盘submit_order此前会将多腿委托(如反手拆分的平仓+开仓、开平分离)收窄为单一 id 字符串,现已修复为返回携带全部腿 id 的完整OrderReceipt。取单个订单 id 用receipt.primary(首腿 broker_order_id,兼容旧用法),取全部腿 id 用receipt.order_ids;str(receipt)取group_id(逻辑委托的客户端订单号),关联成交请用trade.group_id而非逐个 order_id 比对;新增cancel_group(group_id)用于一次性撤销一个逻辑委托的全部腿。 -
回测指标口径变更(破坏性):Sharpe / Sortino 比率的分子改为「日收益算术均值 ×
days_per_year」做年化,替代原先的 CAGR(复合年化),与 pyfolio/empyrical/quantstats 等主流实现一致,并与分母√days_per_year的年化口径匹配。升级后历史报告的 Sharpe/Sortino 数值会变化,不可直接与旧版逐值对比;UPI 与 Calmar 仍沿用 CAGR 口径。 -
策略交易日边界回调已硬切改名:
before_trading(trading_date, timestamp)更名为on_before_trading(trading_date, timestamp),after_trading(trading_date, timestamp)更名为on_after_trading(trading_date, timestamp)。 -
旧回调名不再保留兼容别名;升级到当前版本后,若策略仍实现
before_trading/after_trading,将不会再被框架触发,请同步迁移到新名称。 -
新增
on_pre_open(event)框架回调,用于表达“盘前决策,本次 open 成交”;该回调默认下单语义会自动解析为price_basis=open, bar_offset=1, temporal=same_cycle,不再要求用户自行用on_timer拼装开盘成交时序。 -
中英文策略指南与 API 参考现已补齐
on_pre_open的语义说明、使用边界、相邻回调对比与示例入口。 -
教材第 5 章与教材目录页现已同步补充完整
on_xxx回调地图、学习路径与相关示例入口,便于用户从教材直接理解各类回调的职责边界,并新增on_pre_open的推荐用法。 -
示例体系已补充
examples/50_framework_hooks_demo.py与examples/51_class_tick_callbacks_demo.py,分别覆盖框架边界钩子与类风格on_tick的最小可运行案例。 -
示例体系新增
examples/52_pre_open_demo.py,用于演示on_pre_open -> on_order/on_trade -> on_bar的触发顺序与当日 open 成交语义。 -
策略 API 批量改名与硬删(破坏性,不保留兼容别名):0.3.x 期间对
Strategy公开面做了一轮收敛,以下旧名已从Strategy移除,旧调用会直接AttributeError。此前这些变更未记入本文件,特此补齐——从 0.2.x / 0.3.早期升级请按下表逐项迁移。旧 API 新 API 迁移写法 get_cash()cash属性self.get_cash()→self.cashget_portfolio_value()equity属性self.get_portfolio_value()→self.equityget_positions()positions属性self.get_positions()→self.positionshold_bar(symbol)get_holding_bars(symbol)仅改名,签名不变 order_target_positions(...)rebalance_positions(...)仅改名,签名不变 order_target_weights(...)rebalance_weights(...)仅改名,签名不变 place_bracket_order(...)place_bracket(...)仅改名,签名不变 create_oco_order_group(a, b)place_oco(a, b)仅改名;新版两个入参同时接受订单 id 与 OrderReceiptregister_indicator(name, ind)register_precomputed_indicator(name, ind)旧名本就是该方法的薄别名,去别名后请直接用全名 stop_buy(symbol, trigger_price, quantity, price)submit_order(...)submit_order(symbol=..., side="Buy", quantity=..., trigger_price=..., price=...);price=None即触发后转市价stop_sell(...)submit_order(...)同上, side="Sell"buy_all(symbol)order_target_percent(...)order_target_percent(symbol=..., target_percent=1.0)注:
cash/equity/positions现为只读属性(property),不能再当方法调用——self.cash()会抛TypeError。执行后端(strategy.execution)层的同名方法未改动,自定义 broker 无需跟随调整。 -
回测成交策略从 dict 收敛为
FillMode对象(破坏性):fill_policy不再接受 dict,make_fill_policy()已移除(保留为抛TypeError的报错壳,错误信息内嵌完整迁移映射);引擎侧set_fill_policy(price_basis, bar_offset, temporal)重塑为set_fill_mode(mode: ExecutionMode, timer_timing: str),非法的(basis, offset)组合在枚举层已不可表达。get_fill_policy()保留不变(checkpoint 反查依赖)。迁移映射:旧 dict 新 FillMode{"price_basis": "open"}NextOpen(){"price_basis": "close", "bar_offset": 0}CurrentClose(){"price_basis": "close", "bar_offset": 0, "temporal": "next_event"}CurrentClose(timer_fill_timing="deferred"){"price_basis": "close", "bar_offset": 1}NextClose(){"price_basis": "ohlc4"}NextAverage(){"price_basis": "hl2"}NextHighLowMid() -
LiveRunner从公开 API 移除(破坏性):实盘入口统一为run_live(...)函数门面,与run_backtest对称。from akquant import LiveRunner会ImportError;原先LiveRunner(...).run(cash=..., duration=...)的两步用法改为单次run_live(..., cash=..., duration=...)调用,配置参数一一对应。 -
add_daily_timer已移除,更名为schedule_daily(破坏性,不保留兼容别名):定时器注册端统一为schedule/schedule_daily/schedule_weekly/schedule_monthly家族(回调端on_timer不变)。升级后旧调用会直接AttributeError。迁移:self.add_daily_timer("14:55:00", "rebalance")改为self.schedule_daily("14:55:00", "rebalance"),参数与触发语义完全一致。注意schedule_weekly/schedule_monthly依赖回测交易日历(_trading_days),在实盘下会记录一条 warning 并被忽略;实盘的周期性任务请用schedule_daily配合回调内的日历判断。详见docs/zh/meta/timer-api-rfc.md。 -
实盘
subscribe()不再静默无效:Strategy.subscribe()只把标的写入_subscriptions,而该列表仅被回测消费——实盘(run_live)的订阅集在会话启动时由instruments一次性交给行情网关,此后调用subscribe()不会触达任何网关。此前这一调用完全无声,用户以为订阅成功却收不到行情。现在实盘下调用subscribe()会记录一条 warning,指明应把该标的加入run_live(instruments=[...])(每个标的只告警一次)。行为本身未变(仍会记录进_subscriptions),回测语义完全不受影响。 -
supports_short_sell在broker_live下开始真正生效:该字段此前在实盘下单路径上没有任何消费者(仅服务order_target*的目标仓位计算),broker 声明了False也照样把开空单发给柜台,只能等柜台报错。现在side=Sell+position_effect=open会在下单前被本地拒绝。内置 CTP 声明True,不受影响;ptrade/miniqmt只声明支持auto,其显式open早已被supported_position_effects拦截,行为不变。若自定义 broker 实际支持融券却被拦,请修正其get_capabilities()中的supports_short_sell声明。 -
tick 事件开始写入历史缓冲区、并推进增量指标(行为变更):此前
Event::Tick只更新当前事件快照,既不进历史也不推进指标,且是静默的——不报错也不工作。现在两者都会发生。若既有策略在 tick 路径注册了增量指标并依赖它「不更新」,结果会变。__engine_rule_version__已相应 bump。 -
回测数据列表改为逐元素类型校验(破坏性):
run_backtest(data=[...])此前只检查首元素类型,[Bar, "garbage"]会漏到 Rust 层抛出难以定位的错误。现在在 Python 层逐元素校验,抛TypeError并指名位置索引与实际类型;空列表抛ValueError。
-
(实盘)收到
SIGTERM时也会触发on_stop(平台「停止任务」按钮的常见实现):run()的finally此前覆盖三条路径 —— 正常结束、KeyboardInterrupt(Ctrl+C/duration到点)、异常中止;但全仓零个signal.signal/atexit,平台若用terminate()停任务,进程直接死在默认SIGTERM处理里,finally根本不执行,on_stop里的撤单/平仓全部落空(0821 起的反馈,此前一直卡在「先问平台是怎么停的」上 —— 那个前置问题其实不必等:注册 handler 对SIGTERM通用有效,真正无解的只有kill -9)。现在会话主循环期间注册 handler,把SIGTERM转成KeyboardInterrupt,复用已被duration与Ctrl+C验证过的那条收尾路径 —— handler 里只抛异常,不跑用户的on_stop(撤单、平仓、打摘要都得在正常栈上跑)。 三处刻意的边界:① handler 只包主循环,进入收尾就恢复原 handler —— 收尾期间再来一个SIGTERM按原行为处理(通常即终止),对齐「再按一次强制退出」的惯例,否则第二个信号会把撤单打断在半路;② 退出时原样恢复原 handler,run_live可能被嵌进宿主进程,留着不还就是污染全局状态;③ 非主线程(平台用线程池承载多个任务)signal.signal会抛ValueError,此时只打一条 WARNING 并照常运行 —— 收不到SIGTERM收尾远好过让任务根本起不来。仍然覆盖不到kill -9(SIGKILL)与 WindowsTerminateProcess:进程不经用户态代码即被回收,需要调度方改用优雅停止。 -
(实盘)盘中丢弃汇总不再稳态刷屏,也不再把排查引向一个不存在的归一化缺陷:上面那条盘中汇总(挂在全量 sync 那一档)此前用
foreign_symbol的计数增量作触发条件,文案还断言「若配置正确应恒为 0,出现增长请怀疑标的归一化把自己的回报也挡掉了」—— 两处都是错的。该计数的计数点在事件入队处、早于一切去重,而柜台的sync_open_orders()/sync_today_trades()返回的是全账户数据 ⇒ 同账户下只要存在非挂载标的的挂单(别的任务、人工下单终端、隔夜挂单),同一笔每轮就 +1;且未传session_tag时不启用严格任务隔离,别的任务的委托不走foreign_task而是落进foreign_symbol。于是它在默认配置下必然线性增长,日志每 30 秒准时刷一条 —— 与当初要修的「挂单每秒被重复推送」是同形的失败模式(同一个函数里已经为duplicate_order逐字论证过这一点,却对foreign_symbol犯了同样的错);而那句文案会让人去查一个并不存在的缺陷(2026-08-26 平台测试反馈原文就是照抄它在怀疑)。 现在触发条件改为「被挡掉的标的集合出现新成员」,与逐条丢弃日志(首次 WARNING 点名、之后同标的降 DEBUG)同一套防刷屏口径:稳态完全静默,出现新的外来标的才报一条、且只点名新出现的那个。两个累计值退化成日志里的上下文,并明确写出「会随每轮全量 sync 对同一笔委托重复累加,不代表故障;仅当点名的标的是本任务挂载的标的时,才说明标的匹配或配置有问题」。BrokerEventBridge新增dropped_foreign_symbol_names()提供被挡标的名的快照(返回副本,避免调用方的集合运算污染防刷屏状态),会话收尾摘要据此在Dropped (foreign symbol)行后列出名单。该集合的读写一并纳入事件锁:它现在由 recovery 线程读、行情/推送线程写,无锁复制会在「复制期间另一线程 add」时抛Set changed size during iteration;顺带修掉一处既有竞态 —— 并发下同一标的此前可能各打一条首次 WARNING。 -
get_history/get_history_multi对未登记标的不再静默返回全 NaN:ctx.history()在 Rust 侧 history 缓冲里完全找不到该 symbol 时返回None,此前 Python 侧原样兜成np.full(count, np.nan),没有任何日志——策略拿到一串 NaN,却完全不知道是标的没订阅/登记错了还是别的原因(0821 平台测试反馈,回测、实盘两侧各报过一次)。现在这条分支(arr is None)会记一条 WARNING 并点名该 symbol,提示「通常意味着它没有被登记/订阅(检查instruments_config/symbols配置或标的代码是否写错),而不是数据源当天没数据」;同一(symbol, field)组合第二次起降为 DEBUG,避免策略每根 bar 都调用get_history时刷屏(去重集合挂在 strategy 实例上惰性建,与gateway/broker_event_bridge.py的_log_foreign_symbol同一套模式)。 刻意没有改的分支:len(arr) < count(有数据但不够长)是预热不足的正常语义——函数契约本来就是左侧补 NaN,策略预热期每根 bar 都会触发这条路径,绝不能告警,否则会比原来的静默更糟(回测开头刷屏)。get_history_df/get_rolling_data/get_history_map均转发自这两个入口,无需单独改。 -
broker recovery 的错误在默认
compatible模式下不再完全静默:BrokerRecovery.handle_error此前第一行就是if recovery_mode != "strict": return,而默认recovery_mode正是compatible——心跳失败、重连失败、sync_open_orders/sync_today_trades/query_account报错在默认配置下完全不打日志、不通知策略、不发recovery_errorobserver 事件,柜台已经掉线而用户毫无感知(这也是「账户退出后任务仍显示运行中」这条反馈的一半根因)。现在把 strict 判断下移到既有的error_key去重之后,插入一条两种模式都打的 WARNING(带source与异常信息,如broker_recovery.heartbeat: ConnectionError: ...),复用现成的去重键(同一错误连续发生只报一次,run_cycle成功收尾会重置、恢复后再犯会再报)。只补可见性,不改容错语义:compatible模式依旧不通知策略on_error、不发 observer 事件,即依旧不中断交易;strict模式原有的通知与事件行为不变。日志级别用 WARNING 而非 ERROR——单次 sync 失败下一轮会重试,够不上错误级别。 -
(实盘)修复账户存在挂单时策略每秒被重复推送同一个
on_order:恢复循环每轮都会把柜台的全部未完成委托重新入队,而事件桥的去重集在每次drain_events后被清空 —— 那层去重只在「同一批」内生效,跨轮完全失效,配合每秒一轮的恢复节奏就成了每秒重推一次。表现是策略回调被同状态的挂单刷屏、订单审计日志每秒一条、接了on_broker_event的前端也跟着每秒收一次。现在新增会话级委托状态指纹(status+filled_quantity+avg_fill_price+reject_reason,刻意不含时间戳 —— 含时间戳的键每次重推都会变、去重必然失效,这是这类缺陷最常见的写法),补上了成交侧早有、委托侧一直缺失的那处不对称。状态一变立刻放行,New → PartiallyFilled → Filled这类真实推进一次都不会被吞;有界 FIFO 上限 50000,终态委托不移除(移除后下一轮重放会再推一次)。批内去重(_event_keys)保留不动,两层各管一段。 去重键按事件类型:委托号建立独立命名空间,而不是裸委托号:ctp / miniqmt / ptrade 三个内置 broker 对同一次状态变化是用同一个 payload 成对派发order与execution_report的(ingest_order_event先order_callback再execution_callback),而两者的四个指纹字段取值逐字相同 —— 共用命名空间会让on_execution_report被永久吞掉。同批内execution_report的键也补上了filled_quantity:UnifiedExecutionReport没有单独的「本次成交量」字段,它是区分连续两次部分成交的唯一信息,缺了它两条partially_filled会被误判成重复。 -
(实盘,破坏性)策略不再收到不属于本会话挂载标的的委托与成交回报:柜台的
sync_open_orders()/sync_today_trades()返回的是全账户数据,不限于本会话订阅的标的,此前这些回报会原样派发给策略 —— 策略因此看到别人的委托、get_order()读到不属于自己的单。现在在事件桥入口按run_live(instruments=...)过滤(order/execution_report/trade三类;account无标的概念不过滤)。 判据是两级的:先认「这单是不是本会话自己报出来的」(按broker_order_id、再按client_order_id查会话内的委托映射),命中一律放行;查不到才按标的判。这一层是必需的——外部信号源经BrokerOrderSink直接下单时不经引擎的合约登记表,可以合法报出instruments之外的标的;它同时把「判据写错」的后果从「吞掉自己的回报」降级成「多派发一条」。标的比较在订阅集构建与回报比对两侧都做后缀归一化(只规整最后一段后缀,期货合约代码里有意义的小写如ag2612不受影响):平台用000012.sz登记、柜台推000012.SZ是真实存在的写法差异,精确字符串比较会把自己的回报判成外来的并静默丢弃。所有边界一律放行(订阅集为空、载荷无symbol、访问器抛异常),吞掉真实回报的代价远大于多派发一条;丢弃会按标的点名一次 WARNING、之后降 DEBUG,并计入会话丢弃计数。 仍然无法区分「同标的、不同任务」:那需要柜台在推送帧里带任务或账户子标识,我们这侧没有可用判据。若同一账户下多个任务交易同一标的,彼此的回报仍会互相可见。 -
修复
runtime_config读取侧默认值与配置类定义漂移:StrategyRuntimeConfig有五个字段,但读取侧兜底用的是一份手写的四键字典,漏了indicator_mode——于是读取侧解析该字段会直接KeyError(当前无调用点命中,故对用户不可见,但新增字段时同样的漏项会再次发生,且下一次可能就是可见的)。根因是这套东西散在三个文件:配置类在strategy.py、写入侧 helper 在backtest/engine.py(与回测毫无关系,只操作策略实例的属性)、读取侧默认值又在strategy_framework_hooks.py里手写第三份。现在配置类与写入侧统一收进新模块akquant/strategy_runtime_config.py,读取侧默认值改为从dataclass的fields()派生,结构上不再可能漂移。StrategyRuntimeConfig由akquant与akquant.strategy重新导出,from akquant import StrategyRuntimeConfig等既有写法不受影响。 -
修复实盘会话(
run_live/LiveRunner.run)结束时不触发策略on_stop的问题:三条停止路径(duration 到期、手动中断、异常中止)现在都会补发on_stop,并在断开 broker 通道之前触发;同时补上实盘 slot 子策略此前从未触发的on_start。 -
双流下
on_bar/on_tick内省略freq不再报歧义错误。tick 序列由引擎无条件写入历史缓冲区(HistoryBuffer::update_tick,与策略是否覆写on_tick无关),因此只要订阅了 tick 流(实盘gateway_options={"emit_ticks": True}、回测run_backtest(data=[Tick, ...], freq="1min")),哪怕策略只写了on_bar、从不读 tick,该标的也同时存在 bar 与 tick 两条序列。此前这种情形下get_history(...)不传freq会抛ValueError: symbol X 同时存在 bar 与 tick 两条历史序列,实盘直接中止会话——用户被要求在一个「他不知道存在」的维度上做选择,而按单流写法写的策略代码本身毫无问题:def on_bar(self, bar): # 只有 on_bar close = self.get_history(20, bar.symbol, "close") # 此前在双流下崩掉
「双流下必须显式指定」这条规则对同时挂
on_bar与on_tick的策略仍然必要(取哪条确实无从推断),但在on_bar/on_tick回调内部,调用点的意图并无歧义。现按当前所处的回调自动定档:on_bar里省略freq等价于freq='bar',on_tick里等价于freq='tick'。行情回调之外(on_timer、on_before_trading、用户自建线程等)维持既有的歧义报错——那些位置推断不出该取哪条,不放宽成「全局默认取 bar」。五个历史入口(get_history/get_history_multi/get_history_df/get_rolling_data/get_history_map)全部覆盖。显式传入的freq优先,单流场景行为完全不变。字段合法性校验仍按用户显式传入的freq判定,不按推断值——否则纯 tick 单流下既有的get_history(field='open')调用(一直是返回退化 OHLC)会突然开始报错。 -
并行参数优化在 worker 进程死亡时不再挂死,失败组合也不再静默丢失。
run_grid_search(max_workers>1)原先用multiprocessing.Pool.imap,遇到 worker 进程级死亡(OOM、被 OOM-killer 杀、os._exit)会永久挂起——这是 CPython 的已知缺陷(bpo-22393, 十余年未修),跑几百个组合的网格时表现为「优化跑了几小时没有任何输出也不退出」。 即便侥幸抛出异常,原代码except之后迭代器不再产出,已提交但未取回的任务全部丢失, 用户既拿不到结果也不知道丢了多少。现改用
concurrent.futures.ProcessPoolExecutor+as_completed逐 future 收结果: 进程死亡会抛BrokenExecutor(标准库刻意的快速失败设计,见 bpo-14148:worker 崩溃后队列与同步对象状态不可信, 故整池置 broken 而不尝试恢复),该异常被捕获后落成带error字段的OptimizationResult进入结果列表,每个参数组合都有交代。注意池语义:一个 worker 异常终止后整池进入 broken 状态,同批尚未执行的任务会一并 失败(都落 error 列,不丢)。这不是「单任务失败其余照常跑完」——要做到那个需要池重建与 任务重投。刻意不做:调研 QuantConnect LEAN / vnpy / backtrader / backtesting.py / PyAlgoTrade 后确认,量化框架无一做任务级重试,唯一商业级实现(LEAN)明确选择 「记录失败、继续、不重试」;且优化场景下 worker 死亡的主因是内存不足,同参数重试大概率 再次 OOM,只是把一次失败变成 N 次失败加 N 倍耗时。正确应对是调小
max_workers或分批跑, 而这需要的正是下面那条汇总信息。策略级异常(参数越界、数据不足、策略内部报错)的处理未变:一直由
_run_single_backtest内部兜住并落error列,从来不经过这条路径。 -
参数优化结束时汇总成功/失败数。此前崩溃信息只逐条打
logger.error,几百个组合的网格里 会被淹没,用户无从判断「崩了 3 个」还是「崩了 200 个」——而这两种情况的应对完全不同。 现在收尾打一行优化完成: 成功 197, 失败 3 (worker 崩溃 3), 共 200 组, 耗时 412.5s(worker 崩溃 N仅在确有崩溃时出现;db_path跳过的历史结果不计入,只反映本轮实跑)。 失败占比 ≥50% 时追加一条 warning 指向排查方向——个别组合失败是正常的,过半失败几乎总意味着 策略或数据本身有问题,而不是参数不好。口径对齐 LEAN 的_completedBacktest/_failedBacktest分列。单线程(max_workers=1)路径同样输出。 -
实盘报单被柜台回绝时不再抛穿策略回调,改为落成
Rejected委托事件 +order_reject审计(origin="broker")。 -
报单超时/连接断开时不再谎报拒单,改为
on_error+order_submit_unknown审计—— 该情形下订单可能已在柜台,谎报拒单会诱导策略重复下单。 -
本地止损单在柜台返回「状态未知」(超时/断连)时不再自动重试。此前每次重试都会生成新的 client_order_id,柜台无从去重,若报文其实已经到达柜台即造成真实的重复委托;现改为放弃该单并打 CRITICAL +
on_error(..., "stop_trigger", ...)。柜台明确拒单仍照旧重试(订单确定不存在)。 -
OrderReceipt新增只读字段failure(None/"rejected"/"unknown"),使调用方能区分空回执 是「无需交易」、「订单确定不存在」还是「状态不可知」。 -
多腿单(平今/平昨、反手)中途失败时保留已成功腿的回执,策略得以撤掉已发出的腿。
-
撤单失败不再抛异常并中断整轮
cancel_all_orders,改为逐单隔离 +order_cancel_failed审计;单笔失败不影响其余单撤销。 -
instruments_config配了却在数据里找不到的标的会告警,不再静默丢弃:合约快照与撮合层的合约表都按数据里实际出现的 symbol 建,配置里 symbol 与数据对不上的条目会被完全静默丢弃,该标的回退到默认合约参数。最典型的撞法是数据用去后缀写法(600487)而配置写带后缀(600487.SH):实测lot_size从配置的 100 变回默认 1.0(tick_size恰好与股票默认值 0.01 相同,唯一露马脚的就是lot_size),A 股下单数量随之不再整百,而用户以为配置已生效。现在运行前会点名这些标的、说明后果并列出数据里实际出现的标的供比对;冷启动(run_backtest)与热启动(run_from_checkpoint)共用同一检查。它与既有的三个集合(filtered_out_symbols主动排除 /symbols里有但数据没有 / adapter 泄漏)互不相交——这一条比的是「配置 vs 数据」,与symbols白名单无关,因此不受是否显式传symbols影响;DataFeed对象输入无从枚举标的,跳过检查。 -
on_order不再重复推送同一订单的同一状态:check_order_events每个 bar/tick 事件都会重扫在途与终态订单,而_emit_order_callback对on_order是无条件调用(on_reject早有_framework_rejected_order_ids去重,on_order没有等价物)。于是一张单只要还留在ctx.recent_rejected_orders/ctx.orders里,它每一拍都会再触发一次on_order——表现为「回调全量推送」「同一张单跨交易日反复出现,看起来盘后还在推新回报」,写在on_order里的逻辑会被反复误触发。现在按状态指纹去重:键为「订单号 + 状态 + 已成交量 + 成交均价 + 拒单原因」,刻意不含时间戳——含updated_at/timestamp_ns的键每次重推都会变、去重完全失效;而只按订单号去重又会把New → PartiallyFilled → Filled这些真实状态推进整批吞掉。去重缓存有界(FIFO,上限 5 万条,模式与成交侧remember_trade_key一致),且不随 checkpoint 落盘:热启动后宁可把当前订单状态重推一轮,也不要因旧 key 残留而吞掉恢复后的第一次状态通报。 -
实盘会话因错误中止时,收尾摘要不再自称 "Manual Stop":策略回调抛异常时
run_live会打CRITICAL+ traceback 并停止事件处理(设计如此,实盘不把异常继续上抛),但紧随其后的摘要标题此前硬编码为TRADING SUMMARY (Manual Stop)——一次因故障中止的会话看起来像正常手动停止,而那条CRITICAL往往已被几十行日志淹没。现在该情形下标题为TRADING SUMMARY (ABORTED ON ERROR);正常结束与手动中断(含duration到期)的输出保持不变。 -
回测 DataFrame 的标的列识别改用项目统一的别名表,多标的不再被静默压成单标的:
run_backtest(data=DataFrame)判断"是不是多标的数据"的判据此前是if "symbol" in df.columns,只认英文列名。而 AKShare 的标准输出列名是股票代码,项目自己的别名表schema.COLUMN_ALIASES也早已包含股票代码/symbol/code/ticker(normalize.resolve_columns与dataframe_to_arrays都在用),实盘侧的dataframe_to_bars更是只认股票代码——两侧要求的列名正好相反。于是"从 AKShare 取多标的数据直接丢进run_backtest"这个最自然的用法会被静默压成单标的:所有标的的 bar 混进一条时间序列(指标与撮合结果均不正确),instruments_config里按真实标的配置的tick_size/lot_size整体失效,下单真实 symbol 只拿到Instrument not found。现在判据改用
resolve_columns(df),识别到的标的列统一重命名为symbol后走既有多标的路径;symbol列的既有行为完全不变。另外,未识别到标的列却检测到同一时间戳存在多行时会打WARNING点出退化、给出可用的列名清单与替代输入(Dict[str, DataFrame]/list[Bar])——同一时间戳多行是多标的被压平的可靠信号,真单标的数据每个时间戳只有一行,因此不会给单标的用法刷屏。三套标的列口径的彻底合并仍在docs/zh/meta/columnar-rfc.md挂账,此处只收敛回测入口这一条。golden 基线零漂移。 -
order_target/order_target_value/order_target_percent的取整口径改为与撮合层一致:下单侧取整此前只读策略属性self.lot_size(缺省 1),而撮合层校验读的是标的登记值Instrument.lot_size(execution/common.rs对买单校验)。于是登记了lot_size=100的 A 股标的,order_target_percent(0.2)仍按 1 股取整,算出的非整百数量必然被自己的风控拒掉——Quantity 19743 is not a multiple of lot size 100。实盘尤其无解:run_live没有lot_size参数(实盘只接Instrument对象),除了在策略里手写self.lot_size = 100,没有任何途径让取整逻辑知道登记值。现在两处取整(
_target_to_orders与calculate_max_buy_qty)共用一个_resolve_lot_size(),优先级为:- 标的未登记
lot_size(拿不到或<=0)→ 用self.lot_size,既有行为不变; self.lot_size比登记值更粗且是其整数倍(如登记 100 而策略想按 200 下单)→ 尊重策略的显式意图(200 的倍数必然也是 100 的倍数,不会被拒);- 其余情况(含缺省的 1、以及与登记值不成倍数的值)→ 用登记值。
lot_size=1的标的(美股/加密)不会被凭空取整到整百。golden 基线零漂移(回测路径的self.lot_size由券商模板灌入,本就是 100)。 - 标的未登记
-
Instrument构造期把交易所后缀归一化为大写,修复小写后缀导致的三处静默失败:Instrument("600028.sh", ...)这类写法此前被原样保留,而instruments是所有下游的唯一源头(实盘订阅集来自[inst.symbol for inst in instruments]、标的属性快照字典、Rust 撮合层的合约登记都从它派生)。于是同一个小写会在三个下游各自以毫不相干的面貌炸开,使用者几乎不可能反推到同一个成因:get_instrument("600028.SH")抛KeyError(快照字典的 key 是小写),报错文案会误导使用者以为标的没登记;- 实盘订阅集变成小写,而 broker 推送帧反解出的 symbol 恒为大写(如 middleware 的
instrument_to_symbol),按标的过滤时精确比较不命中 ⇒ 本任务自己的 order/trade 回报被静默丢弃(只留一行 debug),表现为「下单成功却收不到回调」; - Rust 撮合层按小写登记合约,大写行情下单时查不到 ⇒ 该标的的所有订单被拒,
reject_reason为Instrument not found for 600028.SH,且因拿不到tick_size/lot_size,本地的价格对齐与整手校验一并失效。
现在在构造期一次性归一化(与既有的
symbol.trim()同属输入清洗),并在改写时打WARNING点出原值与新值,提示上游统一写法。只规整最后一个.之后的后缀,证券代码段原样保留——上期所/大商所的期货合约代码本身含有意义的小写(ag2612、rb2601)且柜台大小写敏感,对整个 symbol 取大写会把它改坏;无.的写法原样返回。已经使用大写后缀的用户零影响(不改写、不告警),golden 基线零漂移。 -
修复热启动省略
symbols时会沿用上一段 checkpoint 里 pickle 残留的白名单的问题:Strategy._symbol_whitelist是普通实例属性,随策略对象整体 pickle 落盘。若阶段一run_backtest(..., symbols=[...])传了白名单,阶段二run_from_checkpoint(...)不再传symbols,引擎层因未显式传入而正确「不过滤」,但策略层的_symbol_whitelist会通过 pickle 沿用阶段一的旧值——造成「引擎放行、策略层subscribe()仍按旧白名单拦截」的自相矛盾,且报错文案里的白名单是本次调用根本没传的值。现在改为无条件赋值:显式传了symbols就写入本次的白名单,没传就显式置None覆盖掉恢复出来的旧值。 -
修复
symbols传字符串时白名单被按字符拆分的问题:如symbols="600519"这种受支持的写法,下发给策略层用于subscribe()校验的白名单曾直接对未归一的原始参数取set(...),导致set("600519")按字符拆成{'6','0','0','5','1','9'},策略在on_start里subscribe("600519")自身标的反而被误判为「白名单外」并抛ValueError。现在改用_resolve_effective_symbols已归一的标的列表构造白名单,字符串、元组、集合等写法均不受影响。 -
修复
warmup_period在多标的下每个标的只预热约N / 标的数根的问题(行为变更,会改变多标的回测数值,__engine_rule_version__升至 1.4.1):预热门槛此前用的是一个跨标的的全局 bar 计数器,而历史缓冲区是按标的各自维护的。于是warmup_period = 60配 3 个标的时,每个标的只攒到 20 根就跨过门槛开始交易,get_history(60, ...)里混入nan占位,指标用不足的数据计算——且完全静默,不报错也不告警。官方示例一律写self.warmup_period = self.params.long_window + 1(指标窗口),而指标是按标的各自计算的,因此这些用法在多标的下全部失效。现在门槛按标的独立计数:标的 A 自己攒够warmup_period根就开始交易,不必等标的 B,warmup_period无需乘以标的数量。影响:多标的策略(轮动、横截面、配对)的on_bar首次触发时间点后移,其后指标数值随之变化,既有回测结果与修复前不再可比——变化本身即修复生效的证据(此前是用不足的数据算出来的)。纯 tick 路径(on_tick从来不受预热门槛约束)不受影响,golden 基线未变化;单标的策略的冷启动回测行为同样不受影响,但按标的计数需要新增_symbol_bar_counts字段随策略一起落盘——早于该字段存在的旧检查点(单标的与多标的都可能受影响)热启动恢复后,若不做兼容处理会把已经攒够的预热期重新走一遍,即使 Rust 历史缓冲区其实已经完整恢复。现在恢复旧检查点时改为查询实际恢复的历史深度而非从 0 重新计数:预热已攒满的直接放行,存档时真正处于预热期中途的会按剩余量继续预热,新格式检查点不受影响。ML 滚动训练仍按全局 bar 计数触发(_rolling_step针对的是全局模型,语义未变);但未配置validation_config时的取模触发在 per-symbol warmup 门槛导致部分 bar 事件被跳过后可能永久错过某次训练(而非仅仅延后),已在下方独立条目中修复为阈值语义。收尾补充(会话结束告警):per-symbol 计数带来一个新的静默场景——若某标的的累计 bar 数在整个会话内始终不足
warmup_period(新上市标的历史太短、长期停牌、数据源对该标的覆盖不全,或warmup_period设得比该标的可用历史还长),它的on_bar会全程一次都不触发,策略对它没做任何决策,此前用户毫无提示,只会看到"这个标的怎么一直没信号"。放行逻辑本身没有问题(数据不足就不该拿它决策),问题只在于不可见。现在会话结束时(_on_stop_internal)会用WARNING(不抛错,数据不足是常见且合理的情况)点名这类标的、它的累计 bar 数与warmup_period,并给出可操作建议(调小warmup_period或为该标的补充历史数据);已经触发过on_bar(哪怕只有一次)的标的不受影响,避免噪音。已知限制:per-symbol 门槛的
return只挡住了on_bar_event里的analyzer_manager.on_bar,on_trade路径(strategy_order_events.py)不受该门槛约束——预热期内产生的成交回报仍会正常到达analyzer_manager.on_trade。这是修复前已存在的结构性不一致(此前预热期很短,不易暴露;per-symbol 化后某些标的的预热期可能等于其全部历史,不一致更容易被放大)。若自定义AnalyzerPlugin(见docs/zh/advanced/analyzer_plugin_spec.md)按"收到的on_bar次数"做分母或索引,在多标的 + 预热场景下会偏小,需要自行按标的维护计数,不能假设 bar 数与成交数同步递增。修复这一点需要改动 analyzer 的调度架构,超出本次范围,暂不改行为。 -
修复未配置
validation_config时 ML 滚动训练触发用取模判断、遇到被跳过的 bar 事件会永久丢失一次训练的问题:should_trigger_training在没有validation_config的分支此前用_bar_count % _rolling_step == 0判断是否触发训练,这对"任何一次被跳过的 bar 事件"都不健壮——per-symbol warmup 门槛(见上一条目)会让某个标的在预热完成前的部分 bar 事件不经过训练判断就直接返回;若全局_bar_count恰好在被跳过的那次到达_rolling_step的倍数,下一次事件时模值已经错过 0,这次训练永久丢失、而非像validation_config分支那样只是顺延(该分支本就用>=阈值判断,不受影响)。极端情形下(_rolling_step与预热跳过窗口重合)可导致model.fit全程一次都不会被调用,回测在没有任何训练过的模型下悄悄跑完全程,不报错也不告警。这不是本次改动引入的新缺陷——旧的全局计数器同样会在跳过窗口内丢失边界重合的触发,只是此前触发概率低很多,per-symbol warmup 生效后跳过窗口变长,缺陷更容易暴露,故一并修复。现在改为阈值语义:新增_last_train_bar_count状态(初值 0),触发条件为_bar_count - _last_train_bar_count >= _rolling_step,消费触发时推进为当前_bar_count——跳过的 bar 只会让触发顺延到下一根满足条件的 bar,不会丢;没有任何跳过时首次触发时机与取模完全一致。 -
修复
freq='tick'下get_history系列field='price'报Invalid field的问题。price是 tick 唯一的迁移出口(open/high/low/close之外,tick 没有真正的开高低价),Python 侧白名单、报错文案与文档都已把它当作合法字段推荐给用户,但 Rust 侧字段解析只认 OHLCV,导致照文档改的用户反而撞上报错。现在price在实际取 tick 容器时等价于close(tick 容器里close存的就是成交价);bar 容器不受影响。 -
修复纯 tick 回测下
ClosedTrade.mae/mfe/max_drawdown_pct静默恒为 0 的问题(回归修复)。mae/mfe本身就是百分数(相对入场价的百分比),并没有对应的绝对值字段。tick/bar 历史序列拆分后,引擎内部计算这几个指标时仍只读 bar 容器,纯 tick 回测下该容器恒为空,取不到历史后按初值 0 写入成交记录,且不报错——若你此前跑过纯 tick 回测(run_backtest(data=[Tick, ...]),未配合freq聚合出 bar),报告里这几列为 0 很可能是本 bug 所致,并非策略本身盈亏波动为零,建议重新跑一次核对。混合输入(tick + bar 同时存在)与纯 bar 回测不受影响,golden 基线未变化。 -
修复
run_live(broker="ctp", gateway_options={"emit_ticks": True, "emit_bars": True})被静默丢弃、on_tick永不触发的问题。CTP 的 builder 链路(_build_ctp→CTPMarketAdapter)此前没有转发这两个参数,底层CTPMarketGateway始终拿到默认值(emit_ticks=False),且没有任何报错或告警提示配置未生效。现已补齐转发;逐参数回退、两者都为False时报错的语义仍由CTPMarketGateway统一实现,与 klinedata 网关保持同构。 -
修复双流(同一 symbol 同时存在 bar 与 tick 历史)下 ML 自动训练被歧义报错静默吞掉的问题:
on_train_signal此前调用不带freq的get_rolling_data(),双流下撞上"两条历史序列同时存在"的ValueError,被外层except Exception降级为一行 WARNING 后放过——训练一次都没跑,回测却照常出报告,指标全部来自未训练的模型。现在遇到这个特定的歧义错误会自动退回freq='bar'重试(ML 训练需要真实 OHLCV,tick 序列没有意义),且该分支单独用 ERROR 级别记录并点破"本次训练窗口已跳过",与其它训练失败场景的 WARNING 区分开。单流场景行为不变。 -
缓解检查点跨版本恢复时含 tick 数据的策略被静默破坏的问题:tick/bar 历史序列拆分之前的存档里,bar 与 tick 共用同一个历史容器,用当前版本恢复这类旧存档会把整个旧容器当作 bar 序列装载,新写入的 tick 数据落入独立的
tick_data容器——若原策略含 tick 数据,续跑后该 symbol 会同时拥有两条历史序列,取历史时可能报"两条历史序列同时存在",或改传freq='tick'后只拿到续跑后的新 tick、历史窗口偏短。不改快照格式(RustHistoryBufferSnapshot::tick_data已用#[serde(default)]兼容旧存档,字段缺失与字段为空本身无法区分),改为在snapshot_features新增history_tick_split标记:load_checkpoint遇到有history_buffer_snapshot但缺这个新标记的旧存档时,会发出RuntimeWarning并记录日志告警,提示重新用当前版本生成检查点。 -
修复同一根 bar 内「先平后开」反手时开平语义标错的问题(#361)。
buy()/sell()在默认position_effect="auto"下按结算持仓拆开平腿,而结算持仓不含同周期已提交未成交的平仓单——close_position()紧跟buy()的写法里,第二笔看到的仓位仍是反向的,于是被判成平仓:反手两笔都标close,本该是close + open。做空方向不暴露此问题只是因为short()/cover()把position_effect硬编码为open/close,从不走 auto 推断。不止是标签错。落账层(
order_manager)只看side无条件加减,所以最终净仓与净值曲线一直是对的(这也是它长期未被发现的原因);但风控与保证金层按position_effect投影,那条假close腿的仓位变化算作 0,保证金需求为 0——一笔实质开仓的单子零保证金过闸。同时is_reduce_first_order会把它排到开仓单之前参与 reduce-first 撮合排序。现改为按可平仓拆腿:结算持仓扣除在途平仓/减仓单占用(状态取
New/Submitted/PartiallyFilled,按quantity - filled_quantity折算,并对平仓量封顶)。刻意不投影在途开仓单——在途开仓单未必成交,不投影它使拆腿偏向判为开仓,即偏向多预留保证金(安全侧)。此口径与 vn.pyOffsetConverter、RQAlphaclosable一致。__engine_rule_version__相应升至1.3.7。 -
修复
order_target*/close_position()在同一根 bar 内重复下单造成超卖的问题(与 #361 同源)。它们同样按结算持仓算差额,先close_position()再order_target_percent()会在全平单之外再补一笔同向单——卖出量超过持仓。现改为按投影持仓(结算持仓叠加全部在途单的预期效果)算差额:目标仓位问的是「仓位最终落在哪」,开仓与平仓在途单都要计入,否则目标不收敛。同一根 bar 内重复调用close_position()现在是幂等的。与
auto拆腿只扣在途平仓单的取舍不同,两者问的问题不同,故分为get_closable_position()与get_projected_position()两个口径,共用同一投影核心。 -
修复多策略下同周期跨策略挂单不可见的问题。
ctx.positions是账户级全局,但active_orders在 slot 循环之前只快照一次,slot 0 本周期提交的单要到循环体末尾才发给事件管理器,slot 1 因此看不到——两边口径不一致会让开平推断漏掉别的策略的减仓意图。现在 slot 之间累加已提交单(仅多策略时累加,单策略不多付克隆开销)。 -
修复
examples/textbook/ch07_futures.py从不触发反手分支的问题:warmup_period = 10少于get_history(count=ma_window + 1)要求的 11 根,返回数组首位为NaN,示例又取closes[:-1][-10:]正好圈入该NaN,均线恒为NaN、比较恒为假、信号永久锁在初值。同时修正期末权益取错 metrics 键的问题:end_portfolio_value从不存在,正确键是end_market_value,原写法有if key in index else 0.0兜底,故静默打印0.00而不报错。 -
修复
executions_df的 Python 兜底路径与 Rust 快路径side列取值不一致的问题:兜底路径用str(t.side).lower()产出orderside.sell,而快路径产出sell——同一列的值随走哪条路而变。 -
修复
broker_live下buy()/sell()缺省参数不解析的问题:quantity=None原先直接透传到拆腿逻辑并以TypeError崩掉——等价于set_sizer()在实盘静默失效;symbol=None原先会把None送进柜台请求。现按回测同口径解析:symbol取当前 bar/tick,买入量走strategy.sizer,卖出量全平持仓。另外解析后quantity <= 0时不再向柜台发 0 手单,改为返回空回执(与回测一致)。一处有意偏离:卖出全平在实盘取可用持仓而非总持仓,因为 A 股 T+1 下按总量报单会被柜台整单拒绝,取可用量退化为部分卖出。 -
修复
broker_live下get_instrument()/get_instruments()/get_instrument_field()对任何标的都抛KeyError的问题:run_live原先从不调用_set_instrument_snapshots,策略侧快照字典恒为空。现由LiveRunner从传入的Instrument列表灌入。受实盘入口形态限制,Instrument仅能回读 9 个字段,option_type/strike_price/expiry_date/underlying_symbol/settlement_*在实盘快照中为None(回测不受影响,仍由InstrumentConfig灌入完整字段)。 -
修复
client_order_id跨会话撞号的问题:原格式{broker}-coid-{序号}的序号每次run_live从 0 起,重启后第一笔单又是...-coid-1,与柜台里同名历史委托冲突——把client_order_id当幂等键的柜台会直接拒单(实测中间件返回 409 Conflict)。现格式为{broker}-{8 位会话标记}-{会话内序号},会话标记每次启动重新生成,跨重启与多进程并行均不撞号。 -
修复
on_after_trading里下 next-open(bar_offset=1)单晚一格成交的问题(#324)。它原先在「下一根 bar」到来时才惰性补触发,引擎时钟已越过日界,其中提交的 next-open 单被撮合守卫推迟一格(盘后下单期望次日开盘成交,实际到第三日)。现改为按日终边界定时器在「当日收盘点」的独立事件里触发(早于下一根 bar),使其中提交的次日开盘单落在下一根 bar,而非晚一格。默认(非 precise)与 precise 模式下均已修正;on_before_trading等开始型钩子本就与on_bar同拍触发,不受影响。__engine_rule_version__相应升至1.3.1。 -
修复
on_pre_open未兑现「本次 open 成交」契约的问题(#324 家族)。其盘前定时器原先排在当日首根 bar 的同一时刻,订单created_at与该 bar 同拍、被 next-open 守卫拦截,导致实际成交在下一日 open(可由examples/52_pre_open_demo.py复现)。现将盘前定时器排在首根 bar 之前 1ns,使on_pre_open中下的默认开盘单落在当日 open,与文档承诺一致。 -
修复短回测区间下 Sharpe/Sortino 因「分子用 CAGR、分母用日波动 × √252」口径不一致而出现异常巨大值(如期货场景 Sharpe 高达数千万)的问题;改用日收益算术均值年化后数值回归合理量级。
-
修复
on_timer/schedule_daily(原add_daily_timer)场景下,订单级fill_policy={"price_basis":"close","bar_offset":0,"temporal":"same_cycle"}未在当日 timer 事件内生效的问题;相关卖单现在会按当日 timer 时间与当日 close 成交,不再延后到下一交易日。 -
修复 framework 内部
__framework_rebalance__/__framework_boundary__timer 被误参与 same-cycle 撮合与终态统计的问题,避免出现+1ns的伪成交、partial fill 被过早补满,以及权益曲线尾部多出额外采样点。 -
修复
on_pre_open首个交易日可能因延迟注册 timer 而不触发的问题;相关 framework timer 现会在回测事件循环开始前直接注入引擎,从而保证首日也能按预期开盘语义执行。 -
修复函数式策略(
strategy=on_bar入口)无法复用「回调未重写就跳过」快路径,导致回测慢一个数量级的问题。判定函数_strategy_overrides_callback是按类比较的(type(strategy)上的方法 vs 框架基类Strategy的默认实现),而内部包装器FunctionalStrategy在类体里无条件定义了全部回调转发方法(未提供对应函数时方法体只是if self._on_xxx_func is not None的空转)——于是on_before_trading/on_after_trading/on_portfolio_update/on_pre_open/on_cross_section/on_timer六个钩子一律被判为「已重写」,快路径全线失效。后果是引擎为每根 Bar 注册并分发整套框架钩子(pre-open / cross-section / time-hooks / portfolio-update),而下游无人消费:1000 根 Bar 的回测里空转分发 3000 次 timer 事件,占总耗时约 2/3。实测第 11 章 12 组合网格搜索:类风格约 1.0 秒,函数式约 11.8 秒(约 13 倍)。回测数值一直是对的(两版结果逐值一致),这也是它长期未被发现的原因。
现给
FunctionalStrategy加类标记_is_functional_wrapper,判定函数在「该方法正是包装器自身那份」时改看用户是否真的提供了对应回调(_on_<name>_func is not None);子类若二次重写该方法,method不再是包装器那份,仍走原有的通用比较,因此继承FunctionalStrategy自定义钩子的写法不受影响。用类标记而非isinstance是为了避开strategy_framework_hooks→backtest.engine的循环导入。修复后同一网格搜索降至约 1.6 秒(1.62x),剩余差距是函数式每次回调多一跳 Python 转发的固有成本。回测数值不变(golden 基线通过、网格结果与类风格逐值一致),故
__engine_rule_version__不变。
- 新增
on_expiry/on_expiry(ctx, event)回调与流式expiry事件;当引擎实际执行expiry_date驱动的到期结算或到期移除后,策略与run_backtest(..., on_event=...)均可收到通知。 - 新增
examples/49_on_expiry_demo.py,演示on_expiry回调、流式expiry事件以及结算后读取最新持仓状态。
- 中英文 API 文档、策略指南、Quickstart、教材与示例总览已补充
on_expiry的能力说明、使用边界与示例入口。
- 修复
StrategyContext/ExpiryEvent类型声明错位问题,并同步完善BacktestStreamEvent、函数式策略on_expiry与相关示例的类型签名,使mypy/pre-commit校验重新通过。
- 修复日内调仓时机与行情数据跨符号排序问题,减少多标的同周期调仓时的执行顺序偏差。
- 新增显式滑点策略写法,
run_backtest、StrategyConfig、策略级strategy_slippage与订单级下单接口现支持{"type": "percent"|"fixed"|"ticks"|"zero", "value": ...}。 short()/cover()现支持tag、fill_policy、slippage与commission,与buy()/sell()的下单覆盖能力保持一致。
- 期货教材示例与相关中文文档已切换为显式滑点 policy 写法,优先推荐
percent/fixed/ticks,并补充了成交时点与滑点语义的防踩坑说明。 - 内置
broker_profile的滑点模板已迁移到显式 policy 表示,避免继续依赖裸数值语义。
- 裸
float/int形式的slippage仍保持兼容,但已进入弃用路径;当前会触发DeprecationWarning,且对可疑的大滑点值给出明确提示。
- 为
ticks滑点补充了tick_size解析与校验逻辑,避免多标的或缺少合约最小变动价位时静默使用错误滑点。
- Fixed non-deterministic backtest metrics in multi-symbol runs when bars share the same timestamp.
- Ensured terminal equity/cash/margin snapshots are overwritten with the fully updated portfolio state before final metric calculation.
run_backtestnow supports optionalon_eventcallback and can emit stream events directly.- Added
ChinaOptionsConfigwith prefix-level option fee configuration (fee_by_symbol_prefix). - Added Engine API
set_options_fee_rules_by_prefix(symbol_prefix, commission_per_contract). - Added readable time-string properties for
Trade.timestamp,Order.created_at, andOrder.updated_at.
run_backtest_streamis removed; stream scenarios should callrun_backtest(..., on_event=...).run_backtestalways uses the unified stream core; runtime rollback flag_engine_modeis removed.- Futures fee Engine API naming is standardized to
set_futures_fee_rules*; legacyset_future_fee_rules*is removed.
- Fixed benchmark return series index normalization and improved validation for report generation.
- Added strategy start-time configuration support to the engine.
- Improved futures margin risk handling.
- Applied conditional open-price optimization according to the configured price-basis policy.
- Added walk-forward model lifecycle management for the ML workflow.
- Added multi-symbol backtesting support and improved backtest window configuration.
- Added dictionary-based multi-symbol input support to the optimization workflow.
- Improved rolling-training scheduling in parameter optimization.
- Fixed missing
ctx.ordersin order-event callbacks.
- Added same-bar cash reuse for sell-then-buy flows.
- Distinguished automatic quantity adjustment from explicitly sized orders.
- Fixed cross-category operator conflict detection in the factor-expression parser.
- Cleaned up completed migration docs and outdated links.
- Refined examples by removing unused imports and obsolete configuration parameters.
- Added
catalog_pathsupport for specifying the data directory in backtests. - Added Top 8 rejected-order reason summaries in backtest output.
- Added order-level execution overrides and strategy-level default execution settings.
- Added
NextCloseexecution mode and unified thesymbolsparameter behavior.
- Replaced
ExecutionModewithExecutionPolicyCore. - Simplified the
price_basisoptions underfill_policy. - Updated the execution-semantics documentation and migration guidance.