Compare commits

..

270 Commits

Author SHA1 Message Date
boris 2de7127f02 支持强市目标仓位微调 2026-07-08 03:18:45 +08:00
boris a5a9688599 回退弱市调仓执行层分拆 2026-07-08 03:12:38 +08:00
boris ad49fc89d3 修正弱市调仓与止盈分拆顺序 2026-07-08 03:10:34 +08:00
boris 6329a8a0da 修正延迟卖出后续补仓控制 2026-07-08 03:08:24 +08:00
boris 69b793cbb8 修正延迟涨停卖出补仓槽位 2026-07-08 03:03:41 +08:00
boris f8f01a0987 修正历史清仓残量补仓语义 2026-07-08 02:58:24 +08:00
boris 4ce52a7af6 修正部分卖出持仓成本 2026-07-08 02:44:31 +08:00
boris ca0471799b 修正ALV残余清仓重判逻辑 2026-07-08 02:38:19 +08:00
boris 0febd3d644 修正ALV清仓禁买跨日状态 2026-07-08 02:31:23 +08:00
boris c085730ca5 修正挂起清仓补仓占位 2026-07-08 02:24:04 +08:00
boris 538edb907d 修正ALV连续补仓槽位释放 2026-07-08 02:22:17 +08:00
boris a854a4ec02 合并延迟清仓与补仓占位 2026-07-08 02:13:45 +08:00
boris ec098c6d39 限制同轮补仓重复释放槽位 2026-07-08 02:12:09 +08:00
boris 17bac07a86 补齐延迟清仓补仓占位 2026-07-08 02:05:29 +08:00
boris 63da6bb1bd 修正ALV补仓槽位占用语义 2026-07-08 02:03:15 +08:00
boris e847ecd54c 修正未完成清仓预算槽释放 2026-07-08 01:55:17 +08:00
boris 51ee4a3f54 按持仓顺序处理未完成清仓补仓 2026-07-08 01:53:00 +08:00
boris 8c4156948e 修复ALV日内补仓循环次数 2026-07-08 01:48:15 +08:00
boris 89f8bb32d0 对齐ALV日内补仓执行顺序 2026-07-08 01:44:32 +08:00
boris e0a7eb8972 修正FIDC未完成清仓占位预算 2026-07-08 01:12:30 +08:00
boris 12da5a4704 对齐ALV补仓订单返回语义 2026-07-08 00:59:11 +08:00
boris 749b5e3b9c 修正FIDC止损未成交后补仓语义 2026-07-08 00:43:25 +08:00
boris e74e2226d5 修正AiQuant兼容选股剔除北交所 2026-07-07 23:57:14 +08:00
boris 5a1534e51e 修复预计算rolling缺失语义 2026-07-07 23:05:26 +08:00
boris 22451300b1 修复AiQuant同批目标调仓净额语义 2026-07-07 22:44:49 +08:00
boris a50e59ab1d 修复AiQuant止损调仓顺序语义 2026-07-07 22:11:54 +08:00
boris 6d86eab021 修正缺字段误触发强制退出 2026-07-07 16:15:43 +08:00
boris 6b306eecf2 修正FIDC风控字段与默认选股语义 2026-07-07 13:14:34 +08:00
boris 90857fae0a 修复目标组合风控拒单记录 2026-07-07 09:09:29 +08:00
boris b37ebb81f1 修复策略显式排除股票匹配 2026-07-07 07:19:10 +08:00
boris e37b8e1265 限制延迟滑点现金口径 2026-07-07 06:36:33 +08:00
boris 3ef4029c4a 修正FIDC执行日风控配置 2026-07-06 20:05:11 +08:00
boris cb189e3de4 完善FIDC策略执行语义 2026-07-06 14:52:50 +08:00
boris 4fee8e1d07 修正精确分钟执行价回退逻辑 2026-07-06 10:55:01 +08:00
boris 64298f09c1 修正涨停持仓延迟卖出打标 2026-07-06 10:27:18 +08:00
boris 60bfa28ef0 修正季节清仓执行时间 2026-07-06 10:23:23 +08:00
boris 8c5a2ef611 修正涨停卖出挂起语义 2026-07-06 10:16:39 +08:00
boris db1ffb5918 修复AiQuant转换调度和不可卖调仓语义 2026-07-06 10:07:56 +08:00
boris fe39a75e6e 修复分钟执行价缺失档位量成交 2026-07-06 09:54:14 +08:00
boris 1d33b29c27 修复季节性清仓执行时间 2026-07-06 09:40:07 +08:00
boris 831edfc8c6 修复平台策略部分清仓跨日续卖 2026-07-06 08:52:53 +08:00
boris c3ab279d7d 修复分钟执行价成交量预过滤 2026-07-06 08:44:57 +08:00
boris 6820b63d56 修复平台策略每日调仓和止损下单 2026-07-06 08:26:16 +08:00
boris fdc099c960 修正内置选择器next-open风控语义 2026-07-06 08:22:59 +08:00
boris afb531da59 修正显式订单测试成交量口径 2026-07-06 08:19:21 +08:00
boris 7c867f1788 修复分钟成交量限制撮合 2026-07-06 08:05:59 +08:00
boris a3415095a7 补充分钟买入投影调试信息 2026-07-06 07:26:20 +08:00
boris 3e3bebf3e0 修复分钟成交量限制误用盘口量 2026-07-06 07:22:58 +08:00
boris 99a21324db 修复回测持仓天数和选股风控语义 2026-07-06 05:54:47 +08:00
boris 92d5801f63 修正平台表达式选股风控补位 2026-07-06 05:45:52 +08:00
boris 551421818b 补齐FIDC风控安全开关合同 2026-07-06 01:11:39 +08:00
boris de1373deae 修复日线投影缺盘口量拒单 2026-07-05 23:06:51 +08:00
boris f9f9706900 增加平台买入投影诊断开关 2026-07-05 23:03:48 +08:00
boris 74105f0fde 修复日线投影成交量限制基数 2026-07-05 22:59:28 +08:00
boris 72c667d790 修复next-open信号日投影缺quote问题 2026-07-05 22:53:03 +08:00
boris 82ed5b3e0b 新增FIDC运行时风控合同验证 2026-07-05 19:35:39 +08:00
boris b489abba6b 修正next open选股风控测试命名 2026-07-05 19:12:47 +08:00
boris 203a20592a 修正平台表达式选股风控缺口 2026-07-05 18:19:14 +08:00
boris 7059d3a8d0 补充next-open选股风控延后回归测试 2026-07-05 17:44:34 +08:00
boris 289448d196 修复FIDC默认选股池依赖风控事实 2026-07-05 17:13:51 +08:00
boris c557656040 修复策略投影成交量约束 2026-07-05 17:01:04 +08:00
boris 7189998699 修正FIDC选股风控延后边界 2026-07-05 15:05:00 +08:00
boris 00c9042c15 修复FIDC选股阶段风控语义 2026-07-05 14:51:54 +08:00
boris d3c986e1f2 修复next-open执行日风控价格 2026-07-05 13:48:01 +08:00
boris 2f61bd8e57 修复next-open涨跌停风控价格口径 2026-07-05 10:41:27 +08:00
boris 61ad4119cf 修复末日next open执行候选测试 2026-07-05 09:40:32 +08:00
boris ab31006d01 修正末日执行测试诊断位置 2026-07-05 09:39:21 +08:00
boris 584a38c7a7 修正next open诊断断言位置 2026-07-05 09:38:34 +08:00
boris 1d817c7f50 增加next open末日执行诊断 2026-07-05 09:37:41 +08:00
boris b327bb074e 使用on day调度验证末日执行 2026-07-05 09:37:07 +08:00
boris 8ed2b0df7f 补充指定决策日调度测试策略 2026-07-05 09:36:27 +08:00
boris a21ac83f21 调整next open末日执行测试入口 2026-07-05 09:35:46 +08:00
boris 13e15cc7c4 补充next open开盘执行回归测试 2026-07-05 09:35:00 +08:00
boris ad6e168303 修正next open末日执行测试 2026-07-05 09:33:34 +08:00
boris 73627b1b2d 修正next open执行日回归用例 2026-07-05 09:33:15 +08:00
boris 1219b42046 修复next open决策执行日映射 2026-07-05 09:31:56 +08:00
boris ba2470aefe 补齐回测交易日期审计字段 2026-07-05 08:47:52 +08:00
boris 8543c3ab6d 收紧回测引擎旧数据源扫描范围 2026-07-05 07:04:56 +08:00
boris 9a16ceefbb 收紧回测引擎旧缓存门禁 2026-07-05 06:01:36 +08:00
boris 549595c1c6 修正FIDC选股阶段风控语义 2026-07-05 03:08:38 +08:00
boris 8125ea2e3b 补全Platform安全除法整数重载 2026-07-05 03:00:34 +08:00
boris 3e4270729b 修复Platform选股阶段风控语义 2026-07-05 02:49:53 +08:00
boris 9aa156eb2a 完善策略生成滚动函数参数约束 2026-07-05 02:15:57 +08:00
boris 4e3ae3b378 修复平台表达式安全除法运行时 2026-07-05 02:01:55 +08:00
boris 339f85c27b 补齐next-open卖出执行日风控测试 2026-07-04 21:45:23 +08:00
boris aff7fa309c 补齐策略生成风控提示词 2026-07-04 21:23:49 +08:00
boris cb02041b3b 收紧风控策略别名冲突校验 2026-07-04 20:35:06 +08:00
boris e8ecc037c9 补充next open执行日风控测试 2026-07-04 20:04:30 +08:00
boris 652531ac63 补齐策略AI持仓合同请求类型 2026-07-04 19:15:13 +08:00
boris a8ffd36150 保留AiQuant盘中卖出价格风控语义 2026-07-04 17:02:55 +08:00
boris 143a021067 统一FIDC卖出allow_sell风控 2026-07-04 17:01:02 +08:00
boris 487e1a38aa 修正退市风控测试数据 2026-07-04 16:52:58 +08:00
boris e70d637ade 修正FIDC退市风控原因识别 2026-07-04 16:50:40 +08:00
boris 8fa4ab24fb 补齐回测风控统一印花税字段 2026-07-04 14:36:24 +08:00
boris 14810708f0 修复退市生效日结算 2026-07-04 14:12:10 +08:00
boris 3e907d8e43 修正next-open信号日选股风控语义 2026-07-04 11:39:59 +08:00
boris bf457d94ce 修正ST星ST独立风控判定 2026-07-04 11:21:00 +08:00
boris e045ca5a49 修复FIDC next-open风险退出执行日判断 2026-07-04 10:28:55 +08:00
boris 84a50111c0 明确FIDC next open实际成交日风控语义 2026-07-04 09:41:02 +08:00
boris 3fae717912 禁止回测运行路径使用JSON数据端点 2026-07-04 09:20:38 +08:00
boris 995dd96117 完善回测风控配置归一化 2026-07-04 05:56:42 +08:00
boris c911e79d88 补齐延迟撮合选股风控测试 2026-07-04 05:51:35 +08:00
boris f507c63069 收敛平台策略选股风控判定 2026-07-04 05:19:22 +08:00
boris 723d2c8354 修正next-open策略上下文日期 2026-07-04 05:00:55 +08:00
boris 9ab813e74d 修复next-open信号日风控投影 2026-07-04 04:14:22 +08:00
boris 69576f7e5b 修复allow_sell执行风控开关 2026-07-04 03:31:30 +08:00
boris 3a66c90f34 补充执行阶段风控审计 2026-07-04 03:27:24 +08:00
boris 5481db63df 修复表达式策略next-open风控日期语义 2026-07-04 03:05:45 +08:00
boris 5f2697540a 修复延迟执行选股风控语义 2026-07-04 03:02:02 +08:00
boris 8e4b3d15a4 补充next-open信号日涨停回归测试 2026-07-04 00:53:45 +08:00
boris cbe135ed0d 补充next-open星ST成交日风控测试 2026-07-04 00:11:14 +08:00
boris 73dd006bb2 清理CSV回测demo入口 2026-07-03 23:40:33 +08:00
boris fea09ce93c 补充next-open执行日风控回归测试 2026-07-03 22:49:50 +08:00
boris 9fa588fef8 补齐next-open卖出执行日风控测试 2026-07-03 21:50:44 +08:00
boris f45a5fd0a7 补齐next-open执行日风控回归测试 2026-07-03 21:30:06 +08:00
boris 25001fd3e4 修正next-open成交日风控语义 2026-07-03 21:08:27 +08:00
boris 3d98ec35e7 禁止回测引擎接入历史特征库 2026-07-03 20:33:12 +08:00
boris 5bbe8959f4 移除FIDC选股CSV覆盖入口 2026-07-03 09:51:39 +08:00
boris 3bb001c374 拆分ST与星号ST风控语义 2026-07-03 09:00:50 +08:00
boris c32926cc34 同步策略生成风控能力说明 2026-07-03 08:48:12 +08:00
boris 54fb92a780 加硬回测数据源门禁 2026-07-03 07:18:13 +08:00
boris 27d6740dc5 补齐回测引擎融合表数据源守卫 2026-07-03 07:08:38 +08:00
boris cab7c605dc 修正回测引擎遗留数据源检查 2026-07-03 06:59:46 +08:00
boris e77baffa10 补齐FIDC北交所风控开关 2026-07-03 06:26:51 +08:00
boris 179c4eaff5 统一策略规范科创板归类 2026-07-03 05:03:52 +08:00
boris 32b6da5aca 拒绝非法回测成交量比例配置 2026-07-03 04:30:40 +08:00
boris 564a2fb9b2 统一表达式策略成本风控来源 2026-07-02 22:59:45 +08:00
boris 25cc643f34 修复风控缺字段审计优先级 2026-07-02 21:39:04 +08:00
boris daa0a9b4e6 完善日线无量订单取消语义 2026-07-02 21:17:44 +08:00
boris 97931c3766 修复日线撮合误用分钟成交量 2026-07-02 21:12:48 +08:00
boris f796a85617 补齐回测风控佣金别名 2026-07-02 12:12:12 +08:00
boris 50120e0f9b 细化缺失风控事实开关判断 2026-07-02 11:40:08 +08:00
boris 8715a6171a 补齐回测卖出侧缺失风控事实拒绝 2026-07-02 11:29:38 +08:00
boris baa77c68e0 补充缺失风控状态拒绝 2026-07-02 09:41:07 +08:00
boris b176d2ff6f 修复FIDC风控别名归一化 2026-07-02 07:45:56 +08:00
boris 7db0e8da1d 实现FIDC配置化风控与交易成本 2026-07-02 07:16:47 +08:00
boris 754fc91376 修正AiQuant动态调仓现金预算 2026-07-01 15:19:05 +08:00
boris fb9d8f3b9a 修正AiQuant回测佣金模型 2026-07-01 15:07:33 +08:00
boris cad8877b7a 修正AiQuant等权调仓预算 2026-07-01 15:00:55 +08:00
boris eae82128ee 对齐除权除息到账顺序 2026-07-01 14:46:10 +08:00
boris 9f188f6313 修正AiQuant固定现金调仓预算 2026-07-01 14:32:52 +08:00
boris 6b1afc975e 补齐北交所基础过滤语义 2026-07-01 14:24:02 +08:00
boris 49e883827e 兼容AiQuant回测profile别名 2026-07-01 13:09:19 +08:00
boris 2900a40b38 修正AiQuant策略严格买入预算 2026-07-01 12:48:06 +08:00
boris a59b687b62 补充调仓日期诊断 2026-07-01 09:33:08 +08:00
boris 8ba4b4d2c1 补充表达式决策日变量 2026-07-01 09:06:44 +08:00
boris eeaf061932 修复信号日期调仓执行语义 2026-07-01 08:42:38 +08:00
boris c3101aa995 补充三年收益达标约束 2026-06-30 12:41:56 +08:00
boris ec3ec7a26f 补充策略生成持仓数量提示 2026-06-30 11:07:39 +08:00
boris 19b7a0c00c 验证目标组合区间表达式 2026-06-29 17:45:46 +08:00
boris 9e6eac557f 修复Smart调仓缺行情处理 2026-06-29 16:35:21 +08:00
boris 1623994287 缺行情调仓订单改为拒单 2026-06-29 16:22:50 +08:00
boris fbc6da1a8f 修复目标组合零权重估值 2026-06-29 14:59:20 +08:00
boris 49981f2f3e 增加回测引擎旧数据源守卫 2026-06-28 06:50:03 +08:00
boris 4009fe0899 严格校验回测撮合类型 2026-06-28 02:41:39 +08:00
boris dd8783c8c1 收敛平台策略撮合模式 2026-06-28 01:16:38 +08:00
boris 9562b8a280 修复next open首日未来函数 2026-06-27 23:57:09 +08:00
boris e83856baa9 更新策略手册撮合口径 2026-06-27 19:42:52 +08:00
boris bb690e12c2 收敛策略生成撮合口径说明 2026-06-27 08:04:10 +08:00
boris 41237dccfd 补充动态因子缺失回归测试 2026-06-27 07:56:29 +08:00
boris 6067adc120 支持动态因子缺值安全表达式 2026-06-27 07:53:51 +08:00
boris 275dde61ae 更新策略手册数据湖命名 2026-06-27 01:46:38 +08:00
boris ab36e6b613 更新分钟线执行能力说明 2026-06-27 00:57:43 +08:00
boris a131c761e5 调整回测撮合为分钟线执行价语义 2026-06-26 17:03:48 +08:00
boris 380c34aa66 移除回测兼容语义残留 2026-06-26 13:39:21 +08:00
boris 7f40cfdab0 切换回测执行价为分钟线语义 2026-06-26 13:27:49 +08:00
boris 6db480b91d 切换分钟执行价语义 2026-06-26 09:27:21 +08:00
boris 02e2a20aff 修正表达式策略执行价诊断文案 2026-06-26 04:53:53 +08:00
boris 1bcedcee0f 修正AiQuant兼容佣金默认值 2026-06-23 12:30:09 +08:00
boris ad405d130e 修正AiQuant兼容回测语义 2026-06-23 09:25:12 +08:00
boris c83526a6a4 懒加载日线序列缓存降低回测内存 2026-06-21 03:57:07 +08:00
boris 9bd19aa042 瘦身回测数据集按日索引内存 2026-06-21 03:48:22 +08:00
boris 2f62d82420 优化回测数据集内存并修复rolling依赖识别 2026-06-21 03:31:45 +08:00
boris 7f809fd875 修复涨停持仓普通调仓提前卖出 2026-06-21 02:53:38 +08:00
boris 8495bf6ad8 允许弱市涨停持仓部分减仓 2026-06-21 02:19:59 +08:00
boris 9d41971d3f 共享日线行情索引存储 2026-06-21 02:11:44 +08:00
boris c409d500b3 减少市场序列构建克隆 2026-06-21 02:06:28 +08:00
boris d0ab59669f 复用已预载执行报价 2026-06-21 01:59:51 +08:00
boris d264e39285 懒加载策略额外因子状态 2026-06-21 01:42:06 +08:00
boris 5b34f3b55b 按选股表达式跳过无关盘中quote 2026-06-21 01:37:04 +08:00
boris 192ac3f843 缓存调度执行quote查询 2026-06-21 01:29:15 +08:00
boris 581d4e32d0 复用选股候选股票状态 2026-06-21 01:20:57 +08:00
boris bb87d69224 按需保留股票额外因子状态 2026-06-21 01:13:22 +08:00
boris 0714d1f77b 移除股票rolling临时哈希开销 2026-06-21 01:06:05 +08:00
boris 78af8c3219 按表达式裁剪股票rolling状态 2026-06-21 01:01:23 +08:00
boris fd27429713 去重股票状态rolling计算 2026-06-21 00:54:33 +08:00
boris a270e368c8 缓存日内股票表达式状态 2026-06-21 00:47:42 +08:00
boris a368fd5d7f 短路预计算rolling因子读取 2026-06-21 00:37:51 +08:00
boris 0f982887a3 缓存平台策略表达式元数据 2026-06-21 00:20:56 +08:00
boris beebc5fa58 修复候选池风险等级二进制缓存 2026-06-20 23:59:44 +08:00
boris f8bc0679ee 修正FiRisk强平触发来源 2026-06-20 23:07:59 +08:00
boris cdca7984ed 修正FiRisk强平执行时间口径 2026-06-20 22:46:43 +08:00
boris 816fc48077 修复FiRisk禁持清仓判定 2026-06-20 19:51:38 +08:00
boris 144483be4c 修复买入禁入误触发强平 2026-06-20 19:24:31 +08:00
boris eb4e77f8c5 补齐AiQuant风控清仓优先级 2026-06-20 18:10:00 +08:00
boris 6ddbdac9cd 修复涨停持仓弱市缩仓保护 2026-06-20 18:01:59 +08:00
boris ecb9a1cfaf 修复AiQuant模式买入禁用过滤 2026-06-20 17:53:37 +08:00
boris 61fc93abf1 Revert "修复AiQuant部分成交补仓预算"
This reverts commit 7ce28e6d0f.
2026-06-20 17:28:55 +08:00
boris 7ce28e6d0f 修复AiQuant部分成交补仓预算 2026-06-20 17:24:30 +08:00
boris 1a4936d250 修复AiQuant调仓现金可用语义 2026-06-20 17:15:30 +08:00
boris 9692557746 修复停运窗口涨停延迟卖出 2026-06-20 16:20:09 +08:00
boris 8df6bfd19c 修正弱市减仓涨停待开板处理 2026-06-20 14:07:13 +08:00
boris 5e66e9799c 优化回测撮合与涨跌停约束 2026-06-20 07:59:22 +08:00
boris 5ecb0e7986 修复策略表达式卖出投影槽位释放 2026-06-19 18:33:07 +08:00
boris c3a5161db1 修正跌停卖出未成交仓位释放 2026-06-19 14:25:10 +08:00
boris 57aebe97ec 修正预计算市值决策口径 2026-06-19 10:20:12 +08:00
boris 651174dc57 修正平台策略市值选股日期口径 2026-06-19 09:26:22 +08:00
boris 4b6301cb37 增加日内补仓预算诊断 2026-06-19 06:03:17 +08:00
boris 1db80e1e13 修正停运窗口延迟卖出顺序 2026-06-18 20:54:59 +08:00
boris 938f4fec13 修正AiQuant止盈止损成本基准 2026-06-18 20:43:53 +08:00
boris daa505152a 修正AiQuant日内补仓预算口径 2026-06-18 20:26:34 +08:00
boris 02d4ea9ca7 优化回测数据集索引查找 2026-06-18 20:09:25 +08:00
boris 3633905459 支持策略决策前批量加载执行价 2026-06-18 17:08:36 +08:00
boris 2265a5dc67 增加执行价快照计数接口 2026-06-18 16:39:49 +08:00
boris 616d9cdce2 支持回测数据集快照组件导出 2026-06-18 16:32:41 +08:00
boris 213deb6e99 优化平台表达式选股快路径 2026-06-18 16:15:34 +08:00
boris 7ff443898c 修正弱市缩仓补买预算 2026-06-18 11:51:16 +08:00
boris d7c1674c6c 修正弱市缩仓阈值语义 2026-06-18 11:39:13 +08:00
boris 4f39ac7dfe 修复平台策略选股表达式口径 2026-06-18 11:12:12 +08:00
boris 8d24badcf2 修正持仓盈亏展示口径 2026-06-17 21:03:45 +08:00
boris 6c7f7130cf 修复平台策略金额买入预算 2026-06-17 19:35:19 +08:00
boris d8b6130428 修复平台策略执行行情投影判断 2026-06-17 19:08:19 +08:00
boris dae573e318 修复AiQuant补位买入预算口径 2026-06-17 18:21:35 +08:00
boris 674e4b0b14 修复非周期补买候选失败中断 2026-06-17 12:15:43 +08:00
boris 828b55c747 共享因子候选索引内存 2026-06-17 10:05:55 +08:00
boris 596d64280b 优化行情序列内存结构 2026-06-17 09:55:31 +08:00
boris 1683d875a0 修正平台策略延迟卖出预算口径 2026-06-17 09:04:50 +08:00
boris ed4658ccd0 修正平台策略选股和弱市调仓口径 2026-06-17 07:40:27 +08:00
boris bc39df0ee5 修复FIDC策略滑点配置解析 2026-06-17 05:31:46 +08:00
boris 70695d8c92 恢复点时刻tick加载语义 2026-06-16 15:35:54 +08:00
boris 0533e2db3a 避免已预取tick重复懒加载 2026-06-16 15:18:43 +08:00
boris 716149c06c 修正平台策略滚动因子优先级 2026-06-16 14:49:41 +08:00
boris 0628dd528a 修复止损卖出受限时的目标仓位预判 2026-06-16 10:20:55 +08:00
boris e146ad6e7d 补充涨停买入撮合约束测试 2026-06-16 09:15:13 +08:00
boris cf2c4fd179 修正AiQuant补仓预算口径 2026-06-16 08:38:19 +08:00
boris 6ba61ef80b 修正跌停止损预判调仓口径 2026-06-16 08:22:15 +08:00
boris e45f990487 修正平台目标调仓执行口径 2026-06-16 08:06:19 +08:00
boris 8e6c912a07 修正AiQuant目标市值估值口径 2026-06-16 07:49:10 +08:00
boris 9a411f2403 修正平台策略弱市调仓顺序 2026-06-16 07:23:51 +08:00
boris d2c65c91b7 修正平台策略投影撮合价口径 2026-06-16 06:22:40 +08:00
boris 5078aec840 修正AiQuant盘中组合估值口径 2026-06-16 06:04:37 +08:00
boris df949ab8ee 修正AiQuant兼容买入数量语义 2026-06-16 05:45:15 +08:00
boris 2e036783bf 修正止损前弱市补仓顺序 2026-06-16 00:29:01 +08:00
boris ff145300b4 修正执行价quote多时间加载 2026-06-16 00:05:34 +08:00
boris c2de9d8e83 修正AiQuant目标市值持仓估值 2026-06-15 20:40:31 +08:00
boris baeda3773d 修正调仓持仓报价预加载语义 2026-06-15 20:29:14 +08:00
boris 725f1845d9 修复涨跌停最终执行价约束 2026-06-15 20:04:42 +08:00
boris e0949a0eaa 统一表达式策略涨跌停触价口径 2026-06-15 19:33:40 +08:00
boris 5d2bcd8366 修正A股涨跌停严格触价规则 2026-06-15 18:50:10 +08:00
boris 5181d0e403 修正平台策略费用和表达式口径 2026-06-15 18:03:21 +08:00
boris 1c31fa80d2 修复AiQuant策略表达式回测执行语义 2026-06-15 11:16:04 +08:00
boris d3d08276ae 修正AiQuant多时间调仓语义 2026-06-14 02:37:26 +08:00
boris 80b34280c2 修正滑点成交后的持仓估值 2026-06-14 02:09:44 +08:00
boris 0cfb7625bf 修正回测指标和成交时间口径 2026-06-14 01:08:29 +08:00
boris 4c3653e009 修正AiQuant兼容回测盘中估值口径 2026-06-13 23:32:24 +08:00
boris 9512a5dd2f 修正点时刻执行报价口径 2026-06-13 21:55:08 +08:00
boris 4f5e3f7162 统一调度时刻使用已知tick 2026-06-13 21:41:37 +08:00
boris 89c2ff58f8 修正点时刻回测使用最新tick 2026-06-13 21:27:21 +08:00
boris 0813ce3ffb 修正目标市值盘中估值口径 2026-06-13 21:09:38 +08:00
boris a030554ab6 修正平台策略滚动量能口径 2026-06-13 20:48:52 +08:00
boris e1d36fc0c7 修正平台表达式回测口径 2026-06-13 20:01:24 +08:00
boris 0dca8e0eff 完善策略调度执行价校验 2026-06-13 15:26:56 +08:00
boris 4cf90d83a3 修复执行价索引和平台表达式回退 2026-06-12 23:46:44 +08:00
boris 9b4462f880 修正策略止盈止损和补仓投影 2026-05-28 18:40:32 +08:00
boris 87b7b2642d 修正策略投影tick依赖 2026-05-28 18:17:33 +08:00
boris 5eee5c7c63 缩小tick查询到实际订单 2026-05-28 17:45:00 +08:00
boris c6dc1d1474 修正回测执行时tick取价 2026-05-28 17:32:40 +08:00
boris 8c86918970 修正微盘买入预算与表达式性能 2026-05-28 10:39:43 +08:00
boris 200d5d1f41 完善平台策略回测撮合和滑点 2026-05-28 08:59:14 +08:00
boris 3499d4aa74 chore: 更新 fidc-backtest-engine - 2026-05-22 2026-05-22 17:22:33 +08:00
43 changed files with 31177 additions and 4265 deletions
Generated
-10
View File
@@ -37,16 +37,6 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bt-demo"
version = "0.1.0"
dependencies = [
"chrono",
"fidc-core",
"serde",
"serde_json",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
-1
View File
@@ -1,7 +1,6 @@
[workspace]
members = [
"crates/fidc-core",
"crates/bt-demo",
]
resolver = "2"
+4 -42
View File
@@ -4,7 +4,7 @@
## 当前能力
- 日频分钟、tick 级策略生命周期与确定性回放。
- 日频分钟执行价策略生命周期与确定性回放。
- A 股行情、估值、因子、基准、候选资格、涨跌停触达、停牌和 ST 标记。
- 平台策略 DSL 与 `StrategyContext` 数据 API,不暴露非平台脚本语法。
- `BacktestConfig` 支持起止日期、初始资金、决策滞后、执行价格字段、基准代码。
@@ -22,7 +22,6 @@
.
├── Cargo.toml
├── crates
│ ├── bt-demo
│ └── fidc-core
│ └── src
│ ├── broker.rs
@@ -37,7 +36,6 @@
│ ├── scheduler.rs
│ ├── strategy.rs
│ └── strategy_ai.rs
├── data/demo
└── docs
```
@@ -51,7 +49,7 @@
- `futures`: 期货账户、合约参数、保证金、手续费和多空持仓。
- `rules`: 中国市场交易规则和风控校验。
- `broker`: 股票撮合、订单簿、滑点、成交量约束、限价和显式订单执行。
- `scheduler`: 日、周、月分钟、tick 调度规则。
- `scheduler`: 日、周、月分钟调度规则。
- `platform_expr_strategy`: 平台 DSL 解析后的表达式策略执行模型。
- `strategy`: 策略 trait、内置策略和运行时视图。
- `strategy_ai`: 策略 AI 手册、提示词生成和数据库字段目录合并。
@@ -96,45 +94,9 @@
## 运行方式
默认运行仓库 demo 数据:
`fidc-backtest-engine` 不再维护本地 CSV demo、partitioned snapshot 目录或导出融合表作为运行入口。生产和集成回测由 `fidc-backtest-service` runner 创建 `DataSet`,数据来自 Strategy Factory Source Lake 的 Arrow/Parquet、manifest/data_epoch 缓存和运行时逻辑视图。
```bash
cargo run --bin bt-demo
```
运行平台内置微盘策略:
```bash
FIDC_BT_STRATEGY=omni-microcap \
FIDC_BT_SIGNAL_SYMBOL=000001.SH \
cargo run --release --bin bt-demo
```
接入真实分区 snapshot 目录:
```bash
FIDC_BT_DATA_LAYOUT=partitioned \
FIDC_BT_DATA_DIR=/path/to/snapshots \
FIDC_BT_SIGNAL_SYMBOL=000001.SH \
cargo run --bin bt-demo
```
约定目录结构:
```text
snapshots/
├── instruments.csv
├── benchmark/YYYY/MM/*.csv
├── market/YYYY/MM/*.csv
├── factors/YYYY/MM/*.csv
└── candidates/YYYY/MM/*.csv
```
运行后默认生成:
- `output/demo/equity_curve.csv`
- `output/demo/trades.csv`
- `output/demo/holdings_summary.csv`
本仓库只保留核心库构建和测试入口:
## 测试与构建
-12
View File
@@ -1,12 +0,0 @@
[package]
name = "bt-demo"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
chrono = { workspace = true }
fidc-core = { path = "../fidc-core" }
serde = { workspace = true }
serde_json = "1"
-567
View File
@@ -1,567 +0,0 @@
use std::collections::BTreeSet;
use std::error::Error;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{NaiveDate, NaiveTime};
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, ChinaAShareCostModel,
ChinaEquityRuleHooks, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DailyEquityPoint,
DataSet, FillEvent, HoldingSummary, OmniMicroCapConfig, OmniMicroCapStrategy, PortfolioState,
PriceField, Strategy, StrategyContext,
};
use serde_json::json;
fn main() -> Result<(), Box<dyn Error>> {
let root = workspace_root();
let data_dir = std::env::var("FIDC_BT_DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| root.join("data/demo"));
let data_layout = std::env::var("FIDC_BT_DATA_LAYOUT").unwrap_or_else(|_| "flat".to_string());
let output_dir = std::env::var("FIDC_BT_OUTPUT_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| root.join("output/demo"));
let json_output = std::env::var("FIDC_BT_JSON")
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
.unwrap_or(false);
fs::create_dir_all(&output_dir)?;
let data = if data_layout == "partitioned" {
DataSet::from_partitioned_dir(&data_dir)?
} else {
DataSet::from_csv_dir(&data_dir)?
};
let strategy_name =
std::env::var("FIDC_BT_STRATEGY").unwrap_or_else(|_| "cn-smallcap-rotation".to_string());
let debug_date = std::env::var("FIDC_BT_DEBUG_DATE")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
.transpose()?;
let decision_lag = std::env::var("FIDC_BT_DECISION_LAG")
.ok()
.and_then(|value| value.parse::<usize>().ok());
let execution_price =
std::env::var("FIDC_BT_EXECUTION_PRICE")
.ok()
.map(|value| match value.as_str() {
"close" => PriceField::Close,
"last" => PriceField::Last,
_ => PriceField::Open,
});
let initial_cash = std::env::var("FIDC_BT_INITIAL_CASH")
.ok()
.and_then(|value| value.parse::<f64>().ok());
let start_date = std::env::var("FIDC_BT_START_DATE")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
.transpose()?;
let end_date = std::env::var("FIDC_BT_END_DATE")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
.transpose()?;
let mut config = BacktestConfig {
initial_cash: initial_cash.unwrap_or(1_000_000.0),
benchmark_code: data.benchmark_code().to_string(),
start_date,
end_date,
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
};
let result = match strategy_name.as_str() {
"cn-smallcap-rotation" | "cn-dyn-smallcap-band" => {
let mut strategy_cfg = if strategy_name == "cn-dyn-smallcap-band" {
CnSmallCapRotationConfig::cn_dyn_smallcap_band()
} else {
CnSmallCapRotationConfig::demo()
};
if strategy_cfg.strategy_name == "cn-smallcap-rotation" {
strategy_cfg.base_index_level = 3000.0;
strategy_cfg.base_cap_floor = 38.0;
strategy_cfg.cap_span = 25.0;
}
if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") {
if !signal_symbol.trim().is_empty() {
strategy_cfg.signal_symbol = Some(signal_symbol);
}
}
config.decision_lag_trading_days = decision_lag.unwrap_or(1);
config.execution_price_field = execution_price.unwrap_or(PriceField::Open);
let strategy = CnSmallCapRotationStrategy::new(strategy_cfg);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
config.execution_price_field,
);
let mut engine = BacktestEngine::new(data, strategy, broker, config);
engine.run()?
}
"aiquant-v104" => {
let mut strategy_cfg = OmniMicroCapConfig::aiquant_v104();
if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") {
if !signal_symbol.trim().is_empty() {
strategy_cfg.benchmark_signal_symbol = signal_symbol;
}
}
if let Some(date) = debug_date {
let eligible = data.eligible_universe_on(date);
eprintln!(
"DEBUG eligible_universe_on {} count={}",
date,
eligible.len()
);
for row in eligible.iter().take(20) {
eprintln!(" {} {:.6}", row.symbol, row.market_cap_bn);
}
let mut debug_strategy = OmniMicroCapStrategy::new(strategy_cfg.clone());
let debug_subscriptions = BTreeSet::new();
let decision = debug_strategy.on_day(&StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 1,
data: &data,
portfolio: &PortfolioState::new(20_000.0),
futures_account: None,
open_orders: &[],
dynamic_universe: None,
subscriptions: &debug_subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
})?;
eprintln!("DEBUG notes={:?}", decision.notes);
eprintln!("DEBUG diagnostics={:?}", decision.diagnostics);
return Ok(());
}
config.decision_lag_trading_days = decision_lag.unwrap_or(1);
config.execution_price_field = execution_price.unwrap_or(PriceField::Close);
config.initial_cash = initial_cash.unwrap_or(20_000.0);
let strategy = OmniMicroCapStrategy::new(strategy_cfg);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
config.execution_price_field,
);
let mut engine = BacktestEngine::new(data, strategy, broker, config);
engine.run()?
}
_ => {
let mut strategy_cfg = OmniMicroCapConfig::omni_microcap();
if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") {
if !signal_symbol.trim().is_empty() {
strategy_cfg.benchmark_signal_symbol = signal_symbol;
}
}
if let Some(date) = debug_date {
let eligible = data.eligible_universe_on(date);
eprintln!(
"DEBUG eligible_universe_on {} count={}",
date,
eligible.len()
);
for row in eligible.iter().take(20) {
eprintln!(" {} {:.6}", row.symbol, row.market_cap_bn);
}
let mut debug_strategy = OmniMicroCapStrategy::new(strategy_cfg.clone());
let debug_subscriptions = BTreeSet::new();
let decision = debug_strategy.on_day(&StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 1,
data: &data,
portfolio: &PortfolioState::new(10_000_000.0),
futures_account: None,
open_orders: &[],
dynamic_universe: None,
subscriptions: &debug_subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
})?;
eprintln!("DEBUG notes={:?}", decision.notes);
eprintln!("DEBUG diagnostics={:?}", decision.diagnostics);
return Ok(());
}
config.decision_lag_trading_days = decision_lag.unwrap_or(0);
config.execution_price_field = execution_price.unwrap_or(PriceField::Last);
config.initial_cash = initial_cash.unwrap_or(10_000_000.0);
let strategy = OmniMicroCapStrategy::new(strategy_cfg);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
config.execution_price_field,
)
.with_intraday_execution_start_time(
NaiveTime::parse_from_str("10:18:00", "%H:%M:%S").expect("valid 10:18:00"),
)
.with_volume_limit(false)
.with_inactive_limit(false)
.with_liquidity_limit(false);
let mut engine = BacktestEngine::new(data, strategy, broker, config);
engine.run()?
}
};
write_equity_curve_csv(&output_dir.join("equity_curve.csv"), &result.equity_curve)?;
write_trades_csv(&output_dir.join("trades.csv"), &result.fills)?;
write_holdings_csv(
&output_dir.join("holdings_summary.csv"),
&result.holdings_summary,
)?;
let summary = build_summary(
&result.strategy_name,
&result.equity_curve,
&result.fills,
&result.holdings_summary,
result.benchmark_series.last(),
&output_dir,
);
print_summary(&summary, &result.equity_curve, &result.holdings_summary);
println!("Artifacts written under {}", output_dir.display());
if json_output {
println!("{}", serde_json::to_string(&summary)?);
}
Ok(())
}
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("workspace root")
}
fn write_equity_curve_csv(path: &Path, rows: &[DailyEquityPoint]) -> Result<(), Box<dyn Error>> {
let mut file = fs::File::create(path)?;
writeln!(
file,
"date,cash,market_value,total_equity,benchmark_close,benchmark_prev_close,notes,diagnostics"
)?;
for row in rows {
writeln!(
file,
"{},{:.2},{:.2},{:.2},{:.2},{:.2},{},{}",
row.date,
row.cash,
row.market_value,
row.total_equity,
row.benchmark_close,
row.benchmark_prev_close,
sanitize_csv_field(&row.notes),
sanitize_csv_field(&row.diagnostics),
)?;
}
Ok(())
}
fn write_trades_csv(path: &Path, rows: &[FillEvent]) -> Result<(), Box<dyn Error>> {
let mut file = fs::File::create(path)?;
writeln!(
file,
"date,symbol,side,quantity,price,gross_amount,commission,stamp_tax,net_cash_flow,reason"
)?;
for row in rows {
writeln!(
file,
"{},{},{:?},{},{:.2},{:.2},{:.2},{:.2},{:.2},{}",
row.date,
row.symbol,
row.side,
row.quantity,
row.price,
row.gross_amount,
row.commission,
row.stamp_tax,
row.net_cash_flow,
sanitize_csv_field(&row.reason),
)?;
}
Ok(())
}
fn write_holdings_csv(path: &Path, rows: &[HoldingSummary]) -> Result<(), Box<dyn Error>> {
let mut file = fs::File::create(path)?;
writeln!(
file,
"date,symbol,quantity,average_cost,last_price,market_value,unrealized_pnl,realized_pnl"
)?;
for row in rows {
writeln!(
file,
"{},{},{},{:.2},{:.2},{:.2},{:.2},{:.2}",
row.date,
row.symbol,
row.quantity,
row.average_cost,
row.last_price,
row.market_value,
row.unrealized_pnl,
row.realized_pnl,
)?;
}
Ok(())
}
fn sanitize_csv_field(text: &str) -> String {
text.replace(',', ";")
}
#[derive(Debug, serde::Serialize)]
struct RunSummary {
strategy: String,
start_date: String,
end_date: String,
start_equity: f64,
final_equity: f64,
total_return: f64,
trade_count: usize,
holding_count: usize,
benchmark_code: Option<String>,
benchmark_last_close: Option<f64>,
output_dir: String,
diagnostics: serde_json::Value,
warnings: Vec<String>,
equity_preview: Vec<serde_json::Value>,
trades_preview: Vec<serde_json::Value>,
}
fn build_summary(
strategy_name: &str,
equity_curve: &[DailyEquityPoint],
fills: &[FillEvent],
holdings: &[HoldingSummary],
benchmark_last: Option<&BenchmarkSnapshot>,
output_dir: &Path,
) -> RunSummary {
let first = equity_curve.first();
let last = equity_curve.last();
let start_equity = first.map(|row| row.total_equity).unwrap_or_default();
let final_equity = last.map(|row| row.total_equity).unwrap_or_default();
let total_return = if start_equity.abs() < f64::EPSILON {
0.0
} else {
(final_equity / start_equity) - 1.0
};
let diagnostics = extract_diagnostics(equity_curve);
let warnings = build_warnings(fills, holdings, &diagnostics);
let equity_preview = equity_curve
.iter()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|row| {
json!({
"date": row.date.to_string(),
"cash": row.cash,
"marketValue": row.market_value,
"totalEquity": row.total_equity,
"benchmarkClose": row.benchmark_close,
"benchmarkPrevClose": row.benchmark_prev_close,
"notes": row.notes,
"diagnostics": row.diagnostics,
})
})
.collect::<Vec<_>>();
let trades_preview = fills
.iter()
.rev()
.take(10)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|row| {
json!({
"date": row.date.to_string(),
"symbol": row.symbol,
"side": format!("{:?}", row.side),
"quantity": row.quantity,
"price": row.price,
"grossAmount": row.gross_amount,
"netCashFlow": row.net_cash_flow,
"reason": row.reason,
})
})
.collect::<Vec<_>>();
RunSummary {
strategy: strategy_name.to_string(),
start_date: first.map(|row| row.date.to_string()).unwrap_or_default(),
end_date: last.map(|row| row.date.to_string()).unwrap_or_default(),
start_equity,
final_equity,
total_return,
trade_count: fills.len(),
holding_count: holdings.len(),
benchmark_code: benchmark_last.map(|row| row.benchmark.clone()),
benchmark_last_close: benchmark_last.map(|row| row.close),
output_dir: output_dir.display().to_string(),
diagnostics,
warnings,
equity_preview,
trades_preview,
}
}
fn extract_diagnostics(equity_curve: &[DailyEquityPoint]) -> serde_json::Value {
let last = equity_curve.last();
let text = last.map(|row| row.diagnostics.as_str()).unwrap_or("");
let notes = last.map(|row| row.notes.as_str()).unwrap_or("");
let mut map = serde_json::Map::new();
map.insert("latestText".to_string(), json!(text));
map.insert("latestNotes".to_string(), json!(notes));
map.insert("equityPointCount".to_string(), json!(equity_curve.len()));
for part in text.split(" | ") {
let part = part.trim();
if let Some(rest) = part.strip_prefix("selection_diag ") {
for token in rest.split_whitespace() {
if let Some((k, v)) = token.split_once('=') {
map.insert(k.to_string(), parse_diag_value(v));
}
}
} else if let Some(rest) = part.strip_prefix("selection_band ") {
for token in rest.split_whitespace() {
if let Some((k, v)) = token.split_once('=') {
map.insert(k.to_string(), parse_diag_value(v));
}
}
} else if let Some(rest) =
part.strip_prefix("market_cap_missing likely blocks selection; sample=")
{
map.insert(
"marketCapMissingSample".to_string(),
json!(
rest.split('|')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
),
);
} else if let Some(rest) = part.strip_prefix("selection_rejections sample=") {
map.insert(
"selectionRejectionsSample".to_string(),
json!(
rest.split(" | ")
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
),
);
} else if let Some(rest) = part.strip_prefix("ma_filter_rejections sample=") {
map.insert(
"maFilterRejectionsSample".to_string(),
json!(
rest.split('|')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
),
);
} else if let Some(rest) = part.strip_prefix("selected=") {
map.insert("selectedLine".to_string(), json!(rest));
}
}
serde_json::Value::Object(map)
}
fn parse_diag_value(value: &str) -> serde_json::Value {
if let Ok(v) = value.parse::<i64>() {
return json!(v);
}
if let Ok(v) = value.parse::<f64>() {
return json!(v);
}
json!(value)
}
fn build_warnings(
fills: &[FillEvent],
holdings: &[HoldingSummary],
diagnostics: &serde_json::Value,
) -> Vec<String> {
let mut warnings = Vec::new();
if fills.is_empty() {
warnings.push("本次回测没有产生任何成交。".to_string());
}
if holdings.is_empty() {
warnings.push("期末没有持仓。".to_string());
}
let selected_after_ma_is_empty = diagnostics
.get("selected_after_ma")
.and_then(|v| v.as_i64())
.unwrap_or(0)
== 0;
if selected_after_ma_is_empty && fills.is_empty() && holdings.is_empty() {
warnings
.push("最终没有股票通过完整选股链路,结果为空时请优先查看 diagnostics。".to_string());
}
if diagnostics
.get("market_cap_missing_count")
.and_then(|v| v.as_i64())
.unwrap_or(0)
> 0
{
warnings.push("存在 market_cap 缺失或非正值,当前会直接阻断该股票进入候选池。".to_string());
}
warnings
}
fn print_summary(
summary: &RunSummary,
equity_curve: &[DailyEquityPoint],
holdings: &[HoldingSummary],
) {
if equity_curve.is_empty() {
println!("No equity curve points generated.");
return;
}
println!("Strategy: {}", summary.strategy);
println!("Start equity: {:.2}", summary.start_equity);
println!("Final equity: {:.2}", summary.final_equity);
println!("Total return: {:.2}%", summary.total_return * 100.0);
println!("Trades: {}", summary.trade_count);
println!("Final holdings: {}", summary.holding_count);
if let (Some(code), Some(close)) = (&summary.benchmark_code, summary.benchmark_last_close) {
println!("Benchmark last close: {} {:.2}", code, close);
}
println!("Recent equity points:");
for point in equity_curve
.iter()
.rev()
.take(3)
.collect::<Vec<_>>()
.into_iter()
.rev()
{
println!(
" {} equity {:.2} cash {:.2} mv {:.2}",
point.date, point.total_equity, point.cash, point.market_value
);
}
if holdings.is_empty() {
println!("No holdings at the end of the demo run.");
} else {
println!("Ending holdings:");
for holding in holdings {
println!(
" {} qty {} mv {:.2} pnl {:.2}",
holding.symbol, holding.quantity, holding.market_value, holding.unrealized_pnl
);
}
}
}
File diff suppressed because it is too large Load Diff
+73 -8
View File
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
use chrono::NaiveDate;
use crate::events::OrderSide;
use crate::risk_control::TradingConstraintConfig;
pub const STOCK_PIT_TAX_CHANGE_DATE: (i32, u32, u32) = (2023, 8, 28);
@@ -38,6 +39,7 @@ pub struct ChinaAShareCostModel {
pub commission_rate: f64,
pub stamp_tax_rate_before_change: f64,
pub stamp_tax_rate_after_change: f64,
pub stamp_tax_change_date: NaiveDate,
pub minimum_commission: f64,
}
@@ -47,20 +49,32 @@ impl Default for ChinaAShareCostModel {
commission_rate: 0.0008,
stamp_tax_rate_before_change: 0.001,
stamp_tax_rate_after_change: 0.0005,
stamp_tax_change_date: default_stamp_tax_change_date(),
minimum_commission: 5.0,
}
}
}
impl ChinaAShareCostModel {
pub fn aiquant_rqalpha_default() -> Self {
pub fn aiquant_default() -> Self {
Self {
commission_rate: 0.0003,
stamp_tax_rate_before_change: 0.0005,
stamp_tax_rate_after_change: 0.0005,
..Self::default()
}
}
pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self {
Self {
commission_rate: config.commission_rate,
stamp_tax_rate_before_change: config.stamp_tax_rate_before_change,
stamp_tax_rate_after_change: config.stamp_tax_rate_after_change,
stamp_tax_change_date: config.stamp_tax_change_date,
minimum_commission: config.minimum_commission,
}
}
pub fn commission_for(&self, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 {
return 0.0;
@@ -69,13 +83,7 @@ impl ChinaAShareCostModel {
}
pub fn stamp_tax_rate_for(&self, date: NaiveDate) -> f64 {
let change_date = NaiveDate::from_ymd_opt(
STOCK_PIT_TAX_CHANGE_DATE.0,
STOCK_PIT_TAX_CHANGE_DATE.1,
STOCK_PIT_TAX_CHANGE_DATE.2,
)
.expect("valid pit tax change date");
if date < change_date {
if date < self.stamp_tax_change_date {
self.stamp_tax_rate_before_change
} else {
self.stamp_tax_rate_after_change
@@ -127,6 +135,15 @@ impl ChinaAShareCostModel {
}
}
fn default_stamp_tax_change_date() -> NaiveDate {
NaiveDate::from_ymd_opt(
STOCK_PIT_TAX_CHANGE_DATE.0,
STOCK_PIT_TAX_CHANGE_DATE.1,
STOCK_PIT_TAX_CHANGE_DATE.2,
)
.expect("valid pit tax change date")
}
impl CostModel for ChinaAShareCostModel {
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost {
if gross_amount <= 0.0 {
@@ -169,3 +186,51 @@ impl CostModel for ChinaAShareCostModel {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aiquant_default_matches_current_backtest_fee_model() {
let model = ChinaAShareCostModel::aiquant_default();
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date");
assert!((model.commission_for(248_059.812) - 74.4179436).abs() < 1e-9);
assert!(
(model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.8735035).abs() < 1e-9
);
}
#[test]
fn cost_model_can_use_configurable_stamp_tax_change_date() {
let config = TradingConstraintConfig {
commission_rate: 0.0003,
minimum_commission: 5.0,
stamp_tax_rate_before_change: 0.002,
stamp_tax_rate_after_change: 0.001,
stamp_tax_change_date: NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"),
..TradingConstraintConfig::default()
};
let model = ChinaAShareCostModel::from_trading_constraints(config);
assert!(
(model.stamp_tax_for(
NaiveDate::from_ymd_opt(2025, 1, 9).expect("valid date"),
OrderSide::Sell,
10_000.0
) - 20.0)
.abs()
< 1e-9
);
assert!(
(model.stamp_tax_for(
NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"),
OrderSide::Sell,
10_000.0
) - 10.0)
.abs()
< 1e-9
);
}
}
+1121 -890
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+45 -6
View File
@@ -23,6 +23,33 @@ mod date_format {
}
}
mod optional_date_format {
use chrono::NaiveDate;
use serde::{self, Deserialize, Deserializer, Serializer};
const FORMAT: &str = "%Y-%m-%d";
pub fn serialize<S>(date: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match date {
Some(date) => serializer.serialize_some(&date.format(FORMAT).to_string()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
value
.map(|text| NaiveDate::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom))
.transpose()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum OrderSide {
Buy,
@@ -63,6 +90,12 @@ impl OrderStatus {
pub struct OrderEvent {
#[serde(with = "date_format")]
pub date: NaiveDate,
#[serde(default, with = "optional_date_format")]
pub decision_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub order_created_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub execution_date: Option<NaiveDate>,
#[serde(default)]
pub order_id: Option<u64>,
pub symbol: String,
@@ -77,6 +110,12 @@ pub struct OrderEvent {
pub struct FillEvent {
#[serde(with = "date_format")]
pub date: NaiveDate,
#[serde(default, with = "optional_date_format")]
pub decision_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub order_created_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub execution_date: Option<NaiveDate>,
#[serde(default)]
pub order_id: Option<u64>,
pub symbol: String,
@@ -123,9 +162,9 @@ pub enum ProcessEventKind {
PreBar,
Bar,
PostBar,
PreTick,
Tick,
PostTick,
PreMinute,
Minute,
PostMinute,
PreScheduled,
PostScheduled,
PreOnDay,
@@ -165,9 +204,9 @@ impl ProcessEventKind {
Self::PreBar => "pre_bar",
Self::Bar => "bar",
Self::PostBar => "post_bar",
Self::PreTick => "pre_tick",
Self::Tick => "tick",
Self::PostTick => "post_tick",
Self::PreMinute => "pre_minute",
Self::Minute => "minute",
Self::PostMinute => "post_minute",
Self::PreScheduled => "pre_scheduled",
Self::PostScheduled => "post_scheduled",
Self::PreOnDay => "pre_on_day",
+12
View File
@@ -746,6 +746,9 @@ impl FuturesAccountState {
);
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id,
symbol: intent.symbol,
side,
@@ -823,6 +826,9 @@ impl FuturesAccountState {
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier;
report.fill_events.push(FillEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id,
symbol: intent.symbol.clone(),
side,
@@ -889,6 +895,9 @@ impl FuturesAccountState {
});
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id,
symbol: intent.symbol,
side,
@@ -915,6 +924,9 @@ impl FuturesAccountState {
);
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id,
symbol: intent.symbol,
side,
+6 -1
View File
@@ -40,10 +40,15 @@ impl Instrument {
.is_some_and(|delisted_at| delisted_at < date)
}
pub fn is_delisted_on_or_before(&self, date: NaiveDate) -> bool {
self.delisted_at
.is_some_and(|delisted_at| delisted_at <= date)
}
pub fn is_active_on(&self, date: NaiveDate) -> bool {
self.listed_at.is_none_or(|listed_at| listed_at <= date)
&& !self.is_delisted_before(date)
&& !self.status.eq_ignore_ascii_case("inactive")
&& !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none())
}
}
+16 -7
View File
@@ -12,13 +12,17 @@ pub mod platform_expr_strategy;
pub mod platform_runtime_schema;
pub mod platform_strategy_spec;
pub mod portfolio;
pub mod risk_control;
pub mod rules;
pub mod scheduler;
pub mod strategy;
pub mod strategy_ai;
pub mod universe;
pub use broker::{BrokerExecutionReport, BrokerSimulator, MatchingType, SlippageModel};
pub use broker::{
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, RebalanceCashMode,
SlippageModel,
};
pub use calendar::TradingCalendar;
pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
pub use data::{
@@ -31,7 +35,7 @@ pub use data::{
pub use engine::{
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
BacktestResult, DailyEquityPoint, FuturesValidationConfig,
BacktestResult, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
};
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
pub use events::{
@@ -48,8 +52,8 @@ pub use metrics::{BacktestMetrics, compute_backtest_metrics};
pub use platform_expr_strategy::{
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
PlatformUniverseActionKind,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformSelectionQuotePlan,
PlatformTradeAction, PlatformUniverseActionKind,
};
pub use platform_runtime_schema::{
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
@@ -66,6 +70,10 @@ pub use platform_strategy_spec::{
StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value,
};
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
pub use risk_control::{
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
StaticRiskRuleConfig, TradingConstraintConfig,
};
pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck};
pub use scheduler::{
ScheduleFrequency, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
@@ -77,9 +85,10 @@ pub use strategy::{
};
pub use strategy_ai::{
ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction,
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiManual,
StrategyAiOptimizeRequest, build_generation_prompt, build_optimization_prompt,
built_in_strategy_manual, merge_catalog_into_manual, render_manual_markdown,
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiHoldingCountContract,
StrategyAiManual, StrategyAiOptimizeRequest, build_generation_prompt,
build_optimization_prompt, built_in_strategy_manual, merge_catalog_into_manual,
render_manual_markdown,
};
pub use universe::{
BandRegime, DynamicMarketCapBandSelector, SelectionContext, SelectionDiagnostics,
File diff suppressed because it is too large Load Diff
@@ -135,11 +135,12 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
"free_float_cap",
"pe_ttm",
"volume",
"tick_volume",
"minute_volume",
"bid1_volume",
"ask1_volume",
"turnover_ratio",
"effective_turnover_ratio",
"up_days_stock",
"open",
"high",
"low",
@@ -154,7 +155,9 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
"round_lot",
"paused",
"is_st",
"is_star_st",
"is_kcb",
"is_bjse",
"is_one_yuan",
"is_new_listing",
"allow_buy",
@@ -327,6 +330,7 @@ mod tests {
"avg_cost",
"current_price",
"stock_ma_short",
"up_days_stock",
] {
assert!(
names.contains(required),
File diff suppressed because it is too large Load Diff
+161 -20
View File
@@ -20,6 +20,7 @@ pub struct Position {
pub average_cost: f64,
pub last_price: f64,
pub realized_pnl: f64,
realized_entry_pnl: f64,
pub trading_pnl: f64,
pub position_pnl: f64,
pub dividend_receivable: f64,
@@ -44,6 +45,7 @@ impl Position {
average_cost: 0.0,
last_price: 0.0,
realized_pnl: 0.0,
realized_entry_pnl: 0.0,
trading_pnl: 0.0,
position_pnl: 0.0,
dividend_receivable: 0.0,
@@ -66,6 +68,16 @@ impl Position {
}
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
self.buy_with_mark_price(date, quantity, price, price);
}
pub fn buy_with_mark_price(
&mut self,
date: NaiveDate,
quantity: u32,
execution_price: f64,
mark_price: f64,
) {
if quantity == 0 {
return;
}
@@ -73,19 +85,28 @@ impl Position {
self.lots.push(PositionLot {
acquired_date: date,
quantity,
entry_price: price,
price,
entry_price: execution_price,
price: execution_price,
});
self.quantity += quantity;
self.last_price = price;
self.last_price = normalized_mark_price(mark_price, execution_price);
self.day_trade_quantity_delta += quantity as i32;
self.day_buy_quantity += quantity;
self.day_buy_value += price * quantity as f64;
self.day_buy_value += execution_price * quantity as f64;
self.recalculate_average_cost();
self.refresh_day_pnl();
}
pub fn sell(&mut self, quantity: u32, price: f64) -> Result<f64, String> {
self.sell_with_mark_price(quantity, price, price)
}
pub fn sell_with_mark_price(
&mut self,
quantity: u32,
execution_price: f64,
mark_price: f64,
) -> Result<f64, String> {
if quantity > self.quantity {
return Err(format!(
"sell quantity {} exceeds current quantity {} for {}",
@@ -95,6 +116,8 @@ impl Position {
let mut remaining = quantity;
let mut realized = 0.0;
let mut realized_entry = 0.0;
let average_cost_before_sell = self.average_cost;
while remaining > 0 {
let Some(first_lot) = self.lots.first_mut() else {
@@ -102,7 +125,8 @@ impl Position {
};
let lot_sell = remaining.min(first_lot.quantity);
realized += (price - first_lot.price) * lot_sell as f64;
realized += (execution_price - first_lot.price) * lot_sell as f64;
realized_entry += (execution_price - first_lot.entry_price) * lot_sell as f64;
first_lot.quantity -= lot_sell;
remaining -= lot_sell;
@@ -112,12 +136,19 @@ impl Position {
}
self.quantity -= quantity;
self.last_price = price;
self.last_price = normalized_mark_price(mark_price, execution_price);
self.realized_pnl += realized;
self.realized_entry_pnl += realized_entry;
self.day_trade_quantity_delta -= quantity as i32;
self.day_sell_quantity += quantity;
self.day_sell_value += price * quantity as f64;
self.day_sell_value += execution_price * quantity as f64;
if self.quantity == 0 {
self.recalculate_average_cost();
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
self.average_cost = average_cost_before_sell;
} else {
self.recalculate_average_cost();
}
self.refresh_day_pnl();
Ok(realized)
}
@@ -138,10 +169,21 @@ impl Position {
(self.last_price - self.average_cost) * self.quantity as f64
}
pub fn unrealized_entry_pnl(&self) -> f64 {
let Some(avg_price) = self.average_entry_price() else {
return 0.0;
};
(self.last_price - avg_price) * self.quantity as f64
}
pub fn pnl(&self) -> f64 {
self.realized_pnl + self.unrealized_pnl()
}
pub fn entry_pnl(&self) -> f64 {
self.realized_entry_pnl + self.unrealized_entry_pnl()
}
pub fn day_start_quantity(&self) -> u32 {
self.day_start_quantity
}
@@ -270,15 +312,31 @@ impl Position {
}
pub fn apply_cash_dividend(&mut self, dividend_per_share: f64) -> f64 {
self.apply_cash_dividend_internal(dividend_per_share, true)
}
pub fn apply_cash_dividend_preserve_cost_basis(&mut self, dividend_per_share: f64) -> f64 {
self.apply_cash_dividend_internal(dividend_per_share, false)
}
fn apply_cash_dividend_internal(
&mut self,
dividend_per_share: f64,
adjust_cost_basis: bool,
) -> f64 {
if self.quantity == 0 || !dividend_per_share.is_finite() || dividend_per_share == 0.0 {
return 0.0;
}
for lot in &mut self.lots {
lot.entry_price -= dividend_per_share;
if adjust_cost_basis {
lot.price -= dividend_per_share;
}
}
if adjust_cost_basis {
self.average_cost -= dividend_per_share;
}
self.last_price -= dividend_per_share;
let cash_delta = self.quantity as f64 * dividend_per_share;
self.day_dividend_cash += cash_delta;
@@ -340,6 +398,14 @@ impl Position {
}
}
fn normalized_mark_price(mark_price: f64, fallback: f64) -> f64 {
if mark_price.is_finite() && mark_price > 0.0 {
mark_price
} else {
fallback
}
}
#[derive(Debug, Clone)]
pub struct PortfolioState {
initial_cash: f64,
@@ -722,21 +788,27 @@ impl PortfolioState {
self.positions
.values()
.filter(|position| position.quantity > 0)
.map(|position| HoldingSummary {
.map(|position| {
let market_value = position.market_value();
let entry_average_cost = position
.average_entry_price()
.filter(|value| value.is_finite() && *value > 0.0)
.unwrap_or(position.average_cost);
HoldingSummary {
date,
symbol: position.symbol.clone(),
quantity: position.quantity,
average_cost: position.average_cost,
average_cost: entry_average_cost,
last_price: position.last_price,
market_value: position.market_value(),
market_value,
value_percent: if total_equity > 0.0 {
position.market_value() / total_equity
market_value / total_equity
} else {
0.0
},
unrealized_pnl: position.unrealized_pnl(),
realized_pnl: position.realized_pnl,
pnl: position.pnl(),
unrealized_pnl: position.unrealized_entry_pnl(),
realized_pnl: position.realized_entry_pnl,
pnl: position.entry_pnl(),
trading_pnl: position.trading_pnl,
position_pnl: position.position_pnl,
dividend_receivable: position.dividend_receivable,
@@ -749,6 +821,7 @@ impl PortfolioState {
sold_value: position.sold_value(),
transaction_cost: position.transaction_cost(),
day_trade_quantity_delta: position.day_trade_quantity_delta(),
}
})
.collect()
}
@@ -772,6 +845,7 @@ impl PortfolioState {
let old_quantity = old_position.quantity;
let last_price = old_position.last_price;
let realized_pnl = old_position.realized_pnl;
let realized_entry_pnl = old_position.realized_entry_pnl;
let mut converted_lots = old_position
.lots
.into_iter()
@@ -808,6 +882,7 @@ impl PortfolioState {
successor.lots.extend(converted_lots);
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
successor.realized_pnl += realized_pnl;
successor.realized_entry_pnl += realized_entry_pnl;
if converted_last_price > 0.0 {
successor.last_price = converted_last_price;
}
@@ -887,6 +962,68 @@ mod tests {
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
}
#[test]
fn partial_sell_preserves_remaining_average_cost() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut position = Position::new("603958.SH");
position.buy(date, 800, 18.0981);
position.record_buy_trade_cost(800, 5.0);
position.buy(date, 1700, 19.4694);
position.record_buy_trade_cost(1700, 8.27451625);
position.buy(date, 200, 18.4584);
position.record_buy_trade_cost(200, 5.0);
position.buy(date, 100, 17.8378);
position.record_buy_trade_cost(100, 5.0);
let average_cost_before = position.average_cost;
position.sell(2700, 16.8331).expect("partial sell");
assert_eq!(position.quantity, 100);
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
}
#[test]
fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
{
let position = portfolio.position_mut("600561.SH");
position.buy(date, 100, 10.0);
position.record_buy_trade_cost(100, 5.0);
position.last_price = 10.5;
}
let summary = portfolio.holdings_summary(date);
assert_eq!(summary.len(), 1);
assert!((summary[0].average_cost - 10.0).abs() < 1e-12);
assert!((summary[0].unrealized_pnl - 50.0).abs() < 1e-12);
assert!((summary[0].realized_pnl - 0.0).abs() < 1e-12);
assert!(
portfolio
.position("600561.SH")
.expect("position")
.average_cost
> summary[0].average_cost
);
}
#[test]
fn cash_dividend_can_preserve_avg_cost_for_aiquant_rules() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut position = Position::new("603102.SH");
position.buy(date, 1000, 46.45);
position.record_buy_trade_cost(1000, 37.16);
let cost_before = position.average_cost;
let entry_before = position.average_entry_price().unwrap();
let cash = position.apply_cash_dividend_preserve_cost_basis(0.6);
assert!((cash - 600.0).abs() < 1e-12);
assert!((position.average_cost - cost_before).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12);
assert!((position.last_price - 45.85).abs() < 1e-12);
}
#[test]
fn portfolio_tracks_dividend_receivable_and_day_pnl() {
let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
@@ -923,7 +1060,7 @@ mod tests {
ask1: 10.01,
prev_close: 9.8,
volume: 1000,
tick_volume: 1000,
minute_volume: 1000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
@@ -946,7 +1083,7 @@ mod tests {
ask1: 10.51,
prev_close: 10.0,
volume: 1000,
tick_volume: 1000,
minute_volume: 1000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
@@ -970,12 +1107,14 @@ mod tests {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
@@ -1033,7 +1172,7 @@ mod tests {
ask1: 10.51,
prev_close: 10.0,
volume: 1000,
tick_volume: 1000,
minute_volume: 1000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
@@ -1056,12 +1195,14 @@ mod tests {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
@@ -1116,7 +1257,7 @@ mod tests {
ask1: 10.31,
prev_close: 10.0,
volume: 1000,
tick_volume: 1000,
minute_volume: 1000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
@@ -1185,7 +1326,7 @@ mod tests {
ask1: 3.02,
prev_close: 2.98,
volume: 152_975,
tick_volume: 152_975,
minute_volume: 152_975,
bid1_volume: 338,
ask1_volume: 2476,
trading_phase: None,
@@ -1208,7 +1349,7 @@ mod tests {
ask1: 3.07,
prev_close: 3.06,
volume: 160_000,
tick_volume: 160_000,
minute_volume: 160_000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
File diff suppressed because it is too large Load Diff
+26 -33
View File
@@ -2,6 +2,7 @@ use chrono::NaiveDate;
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
use crate::portfolio::Position;
use crate::risk_control::ChinaAShareRiskControl;
#[derive(Debug, Clone)]
pub struct RuleCheck {
@@ -26,6 +27,10 @@ impl RuleCheck {
}
pub trait EquityRuleHooks {
fn duplicates_standard_china_risk(&self) -> bool {
false
}
fn can_buy(
&self,
execution_date: NaiveDate,
@@ -47,21 +52,11 @@ pub trait EquityRuleHooks {
#[derive(Debug, Clone, Default)]
pub struct ChinaEquityRuleHooks;
impl ChinaEquityRuleHooks {
fn at_upper_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool {
snapshot.is_at_upper_limit_price(snapshot.buy_price(price_field))
}
fn at_lower_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool {
let check_price = match price_field {
PriceField::Last => snapshot.price(PriceField::Last),
_ => snapshot.sell_price(price_field),
};
snapshot.is_at_lower_limit_price(check_price)
}
}
impl EquityRuleHooks for ChinaEquityRuleHooks {
fn duplicates_standard_china_risk(&self) -> bool {
true
}
fn can_buy(
&self,
_execution_date: NaiveDate,
@@ -69,14 +64,14 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
candidate: &CandidateEligibility,
price_field: PriceField,
) -> RuleCheck {
if snapshot.paused || candidate.is_paused {
return RuleCheck::reject("paused");
}
if !candidate.allow_buy {
return RuleCheck::reject("buy disabled by eligibility flags");
}
if Self::at_upper_limit(snapshot, price_field) {
return RuleCheck::reject("open at or above upper limit");
if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason(
_execution_date,
candidate,
snapshot,
None,
ChinaAShareRiskControl::buy_check_price(snapshot, price_field),
) {
return RuleCheck::reject(reason);
}
RuleCheck::allow()
@@ -90,17 +85,15 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
position: &Position,
price_field: PriceField,
) -> RuleCheck {
if snapshot.paused || candidate.is_paused {
return RuleCheck::reject("paused");
}
if !candidate.allow_sell {
return RuleCheck::reject("sell disabled by eligibility flags");
}
if Self::at_lower_limit(snapshot, price_field) {
return RuleCheck::reject("open at or below lower limit");
}
if position.sellable_qty(execution_date) == 0 {
return RuleCheck::reject("t+1 sellable quantity is zero");
if let Some(reason) = ChinaAShareRiskControl::sell_rejection_reason(
execution_date,
candidate,
snapshot,
None,
Some(position),
ChinaAShareRiskControl::sell_check_price(snapshot, price_field),
) {
return RuleCheck::reject(reason);
}
RuleCheck::allow()
+2 -2
View File
@@ -7,7 +7,7 @@ pub enum ScheduleStage {
BeforeTrading,
OpenAuction,
Bar,
Tick,
Minute,
OnDay,
AfterTrading,
Settlement,
@@ -225,7 +225,7 @@ pub fn default_stage_time(stage: ScheduleStage) -> Option<NaiveTime> {
ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")),
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")),
ScheduleStage::Bar => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
ScheduleStage::Tick => None,
ScheduleStage::Minute => None,
ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
ScheduleStage::AfterTrading => Some(NaiveTime::from_hms_opt(15, 0, 0).expect("valid time")),
ScheduleStage::Settlement => Some(NaiveTime::from_hms_opt(15, 1, 0).expect("valid time")),
File diff suppressed because it is too large Load Diff
+143 -32
View File
@@ -69,7 +69,30 @@ pub struct StrategyAiCatalog {
pub indicator_factors: Vec<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub clickhouse_table_fields: Vec<ManualFactorSource>,
pub data_lake_fields: Vec<ManualFactorSource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StrategyAiHoldingCountContract {
#[serde(
default,
alias = "holdingCount",
alias = "holding_count",
alias = "targetHoldingCount",
alias = "target_holding_count"
)]
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<i64>,
#[serde(
default,
alias = "kind",
alias = "holdingCountMode",
alias = "holding_count_mode",
alias = "targetHoldingCountMode",
alias = "target_holding_count_mode"
)]
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -79,6 +102,9 @@ pub struct StrategyAiGenerateRequest {
pub market: String,
pub benchmark_symbol: String,
pub signal_symbol: String,
#[serde(default, alias = "holdingCountContract")]
#[serde(skip_serializing_if = "Option::is_none")]
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -87,8 +113,15 @@ pub struct StrategyAiOptimizeRequest {
pub objective: String,
pub result_summary: serde_json::Value,
pub diagnostics: Vec<String>,
#[serde(default, alias = "holdingCountContract")]
#[serde(skip_serializing_if = "Option::is_none")]
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
}
const DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT: &str = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益 >= 150% 即视为满足收益目标;达到该阈值后可以继续优化夏普、回撤、换手和稳定性,但不得把已达标策略判为失败或为了追更高收益破坏无未来数据、持仓数量和同条件对账合同。";
const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "reject_st_selection=false、reject_st_buy=true、reject_star_st_selection=false、reject_star_st_buy=true、reject_paused_selection=false、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=false、reject_inactive_buy=true、reject_inactive_sell=true、reject_new_listing_selection=false、reject_new_listing_buy=true、reject_kcb_selection=false、reject_kcb_buy=true、reject_bjse_selection=false、reject_bjse_buy=true、reject_one_yuan_selection=false、reject_one_yuan_buy=true、respect_allow_buy_sell=true、reject_upper_limit_selection=false、reject_lower_limit_selection=false、reject_upper_limit_buy=true、reject_lower_limit_sell=true、forbid_same_day_rebuy_after_sell=true、blacklist_enabled=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\"";
const DEFAULT_RISK_POLICY_DSL_CODE: &str = "reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=true, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\"";
pub fn built_in_strategy_manual() -> StrategyAiManual {
StrategyAiManual {
title: "OmniQuant 平台策略脚本手册".to_string(),
@@ -97,11 +130,12 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
"平台策略脚本采用声明式 DSL + 表达式执行模型。".to_string(),
"支持 let 变量、fn 自定义函数、when/unless/else 条件块、可用指标/因子字段映射。".to_string(),
"支持数值型和字符串型因子,字符串字段可用于行业、概念、标签、板块等分类过滤。".to_string(),
"当前默认回测数据已支持 OHLCV、市值、流通市值、换手率、有效换手率、上市天数、停牌/ST/板块、涨跌停价格、tick 触达涨跌停、常用价格/成交量均线,以及 stock_indicator_factors_v1 中已入库的通用指标因子".to_string(),
"用户明确指定目标持仓数量或最低持仓数量时,selection.limit 必须严格表达该数量;不要因为优化收益、减少交易或转换框架而擅自改小持仓数".to_string(),
"当前默认回测数据已支持 OHLCV、市值、流通市值、换手率、有效换手率、上市天数、停牌/ST/板块、涨跌停价格、分钟线触达涨跌停、常用价格/成交量均线,以及 stock_indicator_factors_v1 中已入库的通用指标因子。".to_string(),
"AI 生成策略时只能输出完整 engine-script 代码,不输出 Markdown、解释、推理过程、JSON 包装或手册复述。".to_string(),
"表达式字段以运行时字段为准:市值使用 market_cap,流通市值使用 free_float_cap;不要在策略表达式中使用数据库原始字段 float_market_cap。".to_string(),
"任意窗口价格均线使用 rolling_mean(\"close\", n) 或 ma(\"close\", n),任意窗口均量使用 rolling_mean(\"volume\", n) 或 vma(n);不要使用未列出的 ma60、stock_ma60、signal_ma60 或 benchmark_ma60 变量。".to_string(),
"next_bar_open 会用决策日信号生成订单,并在下一可交易开盘撮合;不得把执行日 open/high/low/close 当成下单前已知信息。".to_string(),
"next_bar_open 会用决策日信号生成订单,并在下一可交易开盘撮合;不得把执行日 open/high/low/close 当成下单前已知信息;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close".to_string(),
"自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(),
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
],
@@ -203,8 +237,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
detail: "支持按交易周或交易月调仓,例如 rebalance.weekly(weekday=5).at([\"10:18\"])、rebalance.weekly(tradingday=-1).at([\"10:18\"])、rebalance.monthly(tradingday=1).at([\"10:18\"])。`.at([...])` 的最后一个时刻会编进分钟级 schedule/time_rule;当前平台把 on_day 近似到 10:18,把 open_auction 近似到 09:31。".to_string(),
},
ManualSection {
title: "bar / tick 生命周期".to_string(),
detail: "回测内核支持 平台内核 风格的 bar/tick 生命周期:日内会发布 pre_bar/bar/post_bar 过程事件;存在 tick 订阅或 tick 调度规则时,会按 execution_quotes 的时间顺序发布 pre_tick/tick/post_tick,并把 tick 阶段下单限制在当前 tick 时间窗内撮合。平台 DSL 中可通过 subscribe([...])、trading.subscription_guard(true) 和 process_event 字段配合显式订单模拟 tick 订阅策略。".to_string(),
title: "bar / minute execution 生命周期".to_string(),
detail: "回测内核支持 平台内核 风格的 bar/分钟执行价生命周期:日内会发布 pre_bar/bar/post_bar 过程事件;存在分钟执行价订阅或分钟调度规则时,会按 execution_quotes 的时间顺序发布 pre_minute/minute/post_minute 过程事件,并把日内阶段下单限制在当前分钟执行价时间窗内撮合。平台 DSL 中可通过 subscribe([...])、trading.subscription_guard(true) 和 process_event 字段配合显式订单模拟日内订阅策略。".to_string(),
},
ManualSection {
title: "selection.market_cap_band / selection.limit / ordering.rank_by / ordering.rank_expr".to_string(),
@@ -214,21 +248,25 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
title: "filter.stock_expr / risk.stop_loss / risk.take_profit / allocation.buy_scale".to_string(),
detail: "表达式型规则,支持多条组合。stop_loss/take_profit 多条按 OR 组合,filter.stock_expr 多条按 AND 组合。".to_string(),
},
ManualSection {
title: "risk.policy / risk.blacklist".to_string(),
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 reject_st_selection、reject_st_buy、reject_star_st_selection、reject_star_st_buy、reject_paused_selection、reject_paused_buy、reject_paused_sell、reject_inactive_selection、reject_inactive_buy、reject_inactive_sell、reject_new_listing_selection、reject_new_listing_buy、reject_kcb_selection、reject_kcb_buy、reject_bjse_selection、reject_bjse_buy、reject_one_yuan_selection、reject_one_yuan_buy、respect_allow_buy_sell、reject_upper_limit_selection、reject_lower_limit_selection、reject_upper_limit_buy、reject_lower_limit_sell、forbid_same_day_rebuy_after_sell、blacklist_enabled、allow_market_orders、live_trading_enabled、blacklisted_symbols、volume_limit_enabled、liquidity_limit_enabled、volume_percent、commission_rate、minimum_commission、stamp_tax_rate_before_change、stamp_tax_rate_after_change、stamp_tax_change_date 等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。ST、*ST、停牌、退市、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不要写进 universe.exclude 或 filter.stock_expr。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
},
ManualSection {
title: "corporate_actions.dividend_reinvestment".to_string(),
detail: "支持 corporate_actions.dividend_reinvestment(true)。开启后,现金分红到账会优先按 round lot 回补成同一只股票,零头保留为现金。".to_string(),
},
ManualSection {
title: "execution.matching_type / execution.slippage".to_string(),
detail: "设置撮合模式和滑点。支持 execution.matching_type(\"next_tick_last\" | \"next_tick_best_own\" | \"next_tick_best_counterparty\" | \"counterparty_offer\" | \"vwap\" | \"current_bar_close\" | \"next_bar_open\" | \"open_auction\")。其中 next_tick_last 使用 tick 的 last_pricenext_tick_best_own / next_tick_best_counterparty 会按 L1 买一卖一近似 平台内核 的 tick 最优价语义;counterparty_offer 在存在 order_book_depth 多档盘口数据时会按真实档位逐档扫单并计算加权成交价,不存在 depth 时回退 L1 对手方报价;vwap 会在盘中执行价链路上聚合多笔成交为单条 VWAP 成交;next_bar_open 使用决策日信号并在下一可交易日开盘撮合,禁止把执行日 open/high/low/close 解释为下单前已知数据;open_auction 使用当日集合竞价开盘价 day_open 进行撮合,且不额外施加滑点,并按竞价成交量而不是盘口一档流动性限制成交;滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\")current_bar_close 使用决策日当日 closenext_bar_open 使用决策日信号并在下一可交易日 open 撮合,禁止把执行日 open/high/low/close 解释为下单前已知数据;next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。日线调仓现金口径由 execution.rebalance_cash_mode(\"sell_then_buy\" | \"same_point_net\" | \"pre_open_cash\") 或页面/API 参数控制,默认 sell_then_buysell_then_buy_delay_slippage_rate 只来自页面/API 执行参数,默认 0,不要写进策略表达式。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
},
ManualSection {
title: "期货提交校验".to_string(),
detail: "期货订单进入撮合前会先执行账户与交易规则校验:合约必须在上市/退市日期范围内,日行情不能停牌,trading_phase 需处于 continuous/trading/open_auction/auction/call_auction/opening_auction 等可交易阶段,限价必须为正且按 futures_trading_parameters.price_tick 或日行情 price_tick 对齐,并且不能越过 upper_limit/lower_limit;随后继续检查反向挂单自成交风险、保证金和可平数量。服务层可通过 FuturesValidationConfig 分别关闭 active instrument、trading phase、limit price tick、price limit 校验,用于兼容特殊数据,但默认全部开启。".to_string(),
detail: "期货订单进入撮合前会先执行账户与交易规则校验:合约必须在上市/退市日期范围内,日行情不能停牌,trading_phase 需处于 continuous/trading/open_auction/auction/call_auction/opening_auction 等可交易阶段,限价必须为正且按 futures_trading_parameters.price_tick 或日行情 price_tick 对齐,并且不能越过 upper_limit/lower_limit;随后继续检查反向挂单自成交风险、保证金和可平数量。服务层可通过 FuturesValidationConfig 分别关闭 active instrument、trading phase、限价最小价位、price limit 校验,但默认全部开启。".to_string(),
},
ManualSection {
title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(),
detail: "支持显式下单、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的 tick 订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。其中 order.target_shares(...) 对应 平台内核 的 order_toorder.target_portfolio_smart(...) 对应 平台内核 的 order_target_portfolio_smart 批量目标权重语义;account.deposit_withdraw(...) 和 account.finance_repay(...) 对应 平台内核 账户出入金与融资/还款语义;order_prices 既可以是逐标的限价映射,也可以是 VWAPOrder/TWAPOrder 这类全局 AlgoOrderorder.vwap_* / order.twap_* 对应 平台内核 的 AlgoOrder 时间窗订单风格,而 update_universe/subscribe/unsubscribe 对应 平台内核 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
detail: "支持显式下单、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。其中 order.target_shares(...) 对应 平台内核 的 order_toorder.target_portfolio_smart(...) 对应 平台内核 的 order_target_portfolio_smart 批量目标权重语义;account.deposit_withdraw(...) 和 account.finance_repay(...) 对应 平台内核 账户出入金与融资/还款语义;order_prices 既可以是逐标的限价映射,也可以是 VWAPOrder/TWAPOrder 这类全局 AlgoOrderorder.vwap_* / order.twap_* 对应 平台内核 的 AlgoOrder 时间窗订单风格,而 update_universe/subscribe/unsubscribe 对应 平台内核 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
},
ManualSection {
title: "when / unless / else".to_string(),
@@ -267,17 +305,17 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
fields: vec![
ManualField { name: "symbol".to_string(), field_type: "string".to_string(), detail: "证券代码。".to_string() },
ManualField { name: "market_cap/free_float_cap".to_string(), field_type: "float".to_string(), detail: "总市值、流通市值。".to_string() },
ManualField { name: "turnover/turnover_ratio/effective_turnover_ratio".to_string(), field_type: "float".to_string(), detail: "换手率、换手率标准字段有效换手率turnover 是 turnover_ratio 的兼容别名".to_string() },
ManualField { name: "turnover_ratio/effective_turnover_ratio".to_string(), field_type: "float".to_string(), detail: "换手率标准字段有效换手率。".to_string() },
ManualField { name: "open/high/low/close/last/last_price/prev_close/amount".to_string(), field_type: "float".to_string(), detail: "开盘、最高、最低、收盘、盘中价、昨收和成交额。".to_string() },
ManualField { name: "upper_limit/lower_limit/price_tick/round_lot/minimum_order_quantity/order_step_size".to_string(), field_type: "float/int".to_string(), detail: "涨跌停、最小价位、整手、最小下单量和数量步长。KSH/BJSE 等板块可与 round_lot 不同。".to_string() },
ManualField { name: "paused/is_st/is_kcb/is_one_yuan/is_new_listing".to_string(), field_type: "bool".to_string(), detail: "可交易性与板块标志。".to_string() },
ManualField { name: "paused/is_st/is_star_st/is_kcb/is_one_yuan/is_new_listing".to_string(), field_type: "bool".to_string(), detail: "可交易性与板块标志ST 与 *ST 是独立字段".to_string() },
ManualField { name: "allow_buy/allow_sell/at_upper_limit/at_lower_limit".to_string(), field_type: "bool".to_string(), detail: "盘中买卖与涨跌停状态。".to_string() },
ManualField { name: "touched_upper_limit/touched_lower_limit/hit_upper_limit/hit_lower_limit".to_string(), field_type: "bool".to_string(), detail: "当日 tick 曾经触达涨跌停。".to_string() },
ManualField { name: "touched_upper_limit/touched_lower_limit/hit_upper_limit/hit_lower_limit".to_string(), field_type: "bool".to_string(), detail: "当日分钟执行价曾经触达涨跌停。".to_string() },
ManualField { name: "symbol_open_order_count/symbol_open_buy_qty/symbol_open_sell_qty/latest_symbol_open_order_id".to_string(), field_type: "int".to_string(), detail: "当前证券在挂单簿中的未成交挂单摘要和最近挂单 id。".to_string() },
ManualField { name: "latest_symbol_open_order_status/latest_symbol_open_order_unfilled_qty".to_string(), field_type: "string/int".to_string(), detail: "当前证券最近一笔挂单的状态和未成交数量。".to_string() },
ManualField { name: "in_dynamic_universe/is_subscribed".to_string(), field_type: "bool".to_string(), detail: "当前证券是否在动态 universe 内,以及是否仍在订阅集合中。".to_string() },
ManualField { name: "stock_ma5/stock_ma10/stock_ma20/stock_ma30".to_string(), field_type: "float".to_string(), detail: "个股价格均线内建别名,按当前交易日前 N 个已完成交易日的收盘价计算;历史窗口不足时为 NaN,比较条件会自然不通过;15 日、45 日等任意窗口请改用 sma(\"close\", n)。".to_string() },
ManualField { name: "stock_volume_ma5/stock_volume_ma10/stock_volume_ma20/stock_volume_ma60".to_string(), field_type: "float".to_string(), detail: "个股成交量均线内建别名,按当前交易日前 N 个已完成交易日的成交量计算,不包含回测当天未来成交量;历史窗口不足时为 NaN,比较条件会自然不通过;任意窗口请改用 rolling_mean(\"volume\", n)。".to_string() },
ManualField { name: "stock_volume_ma5/stock_volume_ma10/stock_volume_ma20/stock_volume_ma60/stock_volume_ma100".to_string(), field_type: "float".to_string(), detail: "个股成交量均线内建别名,按当前交易日前 N 个已完成交易日的成交量计算,不包含回测当天未来成交量;历史窗口不足时为 NaN,比较条件会自然不通过;任意窗口请改用 rolling_mean(\"volume\", n)。".to_string() },
ManualField { name: "factors[\"field\"] / factor(\"field\")".to_string(), field_type: "float/string".to_string(), detail: "当前证券当日可用因子。默认可用字段以手册的“可用指标、参数和字段”清单为准;自定义因子需要预先写入策略数据或 extra_factors。数值字段返回数字,字符串字段返回字符串。".to_string() },
ManualField { name: "listed_days".to_string(), field_type: "int".to_string(), detail: "上市天数。".to_string() },
],
@@ -304,13 +342,13 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
functions: vec![
ManualFunction { name: "factor".to_string(), signature: "factor(\"column_name\")".to_string(), detail: "读取当前股票当日可用因子列。数值因子返回 float,字符串因子返回 string;缺失字段默认返回 0 或空字符串,建议重要条件配合 diagnostics 查看候选过滤数量。".to_string() },
ManualFunction { name: "day_factor".to_string(), signature: "day_factor(\"field_name\")".to_string(), detail: "读取日级/指数级字段映射。".to_string() },
ManualFunction { name: "history_bars".to_string(), signature: "ctx.history_bars(symbol, count, \"1d\" | \"1m\" | \"tick\", \"close\", include_now)".to_string(), detail: "回测内核策略上下文数据 API,返回指定证券最近 N 条数值序列。日线字段支持 open/high/low/close/last/prev_close/volume/upper_limit/lower_limit;分钟或 tick 字段支持 last/bid1/ask1/volume_delta/amount_delta。日线 include_now=false 排除当前交易日;分钟/tick 会按当前 on_bar、on_tick 或调度时刻截断,include_now=false 排除当前 bar/tick,避免未来函数".to_string() },
ManualFunction { name: "current_snapshot".to_string(), signature: "ctx.current_snapshot(symbol)".to_string(), detail: "读取当前交易日指定证券的日级快照,可用于获得日 open/close/last/upper_limit/lower_limit 等字段。".to_string() },
ManualFunction { name: "history_bars".to_string(), signature: "ctx.history_bars(symbol, count, \"1d\" | \"1m\", \"close\", include_now)".to_string(), detail: "回测内核策略上下文数据 API,返回指定证券最近 N 条数值序列。日线字段支持 open/high/low/close/last/prev_close/volume/upper_limit/lower_limit;分钟字段支持 last/bid1/ask1/volume_delta/amount_delta。日线 include_now=false 排除当前信号日;分钟线会按当前 on_bar、日内事件或调度时刻截断,include_now=false 排除当前分钟执行价。next_bar_open 下该 API 只能看到信号日数据,不能读取实际成交日数据".to_string() },
ManualFunction { name: "current_snapshot".to_string(), signature: "ctx.current_snapshot(symbol)".to_string(), detail: "读取当前信号日指定证券的日级快照,可用于获得信号日 open/close/last/upper_limit/lower_limit 等字段next_bar_open 的实际成交日涨跌停、停牌、ST、退市、一元、黑名单、成交量和盘口流动性由撮合层按执行日判断".to_string() },
ManualFunction { name: "instrument/instruments/all_instruments".to_string(), signature: "ctx.instrument(symbol)".to_string(), detail: "读取证券元数据,包括名称、板块、上市日期、退市日期、最小下单量、整手、最小价位等;all_instruments 按证券代码稳定排序返回全量证券。".to_string() },
ManualFunction { name: "active_instruments/instruments_history".to_string(), signature: "ctx.active_instruments(&[symbol])".to_string(), detail: "active_instruments 返回当前交易日已上市且未退市的证券;instruments_history 返回给定代码的历史证券记录,包含当前已退市标的,对齐 平台内核 的 active_instruments/instruments_history 能力。".to_string() },
ManualFunction { name: "active_instruments/instruments_history".to_string(), signature: "ctx.active_instruments(&[symbol])".to_string(), detail: "active_instruments 返回当前信号日已上市且未退市的证券;instruments_history 返回给定代码的历史证券记录,包含当前已退市标的,对齐 平台内核 的 active_instruments/instruments_history 能力。".to_string() },
ManualFunction { name: "get_trading_dates/get_previous_trading_date/get_next_trading_date".to_string(), signature: "ctx.get_previous_trading_date(date, n)".to_string(), detail: "交易日历 API。get_trading_dates 返回闭区间交易日;previous/next 返回相对某日向前或向后的第 n 个交易日,当前日自身不计入。".to_string() },
ManualFunction { name: "is_suspended/is_st_stock".to_string(), signature: "ctx.is_suspended(symbol, count)".to_string(), detail: "读取指定证券截至当前交易日最近 count 个交易日的停牌或 ST 标记,返回 bool 序列,顺序从旧到新;对应平台内核的 is_suspended/is_st_stock 数据能力。".to_string() },
ManualFunction { name: "get_price".to_string(), signature: "ctx.get_price(symbol, start_date, end_date, \"1d\" | \"1m\" | \"tick\")".to_string(), detail: "按日期区间读取统一 PriceBar 序列。日线返回 open/high/low/close/last/volume/盘口字段;分钟或 tick 返回按 timestamp 排序的 last/bid1/ask1/volume_delta/amount_delta 映射,便于服务层转成表格或前端明细。".to_string() },
ManualFunction { name: "is_suspended/is_st_stock".to_string(), signature: "ctx.is_suspended(symbol, count)".to_string(), detail: "读取指定证券截至当前信号日最近 count 个交易日的停牌或 ST 标记,返回 bool 序列,顺序从旧到新;对应平台内核的 is_suspended/is_st_stock 数据能力。执行日停牌或 ST 只能由撮合风控判断,不能在 next_bar_open 的 T 日提前固化。".to_string() },
ManualFunction { name: "get_price".to_string(), signature: "ctx.get_price(symbol, start_date, end_date, \"1d\" | \"1m\")".to_string(), detail: "按日期区间读取统一 PriceBar 序列。日线返回 open/high/low/close/last/volume/盘口字段;分钟线返回按 timestamp 排序的 last/bid1/ask1/volume_delta/amount_delta 映射,便于服务层转成表格或前端明细。".to_string() },
ManualFunction { name: "get_dividend / dividend_cash / has_dividend".to_string(), signature: "dividend_cash(lookback) / has_dividend(lookback)".to_string(), detail: "高级数据 风格分红 API。Rust Context 可用 ctx.get_dividend(symbol, start_date) 读取明细;平台表达式可用 dividend_cash(lookback) 汇总当前股票最近 N 个交易日现金分红,用 has_dividend(lookback) 判断是否发生分红,也支持 dividend_cash(\"600000.SH\", lookback)。".to_string() },
ManualFunction { name: "get_split / split_ratio / has_split".to_string(), signature: "split_ratio(lookback) / has_split(lookback)".to_string(), detail: "高级数据 风格拆分/送转 API。Rust Context 可用 ctx.get_split(symbol, start_date) 读取明细;平台表达式可用 split_ratio(lookback) 计算当前股票最近 N 个交易日累计拆分比例,has_split(lookback) 判断是否发生送转。".to_string() },
ManualFunction { name: "get_factor / factor_value".to_string(), signature: "factor_value(\"field\", lookback=1)".to_string(), detail: "数值因子 API。factor(\"field\") 读取当前股票当日因子;factor_value(\"field\", lookback) 会在最近 N 个交易日内取该字段最新数值,适合读取任意可用指标或自定义数值因子。Rust Context 可用 ctx.get_factor(symbol, start, end, field) 读取完整数值序列。".to_string() },
@@ -328,13 +366,13 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason;可用便捷函数读取状态、成交均价和费用,对齐 平台内核 Order 的核心属性。".to_string() },
ManualFunction { name: "account/portfolio_view/accounts".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 account_type、cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等;DSL 中同名字段可直接使用。也可用 ctx.stock_account()、ctx.account_by_type(\"STOCK\")、ctx.accounts() 按账户类型读取;当前股票回测路径不会把 FUTURE 虚假映射成 STOCK。".to_string() },
ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金;receiving_days 大于 0 时按交易日延迟到账,并保持净值口径不把外部资金流当成收益。finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 平台内核 管理费回调能力。".to_string() },
ManualFunction { name: "rolling_mean / sma / ma".to_string(), signature: "rolling_mean(\"field\", lookback) / ma(\"close\", 20)".to_string(), detail: "任意字段滚动均值,支持 close、volume、amount、turnover_ratio、effective_turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 和所有数值型 extra_factors。个股 close 使用当前交易日前已完成收盘序列,volume 使用当前交易日前已完成成交量序列;历史窗口不足时在选股过滤和买入仓位表达式中按不通过/0 仓处理。".to_string() },
ManualFunction { name: "rolling_mean / sma / ma".to_string(), signature: "rolling_mean(\"field\", lookback) / ma(\"close\", 20)".to_string(), detail: "任意字段滚动均值,支持 close、volume、amount、turnover_ratio、effective_turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 和所有数值型 extra_factors。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。个股 close 使用当前交易日前已完成收盘序列,volume 使用当前交易日前已完成成交量序列;历史窗口不足时在选股过滤和买入仓位表达式中按不通过/0 仓处理。".to_string() },
ManualFunction { name: "vma".to_string(), signature: "vma(60)".to_string(), detail: "rolling_mean(\"volume\", lookback) 的便捷别名,用于任意窗口成交量均线,例如 vma(5) < vma(60)。".to_string() },
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
ManualFunction { name: "数据库指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "stock_indicator_factors_v1 中的指标会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() },
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;需要收益率波动时先使用已注册收益率字段或发布因子,不要写 rolling_stddev(pct_change(\"close\", 1), 20)。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
ManualFunction { name: "Source Lake 指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "Strategy Factory Source Lake 中已完成 PIT/as-of 审计的 source rows 字段、已发布指标或因子 artifact 会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() },
ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() },
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() },
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs) / safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法,两参数形式默认 fallback=0".to_string() },
ManualFunction { name: "contains/starts_with/ends_with/lower/upper/trim/strlen".to_string(), signature: "starts_with(symbol, \"60\")".to_string(), detail: "字符串辅助函数。".to_string() },
],
factor_sources: vec![
@@ -360,12 +398,12 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
},
ManualFactorSource {
table: "盘口深度参数".to_string(),
detail: "可选字段包括 date、symbol、timestamp、level、bid_price、bid_volume、ask_price、ask_volume。存在盘口深度时,期货 counterparty_offer / next_tick_best_counterparty 可按真实多档盘口逐档扫单;不存在时不会伪造 depth。".to_string(),
detail: "可选字段包括 date、symbol、timestamp、level、bid_price、bid_volume、ask_price、ask_volume。存在盘口深度时,期货 minute_best_counterparty 可按真实多档盘口逐档扫单;不存在时不会伪造 depth。".to_string(),
fields: vec![],
},
ManualFactorSource {
table: "期货交易参数".to_string(),
detail: "字段包括 symbol、effective_date、contract_multiplier、long_margin_rate、short_margin_rate、commission_type、open_commission_ratio、close_commission_ratio、close_today_commission_ratio、price_tick。回测会按交易日自动选择不晚于当前日期的最新参数,用于保证金、手续费和限价 tick 校验。".to_string(),
detail: "字段包括 symbol、effective_date、contract_multiplier、long_margin_rate、short_margin_rate、commission_type、open_commission_ratio、close_commission_ratio、close_today_commission_ratio、price_tick。回测会按交易日自动选择不晚于当前日期的最新参数,用于保证金、手续费和限价最小价位校验。".to_string(),
fields: vec![],
},
],
@@ -383,8 +421,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
code: "filter.stock_expr(industry_name(\"citics\", 1) == \"电子\" && factor_text(\"concept\") == \"ai_chip\")".to_string(),
},
ManualExample {
title: "next tick 撮合 + tick 滑点".to_string(),
code: "execution.matching_type(\"next_tick_last\")\nexecution.slippage(\"tick_size\", 1)".to_string(),
title: "分钟执行价撮合 + 最小价位滑点".to_string(),
code: "execution.matching_type(\"minute_last\")\nexecution.slippage(\"tick_size\", 1)".to_string(),
},
ManualExample {
title: "动态 universe 和订阅".to_string(),
@@ -432,17 +470,21 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
out.push_str("## AI 代码生成硬约束\n");
out.push_str("- 只输出完整 `engine-script` 代码;第一行必须是 `strategy(\"...\")`、`let`、`fn`、`const` 或 `//`。\n");
out.push_str("- 禁止输出 Markdown、解释、推理过程、JSON 包装、手册复述或结果报告。\n");
out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`execution.matching_type`、`execution.slippage`、`universe.exclude`。\n");
out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`risk.policy`、`risk.blacklist`、`execution.matching_type`、`execution.rebalance_cash_mode`、`execution.slippage`、`universe.exclude`。\n");
out.push_str("- `universe.exclude` 只用于用户明确要求的业务排除项;ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量、手续费和印花税等基础风控必须写 `risk.policy(...)` 或由运行态 RiskLimits 注入。\n");
out.push_str("- 禁止伪 DSL`filter(...)`、`rank(...)`、`select.top(...)`、`weight.equal(...)`、`sell_rule(...)`、`backtest(...)`、`risk.max_position(...)`。\n");
out.push_str("- 市值表达式字段只能用 `market_cap` 或 `free_float_cap`;不要使用数据库原始字段 `float_market_cap`。\n");
out.push_str("- 任意窗口价格均线使用 `rolling_mean(\"close\", n)` 或 `ma(\"close\", n)`;任意窗口均量使用 `rolling_mean(\"volume\", n)` 或 `vma(n)`;不要使用未列出的 `ma60`、`stock_ma60`、`signal_ma60` 或 `benchmark_ma60` 变量。\n");
out.push_str("- `rolling_mean`、`rolling_sum/min/max/stddev/zscore`、`pct_change`、`factor_value` 等 helper 的第一个参数必须是字段名或字符串字段名;不要输出 `rolling_stddev(pct_change(\"close\", 1), 20)` 这类嵌套表达式。\n");
out.push_str("- 自定义 `fn` 必须通过参数传入运行时字段;不要用 `fn score()` 这类零参数函数直接引用 `market_cap`、`close`、`ma5` 等股票字段。\n");
out.push_str("- `selection.market_cap_band` 必须写命名参数:`field=\"market_cap\"` 或 `field=\"free_float_cap\"`,并包含 `lower=...` 与 `upper=...`。\n");
out.push_str(
"- `risk.index_exposure(...)` 只能传一个表达式;不要生成 `risk.exposure(...)`。\n",
);
out.push_str("- `filter.stock_expr(...)` 只写 alpha 或业务过滤条件;不要把 `!is_st`、`!paused`、`!at_upper_limit`、`!at_lower_limit` 这类基础风控散落在过滤表达式里。\n");
out.push_str("- 完整三元表达式 `cond ? a : b` 可在表达式参数中使用;若当前运行环境报 `Unknown operator: '?'`,先重编译并重启回测服务,不要改写策略语义掩盖运行时漂移。\n");
out.push_str("- `next_bar_open` 的选股、排序和仓位信号来自决策日,订单在下一可交易开盘撮合;不要使用执行日价格作为下单前信号。\n");
out.push_str("- `next_bar_open` 必须区分信号日、订单创建日和实际成交日:T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须比较实际 next-open 成交价与涨跌停价,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
out.push_str("## 语句块\n");
for item in &manual.statement_blocks {
@@ -514,13 +556,19 @@ pub fn build_generation_prompt(
prompt.push_str("- 不要输出解释文本。\n");
prompt.push_str("- 必须使用 strategy(\"...\") { ... } 语法。\n");
prompt.push_str("- 如需自定义参数,使用 let 和 fn。\n");
prompt.push_str("- 优先使用数据库已存在字段factors[...]。\n\n");
prompt.push_str("- 优先使用 Strategy Factory Source Lake 已注册 source rows 字段、已发布指标/因子 artifact 和运行时已存在字段factors[...];不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件\n\n");
prompt.push_str("- 生成的代码必须能转换为 strategy_spec 并提交 POST /v1/backtests。\n");
prompt.push_str("- 用户指定“持仓N只、目标持仓N、stocknum=N、selection.limit(N)”时,必须把最终持仓槽位写成 N;用户指定“至少/不少于N只”时,最终持仓槽位必须 >= N。\n");
prompt.push_str("- ");
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
prompt.push('\n');
prompt.push_str("- 不要使用手册未列出的字段、函数或外部平台 API 名称。\n\n");
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、execution.matching_type、execution.slippage、universe.exclude。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
prompt.push_str("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma6060日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n);不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用, !is_st、!paused、!at_upper_limit、!at_lower_limit,不要写 is_st == 0risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposure;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;execution.matching_type 只能取 next_tick_last、next_tick_best_own、next_tick_best_counterparty、counterparty_offer、vwap、current_bar_close、next_bar_open、open_auctionnext_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n");
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、risk.policy、risk.blacklist、execution.matching_type、execution.slippage、universe.exclude。universe.exclude 只用于用户明确要求的业务排除项,不能表达 FIDC 基础风控。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
prompt.push_str(&format!("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma6060日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n)rolling_mean、rolling_sum/min/max/stddev/zscore、pct_change、factor_value 等 helper 的第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposurerisk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,必须覆盖完整默认配置面,例如 {DEFAULT_RISK_POLICY_DSL_PROMPT},不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_typenext_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\n"));
prompt.push_str("回测成功但 tradeCount=0 或 holdingCount=0 是无效策略;第一版必须保持稳定买入覆盖率,复杂因子只能在后续优化中逐步加严。\n");
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && !is_st && !paused && close > 2 && !at_upper_limit && !at_lower_limit)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n");
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && close > 2)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.policy(");
prompt.push_str(DEFAULT_RISK_POLICY_DSL_CODE);
prompt.push_str(")\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n");
prompt.push_str("用户目标:\n");
prompt.push_str(&format!("- {}\n", request.user_goal));
if !request.constraints.is_empty() {
@@ -547,7 +595,10 @@ pub fn build_optimization_prompt(
prompt.push_str("输出格式硬约束:回复第一行必须是 strategy(\"...\")、let、fn、const 或 //;回复中不得包含 Markdown、解释、思考过程、手册复述、JSON 包装或自然语言总结。\n");
prompt.push_str("长度硬约束:策略代码目标 80 行以内,只保留必要 let/fn/strategy 块;不要复制下面的手册片段、历史策略全文或字段清单。\n");
prompt.push_str("优化不限制在原策略已有参数或少量扰动。只要 OmniQuant/FIDC 已支持,可以自由增加、修改、删除策略代码、参数、候选池、过滤函数、排序、仓位、止盈止损、调仓周期、指标因子和辅助函数;不得引入手册未列出的字段或外部平台 API 名称。\n");
prompt.push_str("可以使用所有已入库日频字段、指标因子和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益\n");
prompt.push_str("持仓数量属于策略合同,不是优化自由参数。原策略或用户目标明确 stocknum、selection.limit、目标持仓N只或不少于N只时,优化后必须保留该目标槽位或满足最低槽位,不能为了收益或交易次数擅自改小\n");
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
prompt.push('\n');
prompt.push_str("可以使用 Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计的日频 source rows 字段、已发布指标/因子 artifact 和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;这些滚动/因子 helper 的字段参数只能是字段名或字符串字段名,不要嵌套表达式;不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n");
prompt.push_str("优化目标:\n");
prompt.push_str(&format!("- {}\n\n", request.objective));
prompt.push_str("当前策略代码如下,仅作为输入参考;回复时不要包含 Markdown 代码围栏:\n");
@@ -569,3 +620,63 @@ pub fn build_optimization_prompt(
prompt.push_str(manual_markdown);
prompt
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn generation_prompt_contains_three_year_return_acceptance_target() {
let prompt = build_generation_prompt(
"manual",
&StrategyAiGenerateRequest {
user_goal: "生成策略".to_string(),
constraints: Vec::new(),
market: "CN_A".to_string(),
benchmark_symbol: "000852.SH".to_string(),
signal_symbol: "000001.SH".to_string(),
holding_count_contract: None,
},
);
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
assert!(prompt.contains("不得把已达标策略判为失败"));
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
assert!(prompt.contains("不要回退 ficlaw-data"));
assert!(prompt.contains("ClickHouse"));
assert!(prompt.contains("T 日只生成订单意图"));
assert!(prompt.contains("按实际成交日判断"));
assert!(prompt.contains("禁止用 T 日执行状态拦截 T+1 可交易订单"));
assert!(prompt.contains("必须覆盖完整默认配置面"));
assert!(prompt.contains("reject_inactive_buy=true"));
assert!(prompt.contains("reject_inactive_sell=true"));
assert!(prompt.contains("reject_new_listing_buy=true"));
assert!(prompt.contains("reject_kcb_buy=true"));
assert!(prompt.contains("reject_bjse_buy=true"));
assert!(prompt.contains("reject_one_yuan_buy=true"));
assert!(prompt.contains("respect_allow_buy_sell=true"));
assert!(prompt.contains("stamp_tax_rate_before_change=0.001"));
assert!(prompt.contains("stamp_tax_change_date=\"2023-08-28\""));
}
#[test]
fn optimization_prompt_contains_three_year_return_acceptance_target() {
let prompt = build_optimization_prompt(
"manual",
&StrategyAiOptimizeRequest {
current_code: "strategy(\"demo\") {}".to_string(),
objective: "优化收益".to_string(),
result_summary: json!({ "total_return": 1.49 }),
diagnostics: Vec::new(),
holding_count_contract: None,
},
);
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
assert!(prompt.contains("继续优化夏普、回撤、换手和稳定性"));
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
assert!(prompt.contains("不要回退 ficlaw-data"));
assert!(prompt.contains("ClickHouse"));
}
}
+325 -4
View File
@@ -4,6 +4,7 @@ use chrono::NaiveDate;
use serde::Serialize;
use crate::data::{BenchmarkSnapshot, DataSet, EligibleUniverseSnapshot};
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BandRegime {
@@ -39,6 +40,7 @@ pub struct SelectionDiagnostics {
pub missing_market_cap_symbols: Vec<String>,
pub selected_symbols: Vec<String>,
pub rejection_examples: Vec<String>,
pub risk_decisions: Vec<FidcRiskDecisionAudit>,
}
pub struct SelectionContext<'a> {
@@ -47,20 +49,62 @@ pub struct SelectionContext<'a> {
pub reference_level: f64,
pub data: &'a DataSet,
pub dynamic_universe: Option<&'a BTreeSet<String>>,
pub risk_config: Option<&'a FidcRiskControlConfig>,
pub defer_selection_risk: bool,
}
impl SelectionContext<'_> {
fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> {
let eligible = self.data.eligible_universe_on(self.decision_date);
let eligible = match (self.risk_config, self.defer_selection_risk) {
(Some(risk_config), false) => self
.data
.eligible_universe_on_with_risk_config(self.decision_date, risk_config),
_ => self.data.eligible_universe_on(self.decision_date).to_vec(),
};
match self.dynamic_universe {
Some(symbols) if !symbols.is_empty() => eligible
.iter()
.into_iter()
.filter(|row| symbols.contains(&row.symbol))
.cloned()
.collect(),
_ => eligible.to_vec(),
_ => eligible,
}
}
fn selection_risk_decisions(&self) -> Vec<FidcRiskDecisionAudit> {
let default_risk_config;
let risk_config = match self.risk_config {
Some(value) => value,
None => {
default_risk_config = FidcRiskControlConfig::default();
&default_risk_config
}
};
let mut decisions = Vec::new();
for factor in self.data.factor_snapshots_on(self.decision_date) {
if self
.dynamic_universe
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(&factor.symbol))
{
continue;
}
let Some(candidate) = self.data.candidate(self.decision_date, &factor.symbol) else {
continue;
};
let Some(market) = self.data.market(self.decision_date, &factor.symbol) else {
continue;
};
if let Some(decision) = ChinaAShareRiskControl::selection_rejection_decision_with_config(
self.decision_date,
candidate,
market,
self.data.instrument(&factor.symbol),
risk_config,
) {
decisions.push(decision);
}
}
decisions
}
}
pub trait UniverseSelector {
@@ -166,9 +210,23 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
missing_market_cap_symbols: Vec::new(),
selected_symbols: Vec::new(),
rejection_examples: Vec::new(),
risk_decisions: Vec::new(),
};
diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len();
diagnostics.risk_decisions = ctx.selection_risk_decisions();
diagnostics.not_eligible_count = diagnostics.risk_decisions.len();
diagnostics.paused_count = diagnostics
.risk_decisions
.iter()
.filter(|decision| decision.rule_code == "paused")
.count();
diagnostics.rejection_examples = diagnostics
.risk_decisions
.iter()
.take(8)
.map(|decision| format!("{} rejected by {}", decision.symbol, decision.rule_code))
.collect();
let eligible = ctx.eligible_universe();
diagnostics.market_cap_missing_count =
diagnostics.factor_total.saturating_sub(eligible.len());
@@ -221,3 +279,266 @@ fn to_universe_candidate(
band_high,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::{
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
};
use crate::instrument::Instrument;
fn d() -> NaiveDate {
NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()
}
fn instrument(symbol: &str) -> Instrument {
Instrument {
symbol: symbol.to_string(),
name: symbol.to_string(),
board: symbol.rsplit('.').next().unwrap_or("").to_string(),
round_lot: 100,
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
delisted_at: None,
status: "active".to_string(),
}
}
fn market(symbol: &str, price: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date: d(),
symbol: symbol.to_string(),
timestamp: Some("2025-01-02 10:00:00".to_string()),
day_open: price,
open: price,
high: price,
low: price,
close: price,
last_price: price,
bid1: price,
ask1: price,
prev_close: price,
volume: 1_000_000,
minute_volume: 10_000,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: price * 1.1,
lower_limit: price * 0.9,
price_tick: 0.01,
}
}
fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date: d(),
symbol: symbol.to_string(),
market_cap_bn,
free_float_cap_bn: market_cap_bn,
pe_ttm: 10.0,
turnover_ratio: Some(0.01),
effective_turnover_ratio: Some(0.01),
extra_factors: Default::default(),
}
}
fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility {
CandidateEligibility {
date: d(),
symbol: symbol.to_string(),
is_st,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb,
is_one_yuan: false,
risk_level_code: None,
}
}
fn benchmark() -> BenchmarkSnapshot {
BenchmarkSnapshot {
date: d(),
benchmark: "000852.SH".to_string(),
open: 2000.0,
close: 2000.0,
prev_close: 1990.0,
volume: 1_000_000,
}
}
#[test]
fn selector_records_structured_selection_risk_decisions() {
let data = DataSet::from_components(
vec![
instrument("000001.SZ"),
instrument("688001.SH"),
instrument("000002.SZ"),
],
vec![
market("000001.SZ", 10.0),
market("688001.SH", 10.0),
market("000002.SZ", 10.0),
],
vec![
factor("000001.SZ", 8.0),
factor("688001.SH", 9.0),
factor("000002.SZ", 10.0),
],
vec![
candidate("000001.SZ", true, false),
candidate("688001.SH", false, true),
candidate("000002.SZ", false, false),
],
vec![benchmark()],
)
.unwrap();
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
let mut risk_config = FidcRiskControlConfig::default();
risk_config.static_rules.reject_st_selection = true;
risk_config.static_rules.reject_kcb_selection = true;
let (_selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
decision_date: d(),
benchmark: &benchmark(),
reference_level: 2000.0,
data: &data,
dynamic_universe: None,
risk_config: Some(&risk_config),
defer_selection_risk: false,
});
let rules = diagnostics
.risk_decisions
.iter()
.map(|decision| decision.rule_code.as_str())
.collect::<BTreeSet<_>>();
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
assert_eq!(
diagnostics.not_eligible_count,
diagnostics.risk_decisions.len()
);
assert!(
diagnostics.risk_decisions[0]
.diagnostic_line()
.starts_with("risk_decision=")
);
}
#[test]
fn selector_applies_configured_selection_risk_on_decision_date() {
let data = DataSet::from_components(
vec![
instrument("000001.SZ"),
instrument("688001.SH"),
instrument("000002.SZ"),
],
vec![
market("000001.SZ", 10.0),
market("688001.SH", 10.0),
market("000002.SZ", 10.0),
],
vec![
factor("000001.SZ", 8.0),
factor("688001.SH", 9.0),
factor("000002.SZ", 10.0),
],
vec![
candidate("000001.SZ", true, false),
candidate("688001.SH", false, true),
candidate("000002.SZ", false, false),
],
vec![benchmark()],
)
.unwrap();
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
let mut risk_config = FidcRiskControlConfig::default();
risk_config.static_rules.reject_st_selection = true;
risk_config.static_rules.reject_kcb_selection = true;
let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
decision_date: d(),
benchmark: &benchmark(),
reference_level: 2000.0,
data: &data,
dynamic_universe: None,
risk_config: Some(&risk_config),
defer_selection_risk: false,
});
let selected_symbols = selected
.iter()
.map(|candidate| candidate.symbol.as_str())
.collect::<BTreeSet<_>>();
assert!(!selected_symbols.contains("000001.SZ"));
assert!(!selected_symbols.contains("688001.SH"));
assert!(selected_symbols.contains("000002.SZ"));
assert_eq!(diagnostics.not_eligible_count, 2);
let rules = diagnostics
.risk_decisions
.iter()
.map(|decision| decision.rule_code.as_str())
.collect::<BTreeSet<_>>();
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
}
#[test]
fn selector_can_defer_configured_selection_risk_without_losing_diagnostics() {
let data = DataSet::from_components(
vec![
instrument("000001.SZ"),
instrument("688001.SH"),
instrument("000002.SZ"),
],
vec![
market("000001.SZ", 10.0),
market("688001.SH", 10.0),
market("000002.SZ", 10.0),
],
vec![
factor("000001.SZ", 8.0),
factor("688001.SH", 9.0),
factor("000002.SZ", 10.0),
],
vec![
candidate("000001.SZ", true, false),
candidate("688001.SH", false, true),
candidate("000002.SZ", false, false),
],
vec![benchmark()],
)
.unwrap();
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
let mut risk_config = FidcRiskControlConfig::default();
risk_config.static_rules.reject_st_selection = true;
risk_config.static_rules.reject_kcb_selection = true;
let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
decision_date: d(),
benchmark: &benchmark(),
reference_level: 2000.0,
data: &data,
dynamic_universe: None,
risk_config: Some(&risk_config),
defer_selection_risk: true,
});
let selected_symbols = selected
.iter()
.map(|candidate| candidate.symbol.as_str())
.collect::<BTreeSet<_>>();
assert!(selected_symbols.contains("000001.SZ"));
assert!(selected_symbols.contains("688001.SH"));
assert!(selected_symbols.contains("000002.SZ"));
let rules = diagnostics
.risk_decisions
.iter()
.map(|decision| decision.rule_code.as_str())
.collect::<BTreeSet<_>>();
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
}
}
+41 -3
View File
@@ -17,12 +17,14 @@ fn candidate() -> CandidateEligibility {
date: d(2024, 1, 3),
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}
}
@@ -41,7 +43,7 @@ fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapsho
ask1: open,
prev_close: 10.0,
volume: 1_000_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 50_000,
ask1_volume: 50_000,
trading_phase: Some("continuous".to_string()),
@@ -65,6 +67,22 @@ fn china_cost_model_applies_minimum_commission_and_stamp_tax() {
assert!((sell.stamp_tax - 100.0).abs() < 1e-9);
}
#[test]
fn aiquant_cost_model_matches_alv_run_options() {
let model = ChinaAShareCostModel::aiquant_default();
let buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 49_978.84);
assert!((buy.commission - 14.993652).abs() < 1e-9);
assert_eq!(buy.stamp_tax, 0.0);
let sell = model.calculate(d(2026, 5, 19), OrderSide::Sell, 100_724.72);
assert!((sell.commission - 30.217416).abs() < 1e-9);
assert!((sell.stamp_tax - 50.36236).abs() < 1e-9);
let small_buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 1_000.0);
assert!((small_buy.commission - 5.0).abs() < 1e-9);
}
#[test]
fn china_cost_model_switches_stamp_tax_rate_after_2023_08_28() {
let model = ChinaAShareCostModel::default();
@@ -175,7 +193,7 @@ fn china_rule_hooks_block_buy_at_limit_up_and_sell_at_limit_down() {
}
#[test]
fn china_rule_hooks_use_tick_size_tolerance_for_price_limits() {
fn china_rule_hooks_use_strict_price_limits() {
let hooks = ChinaEquityRuleHooks;
let candidate = candidate();
@@ -184,6 +202,13 @@ fn china_rule_hooks_use_tick_size_tolerance_for_price_limits() {
..snapshot(10.9995, 11.0, 9.0)
};
let buy_check = hooks.can_buy(d(2024, 1, 3), &near_upper, &candidate, PriceField::Open);
assert!(buy_check.allowed);
let exact_upper = DailyMarketSnapshot {
price_tick: 0.001,
..snapshot(11.0, 11.0, 9.0)
};
let buy_check = hooks.can_buy(d(2024, 1, 3), &exact_upper, &candidate, PriceField::Open);
assert!(!buy_check.allowed);
let near_lower = DailyMarketSnapshot {
@@ -199,6 +224,19 @@ fn china_rule_hooks_use_tick_size_tolerance_for_price_limits() {
&position,
PriceField::Open,
);
assert!(sell_check.allowed);
let exact_lower = DailyMarketSnapshot {
price_tick: 0.001,
..snapshot(9.0, 11.0, 9.0)
};
let sell_check = hooks.can_sell(
d(2024, 1, 3),
&exact_lower,
&candidate,
&position,
PriceField::Open,
);
assert!(!sell_check.allowed);
}
@@ -223,7 +261,7 @@ fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() {
ask1: 2.53,
prev_close: 2.80,
volume: 1_000_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 50_000,
ask1_volume: 50_000,
trading_phase: Some("continuous".to_string()),
+170 -4
View File
@@ -88,10 +88,77 @@ impl Strategy for BuyAndHoldStrategy {
},
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
}
fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: 10.0,
open: 10.0,
high: 10.1,
low: 9.9,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}
}
fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
}
}
fn stock_candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}
}
fn benchmark_snapshot(date: NaiveDate) -> BenchmarkSnapshot {
BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}
}
#[test]
fn engine_reinvests_dividend_receivable_in_round_lots() {
let buy_date = d(2025, 1, 1);
@@ -122,7 +189,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -145,7 +212,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -168,7 +235,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -215,34 +282,40 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
date: buy_date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: ex_date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: payable_date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![
@@ -300,7 +373,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
PriceField::Open,
),
BacktestConfig {
initial_cash: 11_005.0,
initial_cash: 11_008.0,
benchmark_code: "000300.SH".to_string(),
start_date: Some(buy_date),
end_date: Some(payable_date),
@@ -327,3 +400,96 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
assert_eq!(reinvest_fill.commission, 0.0);
assert_eq!(reinvest_fill.stamp_tax, 0.0);
}
#[test]
fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() {
let buy_date = d(2025, 1, 1);
let ex_date = d(2025, 1, 2);
let data = DataSet::from_components_with_actions(
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "Anchor".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}],
vec![
stock_market_snapshot(buy_date),
stock_market_snapshot(ex_date),
],
vec![
stock_factor_snapshot(buy_date),
stock_factor_snapshot(ex_date),
],
vec![stock_candidate(buy_date), stock_candidate(ex_date)],
vec![benchmark_snapshot(buy_date), benchmark_snapshot(ex_date)],
vec![CorporateAction {
date: ex_date,
symbol: "000001.SZ".to_string(),
payable_date: Some(ex_date),
share_cash: 1.05,
share_bonus: 0.2,
share_gift: 0.0,
issue_quantity: 0.0,
issue_price: 0.0,
reform: false,
adjust_factor: None,
successor_symbol: None,
successor_ratio: None,
successor_cash: None,
}],
)
.expect("dataset");
let mut engine = BacktestEngine::new(
data,
BuyAndHoldStrategy {
first_date: buy_date,
},
BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
),
BacktestConfig {
initial_cash: 11_008.0,
benchmark_code: "000300.SH".to_string(),
start_date: Some(buy_date),
end_date: Some(ex_date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
)
.with_dividend_reinvestment(true);
let result = engine.run().expect("backtest run");
let final_holding = result
.holdings_summary
.iter()
.find(|row| row.symbol == "000001.SZ")
.expect("holding");
assert_eq!(final_holding.quantity, 1_300);
let reinvest_fill = result
.fills
.iter()
.find(|fill| fill.reason == "dividend_reinvestment")
.expect("reinvestment fill");
assert_eq!(reinvest_fill.quantity, 100);
assert!((reinvest_fill.price - ((10.0 - 1.05) / 1.2)).abs() < 1e-9);
assert!(
result
.position_events
.iter()
.any(|event| event.reason == "stock_split 1.200000" && event.quantity_after == 1_200)
);
assert!(
result
.account_events
.iter()
.any(|event| event.note.contains("cash_receivable_reinvested"))
);
}
@@ -0,0 +1,652 @@
use chrono::{Duration, NaiveDate, NaiveTime};
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
StrategyDecision,
};
use std::sync::{Arc, Mutex};
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
fn t(hour: u32, minute: u32, second: u32) -> NaiveTime {
NaiveTime::from_hms_opt(hour, minute, second).expect("valid time")
}
#[derive(Default)]
struct DecisionQuoteReader {
day_count: usize,
}
impl Strategy for DecisionQuoteReader {
fn name(&self) -> &str {
"decision_quote_reader"
}
fn decision_quote_times(&self) -> Vec<NaiveTime> {
vec![t(10, 40, 0)]
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
self.day_count += 1;
if self.day_count == 1 {
return Ok(StrategyDecision {
order_intents: vec![OrderIntent::Value {
symbol: "000001.SZ".to_string(),
value: 5_000.0,
reason: "seed_position".to_string(),
}],
..StrategyDecision::default()
});
}
assert!(
ctx.portfolio.position("000001.SZ").is_some(),
"second day should carry the first day position"
);
let quote_loaded_before_decision = ctx
.data
.execution_quotes_on(ctx.execution_date, "000001.SZ")
.iter()
.any(|quote| quote.timestamp.time() == t(10, 39, 59) && quote.last_price == 11.0);
assert!(
quote_loaded_before_decision,
"engine must load declared decision quote before strategy.on_day"
);
Ok(StrategyDecision::default())
}
}
#[test]
fn engine_preloads_declared_decision_quotes_for_current_positions() {
let first = d(2026, 1, 5);
let second = d(2026, 1, 6);
let data = DataSet::from_components(
Vec::new(),
vec![
DailyMarketSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.2,
low: 9.9,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 9.8,
volume: 10_000,
minute_volume: 1_000,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 10.78,
lower_limit: 8.82,
price_tick: 0.01,
},
DailyMarketSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5,
open: 10.5,
high: 11.2,
low: 10.4,
close: 10.6,
last_price: 10.6,
bid1: 10.6,
ask1: 10.6,
prev_close: 10.0,
volume: 10_000,
minute_volume: 1_000,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
},
],
vec![
DailyFactorSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
extra_factors: Default::default(),
},
],
vec![
CandidateEligibility {
date: first,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: second,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![
BenchmarkSnapshot {
date: first,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1000.0,
prev_close: 990.0,
volume: 1_000_000,
},
BenchmarkSnapshot {
date: second,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1001.0,
prev_close: 1000.0,
volume: 1_000_000,
},
],
)
.expect("dataset");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(first),
end_date: Some(second),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
};
let mut engine = BacktestEngine::new(data, DecisionQuoteReader::default(), broker, config)
.with_execution_quote_loader(move |request| {
assert_eq!(
request.end_time, None,
"decision quote preload must request latest quote at or before start_time"
);
Ok(request
.symbols
.into_iter()
.map(|symbol| IntradayExecutionQuote {
date: request.date,
symbol,
timestamp: request.date.and_time(t(10, 39, 59)),
last_price: if request.date == second { 11.0 } else { 10.0 },
bid1: if request.date == second { 11.0 } else { 10.0 },
ask1: if request.date == second { 11.0 } else { 10.0 },
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()),
})
.collect())
});
engine.run().expect("backtest should run");
}
#[test]
fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
let first = d(2026, 1, 5);
let second = d(2026, 1, 6);
let data = DataSet::from_components_with_actions_and_quotes(
Vec::new(),
vec![
DailyMarketSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.2,
low: 9.9,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 9.8,
volume: 10_000,
minute_volume: 1_000,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 10.78,
lower_limit: 8.82,
price_tick: 0.01,
},
DailyMarketSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5,
open: 10.5,
high: 11.2,
low: 10.4,
close: 10.6,
last_price: 10.6,
bid1: 10.6,
ask1: 10.6,
prev_close: 10.0,
volume: 10_000,
minute_volume: 1_000,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
},
],
vec![
DailyFactorSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
extra_factors: Default::default(),
},
],
vec![
CandidateEligibility {
date: first,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: second,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![
BenchmarkSnapshot {
date: first,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1000.0,
prev_close: 990.0,
volume: 1_000_000,
},
BenchmarkSnapshot {
date: second,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1001.0,
prev_close: 1000.0,
volume: 1_000_000,
},
],
Vec::new(),
vec![
IntradayExecutionQuote {
date: first,
symbol: "000001.SZ".to_string(),
timestamp: first.and_time(t(10, 39, 59)),
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date: second,
symbol: "000001.SZ".to_string(),
timestamp: second.and_time(t(10, 39, 59)),
last_price: 11.0,
bid1: 11.0,
ask1: 11.0,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()),
},
],
)
.expect("dataset");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(first),
end_date: Some(second),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
};
let loader_calls = Arc::new(Mutex::new(0usize));
let captured_loader_calls = Arc::clone(&loader_calls);
let mut engine = BacktestEngine::new(data, DecisionQuoteReader::default(), broker, config)
.with_execution_quote_loader(move |_| {
*captured_loader_calls.lock().expect("loader mutex") += 1;
Ok(Vec::new())
});
engine.run().expect("backtest should run");
assert_eq!(
*loader_calls.lock().expect("loader mutex"),
0,
"preloaded execution quotes should satisfy decision-time quote requests"
);
}
#[derive(Default)]
struct MultiTimeDecisionQuoteReader {
day_count: usize,
}
impl Strategy for MultiTimeDecisionQuoteReader {
fn name(&self) -> &str {
"multi_time_decision_quote_reader"
}
fn decision_quote_times(&self) -> Vec<NaiveTime> {
vec![t(10, 31, 0), t(10, 40, 0)]
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
self.day_count += 1;
if self.day_count == 1 {
return Ok(StrategyDecision {
order_intents: vec![OrderIntent::Value {
symbol: "000001.SZ".to_string(),
value: 5_000.0,
reason: "seed_position".to_string(),
}],
..StrategyDecision::default()
});
}
let quote_times = ctx
.data
.execution_quotes_on(ctx.execution_date, "000001.SZ")
.iter()
.map(|quote| quote.timestamp.time())
.collect::<Vec<_>>();
assert!(
quote_times.contains(&t(10, 30, 59)),
"10:31 decision quote must be loaded"
);
assert!(
quote_times.contains(&t(10, 39, 59)),
"10:40 decision quote must not be skipped because 10:31 was loaded"
);
Ok(StrategyDecision::default())
}
}
#[test]
fn engine_loads_distinct_decision_quote_times_on_same_day() {
let first = d(2026, 1, 5);
let second = d(2026, 1, 6);
let data = DataSet::from_components(
Vec::new(),
vec![
DailyMarketSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.2,
low: 9.9,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 9.8,
volume: 10_000,
minute_volume: 1_000,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 10.78,
lower_limit: 8.82,
price_tick: 0.01,
},
DailyMarketSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5,
open: 10.5,
high: 11.2,
low: 10.4,
close: 10.6,
last_price: 10.6,
bid1: 10.6,
ask1: 10.6,
prev_close: 10.0,
volume: 10_000,
minute_volume: 1_000,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
},
],
vec![
DailyFactorSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
extra_factors: Default::default(),
},
],
vec![
CandidateEligibility {
date: first,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: second,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![
BenchmarkSnapshot {
date: first,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1000.0,
prev_close: 990.0,
volume: 1_000_000,
},
BenchmarkSnapshot {
date: second,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1001.0,
prev_close: 1000.0,
volume: 1_000_000,
},
],
)
.expect("dataset");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(first),
end_date: Some(second),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
};
let requests = Arc::new(Mutex::new(Vec::<(NaiveDate, NaiveTime)>::new()));
let captured_requests = Arc::clone(&requests);
let mut engine = BacktestEngine::new(
data,
MultiTimeDecisionQuoteReader::default(),
broker,
config,
)
.with_execution_quote_loader(move |request| {
let start_time = request
.start_time
.expect("decision quote loader request must include start_time");
captured_requests
.lock()
.expect("request mutex")
.push((request.date, start_time));
Ok(request
.symbols
.into_iter()
.map(|symbol| IntradayExecutionQuote {
date: request.date,
symbol,
timestamp: request.date.and_time(start_time) - Duration::seconds(1),
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()),
})
.collect())
});
engine.run().expect("backtest should run");
let requests = requests.lock().expect("request mutex").clone();
assert!(
requests.contains(&(second, t(10, 31, 0))),
"second-day 10:31 quote request is required"
);
assert!(
requests.contains(&(second, t(10, 40, 0))),
"second-day 10:40 quote request must not be skipped by earlier quote"
);
}
+79 -11
View File
@@ -34,6 +34,7 @@ impl Strategy for BuyThenHoldStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
});
}
Ok(StrategyDecision::default())
@@ -43,7 +44,8 @@ impl Strategy for BuyThenHoldStrategy {
#[test]
fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() {
let date1 = d(2025, 1, 2);
let date2 = d(2025, 1, 3);
let delist_date = d(2025, 1, 3);
let date2 = d(2025, 1, 6);
let data = DataSet::from_components(
vec![
Instrument {
@@ -52,8 +54,8 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: Some(date1),
status: "delisted".to_string(),
delisted_at: Some(delist_date),
status: "active".to_string(),
},
Instrument {
symbol: "000002.SZ".to_string(),
@@ -80,7 +82,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -103,7 +105,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
ask1: 5.01,
prev_close: 5.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -113,9 +115,32 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
price_tick: 0.01,
},
DailyMarketSnapshot {
date: date2,
date: delist_date,
symbol: "000002.SZ".to_string(),
timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 5.05,
open: 5.05,
high: 5.15,
low: 5.0,
close: 5.05,
last_price: 5.05,
bid1: 5.04,
ask1: 5.06,
prev_close: 5.0,
volume: 110_000,
minute_volume: 110_000,
bid1_volume: 110_000,
ask1_volume: 110_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 5.5,
lower_limit: 4.5,
price_tick: 0.01,
},
DailyMarketSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: 5.1,
open: 5.1,
high: 5.2,
@@ -126,7 +151,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
ask1: 5.11,
prev_close: 5.0,
volume: 120_000,
tick_volume: 120_000,
minute_volume: 120_000,
bid1_volume: 120_000,
ask1_volume: 120_000,
trading_phase: Some("continuous".to_string()),
@@ -157,6 +182,16 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
date: delist_date,
symbol: "000002.SZ".to_string(),
market_cap_bn: 30.5,
free_float_cap_bn: 28.5,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
@@ -173,34 +208,53 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
date: date1,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: date1,
symbol: "000002.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: delist_date,
symbol: "000002.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: date2,
symbol: "000002.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![
@@ -212,6 +266,14 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
prev_close: 99.0,
volume: 1_000_000,
},
BenchmarkSnapshot {
date: delist_date,
benchmark: "000300.SH".to_string(),
open: 100.5,
close: 100.5,
prev_close: 100.0,
volume: 1_050_000,
},
BenchmarkSnapshot {
date: date2,
benchmark: "000300.SH".to_string(),
@@ -273,7 +335,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: Some(date2),
status: "delisted".to_string(),
status: "active".to_string(),
},
Instrument {
symbol: "000002.SZ".to_string(),
@@ -300,7 +362,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -323,7 +385,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
ask1: 20.0,
prev_close: 20.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -346,7 +408,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
ask1: 21.0,
prev_close: 20.0,
volume: 120_000,
tick_volume: 120_000,
minute_volume: 120_000,
bid1_volume: 120_000,
ask1_volume: 120_000,
trading_phase: Some("continuous".to_string()),
@@ -393,34 +455,40 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
date: date1,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: date1,
symbol: "000002.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: date2,
symbol: "000002.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![
+104 -46
View File
@@ -58,7 +58,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
ask1: 10.0,
prev_close: 9.9,
volume: 1_000_000,
tick_volume: 1_000_000,
minute_volume: 1_000_000,
bid1_volume: 1_000_000,
ask1_volume: 1_000_000,
trading_phase: Some("continuous".to_string()),
@@ -81,12 +81,14 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
@@ -115,7 +117,7 @@ fn market_row(date: NaiveDate, symbol: &str, open: f64, close: f64) -> DailyMark
ask1: close,
prev_close: open,
volume: 1_000_000,
tick_volume: 1_000_000,
minute_volume: 1_000_000,
bid1_volume: 1_000_000,
ask1_volume: 1_000_000,
trading_phase: Some("continuous".to_string()),
@@ -148,12 +150,14 @@ fn candidate_row(date: NaiveDate, symbol: &str) -> CandidateEligibility {
date,
symbol: symbol.to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}
}
@@ -292,6 +296,7 @@ impl Strategy for HookProbeStrategy {
order_intents: Vec::new(),
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
@@ -329,11 +334,12 @@ impl Strategy for AuctionOrderStrategy {
exit_symbols: BTreeSet::new(),
order_intents: vec![fidc_core::OrderIntent::Value {
symbol: "000001.SZ".to_string(),
value: 1_000.0,
value: 1_010.0,
reason: "auction_buy".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
@@ -382,6 +388,7 @@ impl Strategy for FuturesOrderStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
}
@@ -625,7 +632,7 @@ struct UniverseDirectiveStrategy {
snapshots: Rc<RefCell<Vec<String>>>,
}
struct TickProbeStrategy {
struct MinuteProbeStrategy {
seen_ticks: Rc<RefCell<Vec<String>>>,
ordered: bool,
}
@@ -714,6 +721,7 @@ impl Strategy for LimitCarryStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
}
@@ -791,13 +799,14 @@ impl Strategy for UniverseDirectiveStrategy {
order_intents,
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
}
impl Strategy for TickProbeStrategy {
impl Strategy for MinuteProbeStrategy {
fn name(&self) -> &str {
"tick-probe"
"minute-probe"
}
fn on_day(
@@ -810,26 +819,27 @@ impl Strategy for TickProbeStrategy {
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Subscribe {
symbols: BTreeSet::from(["000001.SZ".to_string()]),
reason: "subscribe_tick_probe".to_string(),
reason: "subscribe_minute_probe".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
fn on_tick(
fn on_minute(
&mut self,
ctx: &StrategyContext<'_>,
quote: &IntradayExecutionQuote,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
let visible_last = ctx
.history_bars(&quote.symbol, 9, "tick", "last", true)
.history_bars(&quote.symbol, 9, "1m", "last", true)
.iter()
.map(|value| format!("{value:.2}"))
.collect::<Vec<_>>()
.join(",");
let previous_last = ctx
.history_bars(&quote.symbol, 9, "tick", "last", false)
.history_bars(&quote.symbol, 9, "1m", "last", false)
.iter()
.map(|value| format!("{value:.2}"))
.collect::<Vec<_>>()
@@ -851,10 +861,11 @@ impl Strategy for TickProbeStrategy {
order_intents: vec![OrderIntent::Shares {
symbol: quote.symbol.clone(),
quantity: 100,
reason: "tick_buy".to_string(),
reason: "minute_buy".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
}
@@ -881,13 +892,13 @@ impl Strategy for DataApiProbeStrategy {
.map(|value| format!("{value:.2}"))
.collect::<Vec<_>>()
.join(",");
let tick_last = ctx
let minute_last = ctx
.history_bars("000001.SZ", 2, "1m", "last", true)
.iter()
.map(|value| format!("{value:.2}"))
.collect::<Vec<_>>()
.join(",");
let previous_tick_last = ctx
let previous_minute_last = ctx
.history_bars("000001.SZ", 2, "1m", "last", false)
.iter()
.map(|value| format!("{value:.2}"))
@@ -917,14 +928,18 @@ impl Strategy for DataApiProbeStrategy {
let daily_price_count = ctx
.get_price("000001.SZ", d(2025, 1, 3), ctx.execution_date, "1d")
.len();
let tick_price_count = ctx
.get_price("000001.SZ", d(2025, 1, 3), ctx.execution_date, "tick")
.len();
let minute_price_bars =
ctx.get_price("000001.SZ", d(2025, 1, 3), ctx.execution_date, "1m");
let minute_price_count = minute_price_bars.len();
let minute_frequency = minute_price_bars
.first()
.map(|bar| bar.frequency.clone())
.unwrap_or_default();
let instrument_history_count =
ctx.instruments_history(&["000001.SZ", "000002.SZ"]).len();
let active_instrument_count = ctx.active_instruments(&["000001.SZ", "000002.SZ"]).len();
self.snapshots.borrow_mut().push(format!(
"daily={daily_close};previous={previous_close};tick={tick_last};previous_tick={previous_tick_last};current={current_close};instrument={instrument_name};all={};history={instrument_history_count};active={active_instrument_count};range={trading_date_count};prev={prev_date};next={next_date};suspended={suspended};st={st_flags};price_daily={daily_price_count};price_tick={tick_price_count}",
"daily={daily_close};previous={previous_close};minute={minute_last};previous_minute={previous_minute_last};current={current_close};instrument={instrument_name};all={};history={instrument_history_count};active={active_instrument_count};range={trading_date_count};prev={prev_date};next={next_date};suspended={suspended};st={st_flags};price_daily={daily_price_count};price_tick={minute_price_count};minute_frequency={minute_frequency}",
ctx.all_instruments().len()
));
}
@@ -952,6 +967,7 @@ impl Strategy for OrderInspectionStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
@@ -1009,6 +1025,7 @@ impl Strategy for AccountFlowStrategy {
],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
@@ -1050,7 +1067,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -1073,7 +1090,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
ask1: 10.1,
prev_close: 10.0,
volume: 110_000,
tick_volume: 110_000,
minute_volume: 110_000,
bid1_volume: 110_000,
ask1_volume: 110_000,
trading_phase: Some("continuous".to_string()),
@@ -1110,23 +1127,27 @@ fn engine_runs_strategy_hooks_in_daily_order() {
date: date1,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: date2,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![
@@ -1244,7 +1265,7 @@ fn engine_executes_open_auction_decisions_before_on_day() {
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("open_auction".to_string()),
@@ -1267,12 +1288,14 @@ fn engine_executes_open_auction_decisions_before_on_day() {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
@@ -1342,7 +1365,7 @@ fn engine_executes_futures_order_intents_against_future_account() {
ask1: 10.0,
prev_close: 9.9,
volume: 1_000_000,
tick_volume: 1_000_000,
minute_volume: 1_000_000,
bid1_volume: 1_000_000,
ask1_volume: 1_000_000,
trading_phase: Some("continuous".to_string()),
@@ -1365,12 +1388,14 @@ fn engine_executes_futures_order_intents_against_future_account() {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
@@ -1832,7 +1857,7 @@ fn engine_sweeps_futures_order_book_depth_when_available() {
ChinaEquityRuleHooks::default(),
PriceField::Last,
)
.with_matching_type(MatchingType::CounterpartyOffer);
.with_matching_type(MatchingType::MinuteBestCounterparty);
let mut engine = BacktestEngine::new(
data,
FuturesDepthLimitOrderStrategy,
@@ -1905,7 +1930,7 @@ fn strategy_context_exposes_advanced_data_helpers() {
}
#[test]
fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
fn engine_runs_minute_hooks_and_executes_minute_orders() {
let date = d(2025, 1, 2);
let data = DataSet::from_components_with_actions_and_quotes(
vec![Instrument {
@@ -1931,7 +1956,7 @@ fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
ask1: 10.2,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -1954,12 +1979,14 @@ fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
@@ -2002,7 +2029,7 @@ fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
.expect("dataset");
let seen_ticks = Rc::new(RefCell::new(Vec::new()));
let strategy = TickProbeStrategy {
let strategy = MinuteProbeStrategy {
seen_ticks: seen_ticks.clone(),
ordered: false,
};
@@ -2035,25 +2062,25 @@ fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
]
);
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].reason, "tick_buy");
assert_eq!(result.fills[0].reason, "minute_buy");
assert_eq!(result.fills[0].quantity, 100);
assert!(
result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::PreTick)
.any(|event| event.kind == ProcessEventKind::PreMinute)
);
assert!(
result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::Tick)
.any(|event| event.kind == ProcessEventKind::Minute)
);
assert!(
result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::PostTick)
.any(|event| event.kind == ProcessEventKind::PostMinute)
);
}
@@ -2103,7 +2130,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
ask1: close + 0.01,
prev_close,
volume,
tick_volume: volume,
minute_volume: volume,
bid1_volume: volume,
ask1_volume: volume,
trading_phase: Some("continuous".to_string()),
@@ -2137,12 +2164,14 @@ fn strategy_context_exposes_engine_native_data_helpers() {
date,
symbol: "000001.SZ".to_string(),
is_st,
is_star_st: false,
is_new_listing: false,
is_paused,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
})
.collect::<Vec<_>>();
let benchmarks = [
@@ -2241,7 +2270,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
assert_eq!(
snapshots.borrow().as_slice(),
[
"daily=10.10,10.20;previous=10.00,10.10;tick=10.15,10.25;previous_tick=10.15;current=10.20;instrument=Anchor;all=2;history=2;active=1;range=3;prev=2025-01-03;next=2025-01-06;suspended=0,1,0;st=0,1,0;price_daily=2;price_tick=3"
"daily=10.10,10.20;previous=10.00,10.10;minute=10.15,10.25;previous_minute=10.15;current=10.20;instrument=Anchor;all=2;history=2;active=1;range=3;prev=2025-01-03;next=2025-01-06;suspended=0,1,0;st=0,1,0;price_daily=2;price_tick=3;minute_frequency=1m"
]
);
}
@@ -2273,7 +2302,7 @@ fn strategy_context_exposes_final_order_runtime_view() {
ask1: 10.2,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -2296,12 +2325,14 @@ fn strategy_context_exposes_final_order_runtime_view() {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
@@ -2489,7 +2520,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
ask1: 10.01,
prev_close: 9.9,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -2512,7 +2543,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
ask1: 10.01,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -2549,23 +2580,27 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
date: date1,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: date2,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![
@@ -2671,7 +2706,7 @@ fn engine_rejects_pending_limit_orders_at_market_close() {
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -2694,7 +2729,7 @@ fn engine_rejects_pending_limit_orders_at_market_close() {
ask1: 9.7,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -2731,23 +2766,27 @@ fn engine_rejects_pending_limit_orders_at_market_close() {
date: date1,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: date2,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![
@@ -2839,7 +2878,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
ask1: 10.0,
prev_close: 9.9,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("open_auction".to_string()),
@@ -2862,7 +2901,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
ask1: 10.1,
prev_close: 10.0,
volume: 110_000,
tick_volume: 110_000,
minute_volume: 110_000,
bid1_volume: 110_000,
ask1_volume: 110_000,
trading_phase: Some("open_auction".to_string()),
@@ -2885,7 +2924,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
ask1: 10.2,
prev_close: 10.1,
volume: 120_000,
tick_volume: 120_000,
minute_volume: 120_000,
bid1_volume: 120_000,
ask1_volume: 120_000,
trading_phase: Some("open_auction".to_string()),
@@ -2932,34 +2971,40 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
date: date1,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: date2,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: date3,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![
@@ -3085,7 +3130,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
ask1: 10.0,
prev_close: 9.9,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("open_auction".to_string()),
@@ -3108,7 +3153,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
ask1: 10.1,
prev_close: 10.0,
volume: 110_000,
tick_volume: 110_000,
minute_volume: 110_000,
bid1_volume: 110_000,
ask1_volume: 110_000,
trading_phase: Some("open_auction".to_string()),
@@ -3131,7 +3176,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
ask1: 10.2,
prev_close: 10.1,
volume: 120_000,
tick_volume: 120_000,
minute_volume: 120_000,
bid1_volume: 120_000,
ask1_volume: 120_000,
trading_phase: Some("open_auction".to_string()),
@@ -3178,34 +3223,40 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
date: date1,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: date2,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: date3,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![
@@ -3462,7 +3513,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
ask1: 10.01,
prev_close: 9.95,
volume: 100_000,
tick_volume: 5_000,
minute_volume: 5_000,
bid1_volume: 2_000,
ask1_volume: 2_000,
trading_phase: Some("continuous".to_string()),
@@ -3485,7 +3536,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
ask1: 20.01,
prev_close: 19.95,
volume: 100_000,
tick_volume: 5_000,
minute_volume: 5_000,
bid1_volume: 2_000,
ask1_volume: 2_000,
trading_phase: Some("continuous".to_string()),
@@ -3532,23 +3583,27 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
date: *date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date: *date,
symbol: "000002.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
]
})
@@ -3642,7 +3697,7 @@ fn engine_exposes_current_process_context_to_strategies() {
ask1: 10.0,
prev_close: 9.9,
volume: 100_000,
tick_volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
@@ -3665,12 +3720,14 @@ fn engine_exposes_current_process_context_to_strategies() {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
@@ -3734,11 +3791,12 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Value {
symbol: "601028.SH".to_string(),
value: 1_000.0,
value: 1_010.0,
reason: "seed_position".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
});
}
Ok(StrategyDecision::default())
File diff suppressed because it is too large Load Diff
@@ -1,93 +0,0 @@
use fidc_core::DataSet;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> PathBuf {
let uniq = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let dir = std::env::temp_dir().join(format!("fidc-bt-partitioned-{uniq}"));
fs::create_dir_all(&dir).expect("mkdir temp");
dir
}
#[test]
fn can_load_partitioned_snapshot_dir() {
let dir = temp_dir();
fs::create_dir_all(dir.join("benchmark/2024/01")).unwrap();
fs::create_dir_all(dir.join("market/2024/01")).unwrap();
fs::create_dir_all(dir.join("factors/2024/01")).unwrap();
fs::create_dir_all(dir.join("candidates/2024/01")).unwrap();
fs::create_dir_all(dir.join("corporate_actions/2024/01")).unwrap();
fs::write(
dir.join("instruments.csv"),
"symbol,name,board,round_lot,listed_at,delisted_at,status\n000001.SZ,PingAn,SZ,100,2020-01-01,,active\n",
)
.unwrap();
fs::write(
dir.join("benchmark/2024/01/2024-01-02.csv"),
"date,benchmark,open,close,prev_close,volume\n2024-01-02,CSI300.DEMO,2990,3000,2980,100000000\n",
)
.unwrap();
fs::write(
dir.join("market/2024/01/2024-01-02.csv"),
"date,symbol,open,high,low,close,prev_close,volume,paused,upper_limit,lower_limit,day_open,last_price,bid1,ask1,price_tick\n2024-01-02,000001.SZ,10,10.5,9.9,10.2,10,100000,false,11,9,10.1,10.15,10.14,10.16,0.01\n",
)
.unwrap();
fs::write(
dir.join("factors/2024/01/2024-01-02.csv"),
"date,symbol,market_cap_bn,free_float_cap_bn,pe_ttm,turnover_ratio,effective_turnover_ratio\n2024-01-02,000001.SZ,40,35,12,3.2,2.1\n",
)
.unwrap();
fs::write(
dir.join("candidates/2024/01/2024-01-02.csv"),
"date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell,is_kcb,is_one_yuan\n2024-01-02,000001.SZ,false,false,false,true,true,false,false\n",
)
.unwrap();
fs::write(
dir.join("corporate_actions/2024/01/2024-01-02.csv"),
"date,symbol,payable_date,share_cash,share_bonus,share_gift,issue_quantity,issue_price,reform,adjust_factor\n2024-01-02,000001.SZ,2024-01-05,0.5,0.1,0.0,0,0,false,1.05\n",
)
.unwrap();
let data = DataSet::from_partitioned_dir(&dir).expect("partitioned dataset");
assert_eq!(data.benchmark_code(), "CSI300.DEMO");
assert!(
data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap())
.len()
== 1
);
let market_rows =
data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
let snapshot = market_rows.first().expect("market snapshot");
assert_eq!(snapshot.day_open, 10.1);
assert_eq!(snapshot.last_price, 10.15);
assert_eq!(snapshot.price_tick, 0.01);
assert_eq!(
data.instruments()
.get("000001.SZ")
.expect("instrument")
.round_lot,
100
);
assert_eq!(
data.instruments()
.get("000001.SZ")
.expect("instrument")
.listed_at,
Some(chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap())
);
let actions = data.corporate_actions_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0].payable_date,
Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 5).unwrap())
);
assert!((actions[0].share_cash - 0.5).abs() < 1e-9);
assert!((actions[0].split_ratio() - 1.1).abs() < 1e-9);
let _ = fs::remove_dir_all(&dir);
}
+572 -6
View File
@@ -1,15 +1,582 @@
use chrono::NaiveDate;
use fidc_core::{
CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DataSet, OmniMicroCapConfig,
BenchmarkSnapshot, CandidateEligibility, CnSmallCapRotationConfig, CnSmallCapRotationStrategy,
DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, OmniMicroCapConfig,
OmniMicroCapStrategy, PortfolioState, Strategy, StrategyContext,
};
use std::collections::BTreeSet;
use std::path::PathBuf;
fn d(value: &str) -> NaiveDate {
NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap()
}
fn instrument(symbol: &str, name: &str) -> Instrument {
Instrument {
symbol: symbol.to_string(),
name: name.to_string(),
board: "Main".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}
}
fn market(
date: &str,
symbol: &str,
open: f64,
high: f64,
low: f64,
close: f64,
prev_close: f64,
volume: u64,
paused: bool,
) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date: d(date),
symbol: symbol.to_string(),
timestamp: None,
day_open: open,
open,
high,
low,
close,
last_price: close,
bid1: close,
ask1: close,
prev_close,
volume,
minute_volume: 0,
bid1_volume: 0,
ask1_volume: 0,
trading_phase: None,
paused,
upper_limit: (prev_close * 1.10 * 100.0).round() / 100.0,
lower_limit: (prev_close * 0.90 * 100.0).round() / 100.0,
price_tick: 0.01,
}
}
fn factor(
date: &str,
symbol: &str,
market_cap_bn: f64,
free_float_cap_bn: f64,
) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date: d(date),
symbol: symbol.to_string(),
market_cap_bn,
free_float_cap_bn,
pe_ttm: 18.0,
turnover_ratio: None,
effective_turnover_ratio: None,
extra_factors: Default::default(),
}
}
fn candidate(
date: &str,
symbol: &str,
is_new_listing: bool,
is_paused: bool,
allow_buy: bool,
allow_sell: bool,
) -> CandidateEligibility {
CandidateEligibility {
date: d(date),
symbol: symbol.to_string(),
is_st: false,
is_star_st: false,
is_new_listing,
is_paused,
allow_buy,
allow_sell,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}
}
fn benchmark(date: &str, open: f64, close: f64, prev_close: f64, volume: u64) -> BenchmarkSnapshot {
BenchmarkSnapshot {
date: d(date),
benchmark: "CSI300.DEMO".to_string(),
open,
close,
prev_close,
volume,
}
}
fn strategy_test_dataset() -> DataSet {
let dates = [
"2024-01-02",
"2024-01-03",
"2024-01-04",
"2024-01-05",
"2024-01-08",
"2024-01-09",
"2024-01-10",
"2024-01-11",
"2024-01-12",
];
let instruments = vec![
instrument("000001.SZ", "Alpha Components"),
instrument("000002.SZ", "Beta Precision"),
instrument("000003.SZ", "Charlie Materials"),
instrument("600001.SH", "Delta Industrials"),
];
let market = vec![
market(
"2024-01-02",
"000001.SZ",
10.0,
10.2,
9.9,
10.1,
9.8,
1_200_000,
false,
),
market(
"2024-01-02",
"000002.SZ",
11.0,
11.3,
10.9,
11.2,
10.8,
1_100_000,
false,
),
market(
"2024-01-02",
"000003.SZ",
8.0,
8.1,
7.8,
7.9,
8.0,
900_000,
false,
),
market(
"2024-01-02",
"600001.SH",
15.0,
15.2,
14.9,
15.1,
15.0,
800_000,
false,
),
market(
"2024-01-03",
"000001.SZ",
10.2,
10.5,
10.1,
10.4,
10.1,
1_250_000,
false,
),
market(
"2024-01-03",
"000002.SZ",
11.2,
11.6,
11.1,
11.5,
11.2,
1_120_000,
false,
),
market(
"2024-01-03",
"000003.SZ",
7.8,
7.9,
7.3,
7.4,
7.9,
930_000,
false,
),
market(
"2024-01-03",
"600001.SH",
15.1,
15.3,
15.0,
15.2,
15.1,
820_000,
false,
),
market(
"2024-01-04",
"000001.SZ",
10.5,
10.8,
10.4,
10.7,
10.4,
1_280_000,
false,
),
market(
"2024-01-04",
"000002.SZ",
11.4,
11.9,
11.3,
11.8,
11.5,
1_150_000,
false,
),
market(
"2024-01-04",
"000003.SZ",
7.3,
7.4,
7.0,
7.1,
7.4,
940_000,
false,
),
market(
"2024-01-04",
"600001.SH",
15.2,
15.5,
15.1,
15.4,
15.2,
830_000,
false,
),
market(
"2024-01-05",
"000001.SZ",
10.8,
11.1,
10.7,
11.0,
10.7,
1_300_000,
false,
),
market(
"2024-01-05",
"000002.SZ",
11.9,
12.1,
11.8,
12.0,
11.8,
1_180_000,
false,
),
market(
"2024-01-05",
"000003.SZ",
7.0,
7.1,
6.8,
6.9,
7.1,
950_000,
false,
),
market(
"2024-01-05",
"600001.SH",
15.4,
15.6,
15.3,
15.5,
15.4,
840_000,
false,
),
market(
"2024-01-08",
"000001.SZ",
11.1,
11.6,
11.0,
11.5,
11.0,
1_400_000,
false,
),
market(
"2024-01-08",
"000002.SZ",
12.1,
12.5,
12.0,
12.4,
12.0,
1_200_000,
false,
),
market(
"2024-01-08",
"000003.SZ",
7.0,
7.3,
6.9,
7.2,
6.9,
980_000,
false,
),
market(
"2024-01-08",
"600001.SH",
15.5,
15.7,
15.4,
15.6,
15.5,
850_000,
false,
),
market(
"2024-01-09",
"000001.SZ",
11.6,
12.4,
11.5,
12.3,
11.5,
1_500_000,
false,
),
market(
"2024-01-09",
"000002.SZ",
12.5,
12.9,
12.4,
12.8,
12.4,
1_250_000,
false,
),
market(
"2024-01-09",
"000003.SZ",
7.2,
7.5,
7.1,
7.4,
7.2,
990_000,
false,
),
market(
"2024-01-09",
"600001.SH",
15.6,
15.7,
15.4,
15.5,
15.6,
860_000,
false,
),
market(
"2024-01-10",
"000001.SZ",
12.2,
12.3,
11.9,
12.0,
12.3,
1_450_000,
false,
),
market(
"2024-01-10",
"000002.SZ",
12.7,
12.8,
12.5,
12.6,
12.8,
1_220_000,
false,
),
market(
"2024-01-10",
"000003.SZ",
7.5,
7.6,
7.4,
7.5,
7.4,
1_000_000,
false,
),
market(
"2024-01-10",
"600001.SH",
15.4,
15.5,
15.1,
15.2,
15.5,
870_000,
false,
),
market(
"2024-01-11",
"000001.SZ",
12.0,
12.1,
11.5,
11.6,
12.0,
1_420_000,
false,
),
market(
"2024-01-11",
"000002.SZ",
12.5,
12.6,
12.1,
12.2,
12.6,
1_210_000,
false,
),
market(
"2024-01-11",
"000003.SZ",
7.4,
7.5,
7.2,
7.3,
7.5,
980_000,
false,
),
market(
"2024-01-11",
"600001.SH",
15.2,
15.2,
15.2,
15.2,
15.2,
0,
true,
),
market(
"2024-01-12",
"000001.SZ",
11.5,
11.6,
11.1,
11.2,
11.6,
1_380_000,
false,
),
market(
"2024-01-12",
"000002.SZ",
12.1,
12.2,
11.8,
11.9,
12.2,
1_190_000,
false,
),
market(
"2024-01-12",
"000003.SZ",
7.2,
7.2,
6.9,
7.0,
7.3,
960_000,
false,
),
market(
"2024-01-12",
"600001.SH",
14.8,
15.0,
14.7,
14.9,
15.2,
850_000,
false,
),
];
let factors = dates
.iter()
.enumerate()
.flat_map(|(idx, date)| {
let i = idx as f64;
[
factor(date, "000001.SZ", 38.0 + i, 24.0 + i * 0.5),
factor(date, "000002.SZ", 45.0 + i, 30.0 + i * 0.5),
factor(date, "000003.SZ", 65.0 - i, 40.0 - i * 0.5),
factor(date, "600001.SH", 85.0 + i, 55.0 + i * 0.5),
]
})
.collect::<Vec<_>>();
let candidates = dates
.iter()
.flat_map(|date| {
let first_two = *date == "2024-01-02" || *date == "2024-01-03";
let paused_600001 = *date == "2024-01-11";
[
candidate(date, "000001.SZ", first_two, false, !first_two, true),
candidate(date, "000002.SZ", false, false, true, true),
candidate(date, "000003.SZ", false, false, true, true),
candidate(
date,
"600001.SH",
false,
paused_600001,
!paused_600001,
!paused_600001,
),
]
})
.collect::<Vec<_>>();
let benchmarks = vec![
benchmark("2024-01-02", 2990.0, 3000.0, 2980.0, 100_000_000),
benchmark("2024-01-03", 3005.0, 3020.0, 3000.0, 102_000_000),
benchmark("2024-01-04", 3025.0, 3050.0, 3020.0, 105_000_000),
benchmark("2024-01-05", 3055.0, 3080.0, 3050.0, 108_000_000),
benchmark("2024-01-08", 3085.0, 3110.0, 3080.0, 109_000_000),
benchmark("2024-01-09", 3100.0, 3090.0, 3110.0, 107_000_000),
benchmark("2024-01-10", 3080.0, 3040.0, 3090.0, 111_000_000),
benchmark("2024-01-11", 3030.0, 2990.0, 3040.0, 115_000_000),
benchmark("2024-01-12", 2980.0, 2950.0, 2990.0, 118_000_000),
];
DataSet::from_components(instruments, market, factors, candidates, benchmarks)
.expect("strategy test dataset")
}
#[test]
fn strategy_emits_target_weights_and_diagnostics() {
let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data/demo");
let data = DataSet::from_csv_dir(&data_dir).expect("demo data");
let data = strategy_test_dataset();
let decision_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let execution_date = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
let portfolio = PortfolioState::new(1_000_000.0);
@@ -53,8 +620,7 @@ fn strategy_emits_target_weights_and_diagnostics() {
#[test]
fn omni_strategy_emits_same_day_decision() {
let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data/demo");
let data = DataSet::from_csv_dir(&data_dir).expect("demo data");
let data = strategy_test_dataset();
let execution_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let portfolio = PortfolioState::new(1_000_000.0);
let mut cfg = OmniMicroCapConfig::omni_microcap();
@@ -1,63 +0,0 @@
let refresh_rate = 15;
let stocknum = 40;
let close_rate = 1.07;
let loss_rate = 0.93;
let rsi_rate = 1.0001;
let trade_rate = 0.5;
let xs = 4 / 500;
let base_index_level = 2000;
let base_cap_floor = 3;
let base_cap_ceiling = 28;
fn band_start(current_price, base_index_level, xs, base_cap_floor) {
if current_price == base_index_level {
base_cap_floor
} else if current_price > 0 {
round((current_price - base_index_level) * xs + base_cap_floor)
} else {
base_cap_floor
}
}
fn band_end(current_price, base_index_level, xs, base_cap_ceiling) {
if current_price == base_index_level {
base_cap_ceiling
} else if current_price > 0 {
round((current_price - base_index_level) * xs + base_cap_ceiling)
} else {
base_cap_ceiling
}
}
strategy("microcap_volume_trend_000852") {
market("CN_A")
benchmark("000852.SH")
signal("000852.SH")
rebalance.every_days(refresh_rate).at("10:18")
universe.exclude("paused", "st", "kcb", "one_yuan", "new_listing")
selection.limit(stocknum)
selection.market_cap_band(
field="market_cap",
lower=band_start(signal_close, base_index_level, xs, base_cap_floor),
upper=band_end(signal_close, base_index_level, xs, base_cap_ceiling)
)
risk.index_exposure(
signal_ma5 > signal_ma10 * rsi_rate ? 1.0 : trade_rate
)
filter.stock_expr(
stock_ma5 > stock_ma10 * rsi_rate &&
stock_ma10 > stock_ma30 * rsi_rate &&
rolling_mean("volume", 5) < rolling_mean("volume", 60)
)
risk.take_profit(close_rate)
risk.stop_loss(loss_rate)
allocation.buy_scale(touched_upper_limit ? 1.0 : trade_rate)
ordering.rank_by("market_cap", "asc")
}
@@ -1,41 +0,0 @@
{
"strategyId": "microcap_volume_trend_000852",
"version": "2",
"parser": "omniquant-engine-script-v2",
"market": "CN_A",
"signalSymbol": "000852.SH",
"benchmark": {
"instrumentId": "000852.SH",
"fallbackInstrumentId": "000852.SH"
},
"engineConfig": {
"market": "CN_A",
"signalSymbol": "000852.SH",
"benchmarkSymbol": "000852.SH",
"refreshRate": 15,
"rankLimit": 40
},
"runtimeExpressions": {
"prelude": "let refresh_rate = 15;\nlet stocknum = 40;\nlet close_rate = 1.07;\nlet loss_rate = 0.93;\nlet rsi_rate = 1.0001;\nlet trade_rate = 0.5;\nlet xs = 4 / 500;\nlet base_index_level = 2000;\nlet base_cap_floor = 3;\nlet base_cap_ceiling = 28;\nfn band_start(current_price, base_index_level, xs, base_cap_floor) {\n if current_price == base_index_level {\n base_cap_floor\n } else if current_price > 0 {\n round((current_price - base_index_level) * xs + base_cap_floor)\n } else {\n base_cap_floor\n }\n}\nfn band_end(current_price, base_index_level, xs, base_cap_ceiling) {\n if current_price == base_index_level {\n base_cap_ceiling\n } else if current_price > 0 {\n round((current_price - base_index_level) * xs + base_cap_ceiling)\n } else {\n base_cap_ceiling\n }\n}",
"selection": {
"limitExpr": "stocknum",
"marketCapField": "market_cap",
"marketCapLowerExpr": "band_start(signal_close, base_index_level, xs, base_cap_floor)",
"marketCapUpperExpr": "band_end(signal_close, base_index_level, xs, base_cap_ceiling)",
"stockFilterExpr": "stock_ma5 > stock_ma10 * rsi_rate && stock_ma10 > stock_ma30 * rsi_rate && rolling_mean(\"volume\", 5) < rolling_mean(\"volume\", 60)"
},
"risk": {
"exposureExpr": "signal_ma5 > signal_ma10 * rsi_rate ? 1.0 : trade_rate",
"stopLossExpr": "loss_rate",
"takeProfitExpr": "close_rate"
},
"allocation": {
"buyScaleExpr": "touched_upper_limit ? 1.0 : trade_rate"
},
"ordering": {
"rankBy": "market_cap",
"rankExpr": "",
"rankOrder": "asc"
}
}
}
@@ -1,42 +0,0 @@
let refresh_rate = 15;
let stocknum = 40;
let xs = 0.008;
let base_index_level = 2000;
let lower_offset = 3;
let upper_offset = 28;
fn cap_floor(current_price, base_index_level, xs, lower_offset) {
round((current_price - base_index_level) * xs + lower_offset)
}
fn cap_ceiling(current_price, base_index_level, xs, upper_offset) {
round((current_price - base_index_level) * xs + upper_offset)
}
strategy("ai_generated_000001_open_cap_band") {
market("CN_A")
benchmark("000852.SH")
signal("000001.SH")
rebalance.every_days(refresh_rate).at("10:18")
universe.exclude("paused", "st", "kcb", "one_yuan", "new_listing")
selection.limit(stocknum)
selection.market_cap_band(
field="market_cap",
lower=cap_floor(signal_open, base_index_level, xs, lower_offset),
upper=cap_ceiling(signal_open, base_index_level, xs, upper_offset)
)
filter.stock_expr(
stock_ma5 > stock_ma10 &&
stock_ma10 > stock_ma30 &&
rolling_mean("volume", 5) < rolling_mean("volume", 60) &&
!ends_with(symbol, ".BJ") &&
!at_upper_limit &&
!at_lower_limit
)
ordering.rank_by("market_cap", "asc")
}
@@ -1,33 +0,0 @@
{
"strategyId": "ai_generated_000001_open_cap_band",
"version": "2",
"parser": "omniquant-engine-script-v2",
"market": "CN_A",
"signalSymbol": "000001.SH",
"benchmark": {
"instrumentId": "000852.SH",
"fallbackInstrumentId": "000852.SH"
},
"engineConfig": {
"market": "CN_A",
"signalSymbol": "000001.SH",
"benchmarkSymbol": "000852.SH",
"refreshRate": 15,
"rankLimit": 40
},
"runtimeExpressions": {
"prelude": "let refresh_rate = 15;\nlet stocknum = 40;\nlet xs = 0.008;\nlet base_index_level = 2000;\nlet lower_offset = 3;\nlet upper_offset = 28;\n\nfn cap_floor(current_price, base_index_level, xs, lower_offset) {\nround((current_price - base_index_level) * xs + lower_offset)\n}\n\nfn cap_ceiling(current_price, base_index_level, xs, upper_offset) {\nround((current_price - base_index_level) * xs + upper_offset)\n}",
"selection": {
"limitExpr": "stocknum",
"marketCapField": "market_cap",
"marketCapLowerExpr": "cap_floor(signal_open, base_index_level, xs, lower_offset)",
"marketCapUpperExpr": "cap_ceiling(signal_open, base_index_level, xs, upper_offset)",
"stockFilterExpr": "stock_ma5 > stock_ma10 && stock_ma10 > stock_ma30 && rolling_mean(\"volume\", 5) < rolling_mean(\"volume\", 60) && !ends_with(symbol, \".BJ\") && !at_upper_limit && !at_lower_limit"
},
"ordering": {
"rankBy": "market_cap",
"rankExpr": "",
"rankOrder": "asc"
}
}
}
-10
View File
@@ -1,10 +0,0 @@
date,benchmark,open,close,prev_close,volume
2024-01-02,CSI300.DEMO,2990,3000,2980,100000000
2024-01-03,CSI300.DEMO,3005,3020,3000,102000000
2024-01-04,CSI300.DEMO,3025,3050,3020,105000000
2024-01-05,CSI300.DEMO,3055,3080,3050,108000000
2024-01-08,CSI300.DEMO,3085,3110,3080,109000000
2024-01-09,CSI300.DEMO,3100,3090,3110,107000000
2024-01-10,CSI300.DEMO,3080,3040,3090,111000000
2024-01-11,CSI300.DEMO,3030,2990,3040,115000000
2024-01-12,CSI300.DEMO,2980,2950,2990,118000000
1 date benchmark open close prev_close volume
2 2024-01-02 CSI300.DEMO 2990 3000 2980 100000000
3 2024-01-03 CSI300.DEMO 3005 3020 3000 102000000
4 2024-01-04 CSI300.DEMO 3025 3050 3020 105000000
5 2024-01-05 CSI300.DEMO 3055 3080 3050 108000000
6 2024-01-08 CSI300.DEMO 3085 3110 3080 109000000
7 2024-01-09 CSI300.DEMO 3100 3090 3110 107000000
8 2024-01-10 CSI300.DEMO 3080 3040 3090 111000000
9 2024-01-11 CSI300.DEMO 3030 2990 3040 115000000
10 2024-01-12 CSI300.DEMO 2980 2950 2990 118000000
-37
View File
@@ -1,37 +0,0 @@
date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell,is_kcb,is_one_yuan
2024-01-02,000001.SZ,false,true,false,false,true,false,false
2024-01-02,000002.SZ,false,false,false,true,true,false,false
2024-01-02,000003.SZ,false,false,false,true,true,false,false
2024-01-02,600001.SH,false,false,false,true,true,false,false
2024-01-03,000001.SZ,false,true,false,false,true,false,false
2024-01-03,000002.SZ,false,false,false,true,true,false,false
2024-01-03,000003.SZ,false,false,false,true,true,false,false
2024-01-03,600001.SH,false,false,false,true,true,false,false
2024-01-04,000001.SZ,false,false,false,true,true,false,false
2024-01-04,000002.SZ,false,false,false,true,true,false,false
2024-01-04,000003.SZ,false,false,false,true,true,false,false
2024-01-04,600001.SH,false,false,false,true,true,false,false
2024-01-05,000001.SZ,false,false,false,true,true,false,false
2024-01-05,000002.SZ,false,false,false,true,true,false,false
2024-01-05,000003.SZ,false,false,false,true,true,false,false
2024-01-05,600001.SH,false,false,false,true,true,false,false
2024-01-08,000001.SZ,false,false,false,true,true,false,false
2024-01-08,000002.SZ,false,false,false,true,true,false,false
2024-01-08,000003.SZ,false,false,false,true,true,false,false
2024-01-08,600001.SH,false,false,false,true,true,false,false
2024-01-09,000001.SZ,false,false,false,true,true,false,false
2024-01-09,000002.SZ,false,false,false,true,true,false,false
2024-01-09,000003.SZ,false,false,false,true,true,false,false
2024-01-09,600001.SH,false,false,false,true,true,false,false
2024-01-10,000001.SZ,false,false,false,true,true,false,false
2024-01-10,000002.SZ,false,false,false,true,true,false,false
2024-01-10,000003.SZ,false,false,false,true,true,false,false
2024-01-10,600001.SH,false,false,false,true,true,false,false
2024-01-11,000001.SZ,false,false,false,true,true,false,false
2024-01-11,000002.SZ,false,false,false,true,true,false,false
2024-01-11,000003.SZ,false,false,false,true,true,false,false
2024-01-11,600001.SH,false,false,true,false,false,false,false
2024-01-12,000001.SZ,false,false,false,true,true,false,false
2024-01-12,000002.SZ,false,false,false,true,true,false,false
2024-01-12,000003.SZ,false,false,false,true,true,false,false
2024-01-12,600001.SH,false,false,false,true,true,false,false
1 date symbol is_st is_new_listing is_paused allow_buy allow_sell is_kcb is_one_yuan
2 2024-01-02 000001.SZ false true false false true false false
3 2024-01-02 000002.SZ false false false true true false false
4 2024-01-02 000003.SZ false false false true true false false
5 2024-01-02 600001.SH false false false true true false false
6 2024-01-03 000001.SZ false true false false true false false
7 2024-01-03 000002.SZ false false false true true false false
8 2024-01-03 000003.SZ false false false true true false false
9 2024-01-03 600001.SH false false false true true false false
10 2024-01-04 000001.SZ false false false true true false false
11 2024-01-04 000002.SZ false false false true true false false
12 2024-01-04 000003.SZ false false false true true false false
13 2024-01-04 600001.SH false false false true true false false
14 2024-01-05 000001.SZ false false false true true false false
15 2024-01-05 000002.SZ false false false true true false false
16 2024-01-05 000003.SZ false false false true true false false
17 2024-01-05 600001.SH false false false true true false false
18 2024-01-08 000001.SZ false false false true true false false
19 2024-01-08 000002.SZ false false false true true false false
20 2024-01-08 000003.SZ false false false true true false false
21 2024-01-08 600001.SH false false false true true false false
22 2024-01-09 000001.SZ false false false true true false false
23 2024-01-09 000002.SZ false false false true true false false
24 2024-01-09 000003.SZ false false false true true false false
25 2024-01-09 600001.SH false false false true true false false
26 2024-01-10 000001.SZ false false false true true false false
27 2024-01-10 000002.SZ false false false true true false false
28 2024-01-10 000003.SZ false false false true true false false
29 2024-01-10 600001.SH false false false true true false false
30 2024-01-11 000001.SZ false false false true true false false
31 2024-01-11 000002.SZ false false false true true false false
32 2024-01-11 000003.SZ false false false true true false false
33 2024-01-11 600001.SH false false true false false false false
34 2024-01-12 000001.SZ false false false true true false false
35 2024-01-12 000002.SZ false false false true true false false
36 2024-01-12 000003.SZ false false false true true false false
37 2024-01-12 600001.SH false false false true true false false
-37
View File
@@ -1,37 +0,0 @@
date,symbol,market_cap_bn,free_float_cap_bn,pe_ttm
2024-01-02,000001.SZ,38,24,18
2024-01-02,000002.SZ,45,30,20
2024-01-02,000003.SZ,65,40,15
2024-01-02,600001.SH,85,55,13
2024-01-03,000001.SZ,39,24.5,18
2024-01-03,000002.SZ,46,30.5,20
2024-01-03,000003.SZ,64,39.5,15
2024-01-03,600001.SH,85,55,13
2024-01-04,000001.SZ,40,25,18
2024-01-04,000002.SZ,47,31,20
2024-01-04,000003.SZ,63,39,15
2024-01-04,600001.SH,86,55.5,13
2024-01-05,000001.SZ,41,25.5,18
2024-01-05,000002.SZ,48,32,20
2024-01-05,000003.SZ,62,38.5,15
2024-01-05,600001.SH,86,56,13
2024-01-08,000001.SZ,42,26,18
2024-01-08,000002.SZ,50,33,21
2024-01-08,000003.SZ,61,38,15
2024-01-08,600001.SH,87,56.5,13
2024-01-09,000001.SZ,44,27,19
2024-01-09,000002.SZ,52,34,21
2024-01-09,000003.SZ,60,37.5,15
2024-01-09,600001.SH,88,57,13
2024-01-10,000001.SZ,43,26.5,19
2024-01-10,000002.SZ,53,34.5,21
2024-01-10,000003.SZ,59,37,15
2024-01-10,600001.SH,89,57.5,13
2024-01-11,000001.SZ,42,26,18
2024-01-11,000002.SZ,52,34,21
2024-01-11,000003.SZ,58,36.5,15
2024-01-11,600001.SH,90,58,13
2024-01-12,000001.SZ,40,25,18
2024-01-12,000002.SZ,50,33,20
2024-01-12,000003.SZ,57,36,15
2024-01-12,600001.SH,92,59,13
1 date symbol market_cap_bn free_float_cap_bn pe_ttm
2 2024-01-02 000001.SZ 38 24 18
3 2024-01-02 000002.SZ 45 30 20
4 2024-01-02 000003.SZ 65 40 15
5 2024-01-02 600001.SH 85 55 13
6 2024-01-03 000001.SZ 39 24.5 18
7 2024-01-03 000002.SZ 46 30.5 20
8 2024-01-03 000003.SZ 64 39.5 15
9 2024-01-03 600001.SH 85 55 13
10 2024-01-04 000001.SZ 40 25 18
11 2024-01-04 000002.SZ 47 31 20
12 2024-01-04 000003.SZ 63 39 15
13 2024-01-04 600001.SH 86 55.5 13
14 2024-01-05 000001.SZ 41 25.5 18
15 2024-01-05 000002.SZ 48 32 20
16 2024-01-05 000003.SZ 62 38.5 15
17 2024-01-05 600001.SH 86 56 13
18 2024-01-08 000001.SZ 42 26 18
19 2024-01-08 000002.SZ 50 33 21
20 2024-01-08 000003.SZ 61 38 15
21 2024-01-08 600001.SH 87 56.5 13
22 2024-01-09 000001.SZ 44 27 19
23 2024-01-09 000002.SZ 52 34 21
24 2024-01-09 000003.SZ 60 37.5 15
25 2024-01-09 600001.SH 88 57 13
26 2024-01-10 000001.SZ 43 26.5 19
27 2024-01-10 000002.SZ 53 34.5 21
28 2024-01-10 000003.SZ 59 37 15
29 2024-01-10 600001.SH 89 57.5 13
30 2024-01-11 000001.SZ 42 26 18
31 2024-01-11 000002.SZ 52 34 21
32 2024-01-11 000003.SZ 58 36.5 15
33 2024-01-11 600001.SH 90 58 13
34 2024-01-12 000001.SZ 40 25 18
35 2024-01-12 000002.SZ 50 33 20
36 2024-01-12 000003.SZ 57 36 15
37 2024-01-12 600001.SH 92 59 13
-5
View File
@@ -1,5 +0,0 @@
symbol,name,board
000001.SZ,Alpha Components,Main
000002.SZ,Beta Precision,Main
000003.SZ,Charlie Materials,Main
600001.SH,Delta Industrials,Main
1 symbol name board
2 000001.SZ Alpha Components Main
3 000002.SZ Beta Precision Main
4 000003.SZ Charlie Materials Main
5 600001.SH Delta Industrials Main
-37
View File
@@ -1,37 +0,0 @@
date,symbol,open,high,low,close,prev_close,volume,paused
2024-01-02,000001.SZ,10.0,10.2,9.9,10.1,9.8,1200000,false
2024-01-02,000002.SZ,11.0,11.3,10.9,11.2,10.8,1100000,false
2024-01-02,000003.SZ,8.0,8.1,7.8,7.9,8.0,900000,false
2024-01-02,600001.SH,15.0,15.2,14.9,15.1,15.0,800000,false
2024-01-03,000001.SZ,10.2,10.5,10.1,10.4,10.1,1250000,false
2024-01-03,000002.SZ,11.2,11.6,11.1,11.5,11.2,1120000,false
2024-01-03,000003.SZ,7.8,7.9,7.3,7.4,7.9,930000,false
2024-01-03,600001.SH,15.1,15.3,15.0,15.2,15.1,820000,false
2024-01-04,000001.SZ,10.5,10.8,10.4,10.7,10.4,1280000,false
2024-01-04,000002.SZ,11.4,11.9,11.3,11.8,11.5,1150000,false
2024-01-04,000003.SZ,7.3,7.4,7.0,7.1,7.4,940000,false
2024-01-04,600001.SH,15.2,15.5,15.1,15.4,15.2,830000,false
2024-01-05,000001.SZ,10.8,11.1,10.7,11.0,10.7,1300000,false
2024-01-05,000002.SZ,11.9,12.1,11.8,12.0,11.8,1180000,false
2024-01-05,000003.SZ,7.0,7.1,6.8,6.9,7.1,950000,false
2024-01-05,600001.SH,15.4,15.6,15.3,15.5,15.4,840000,false
2024-01-08,000001.SZ,11.1,11.6,11.0,11.5,11.0,1400000,false
2024-01-08,000002.SZ,12.1,12.5,12.0,12.4,12.0,1200000,false
2024-01-08,000003.SZ,7.0,7.3,6.9,7.2,6.9,980000,false
2024-01-08,600001.SH,15.5,15.7,15.4,15.6,15.5,850000,false
2024-01-09,000001.SZ,11.6,12.4,11.5,12.3,11.5,1500000,false
2024-01-09,000002.SZ,12.5,12.9,12.4,12.8,12.4,1250000,false
2024-01-09,000003.SZ,7.2,7.5,7.1,7.4,7.2,990000,false
2024-01-09,600001.SH,15.6,15.7,15.4,15.5,15.6,860000,false
2024-01-10,000001.SZ,12.2,12.3,11.9,12.0,12.3,1450000,false
2024-01-10,000002.SZ,12.7,12.8,12.5,12.6,12.8,1220000,false
2024-01-10,000003.SZ,7.5,7.6,7.4,7.5,7.4,1000000,false
2024-01-10,600001.SH,15.4,15.5,15.1,15.2,15.5,870000,false
2024-01-11,000001.SZ,12.0,12.1,11.5,11.6,12.0,1420000,false
2024-01-11,000002.SZ,12.5,12.6,12.1,12.2,12.6,1210000,false
2024-01-11,000003.SZ,7.4,7.5,7.2,7.3,7.5,980000,false
2024-01-11,600001.SH,15.2,15.2,15.2,15.2,15.2,0,true
2024-01-12,000001.SZ,11.5,11.6,11.1,11.2,11.6,1380000,false
2024-01-12,000002.SZ,12.1,12.2,11.8,11.9,12.2,1190000,false
2024-01-12,000003.SZ,7.2,7.2,6.9,7.0,7.3,960000,false
2024-01-12,600001.SH,14.8,15.0,14.7,14.9,15.2,850000,false
1 date symbol open high low close prev_close volume paused
2 2024-01-02 000001.SZ 10.0 10.2 9.9 10.1 9.8 1200000 false
3 2024-01-02 000002.SZ 11.0 11.3 10.9 11.2 10.8 1100000 false
4 2024-01-02 000003.SZ 8.0 8.1 7.8 7.9 8.0 900000 false
5 2024-01-02 600001.SH 15.0 15.2 14.9 15.1 15.0 800000 false
6 2024-01-03 000001.SZ 10.2 10.5 10.1 10.4 10.1 1250000 false
7 2024-01-03 000002.SZ 11.2 11.6 11.1 11.5 11.2 1120000 false
8 2024-01-03 000003.SZ 7.8 7.9 7.3 7.4 7.9 930000 false
9 2024-01-03 600001.SH 15.1 15.3 15.0 15.2 15.1 820000 false
10 2024-01-04 000001.SZ 10.5 10.8 10.4 10.7 10.4 1280000 false
11 2024-01-04 000002.SZ 11.4 11.9 11.3 11.8 11.5 1150000 false
12 2024-01-04 000003.SZ 7.3 7.4 7.0 7.1 7.4 940000 false
13 2024-01-04 600001.SH 15.2 15.5 15.1 15.4 15.2 830000 false
14 2024-01-05 000001.SZ 10.8 11.1 10.7 11.0 10.7 1300000 false
15 2024-01-05 000002.SZ 11.9 12.1 11.8 12.0 11.8 1180000 false
16 2024-01-05 000003.SZ 7.0 7.1 6.8 6.9 7.1 950000 false
17 2024-01-05 600001.SH 15.4 15.6 15.3 15.5 15.4 840000 false
18 2024-01-08 000001.SZ 11.1 11.6 11.0 11.5 11.0 1400000 false
19 2024-01-08 000002.SZ 12.1 12.5 12.0 12.4 12.0 1200000 false
20 2024-01-08 000003.SZ 7.0 7.3 6.9 7.2 6.9 980000 false
21 2024-01-08 600001.SH 15.5 15.7 15.4 15.6 15.5 850000 false
22 2024-01-09 000001.SZ 11.6 12.4 11.5 12.3 11.5 1500000 false
23 2024-01-09 000002.SZ 12.5 12.9 12.4 12.8 12.4 1250000 false
24 2024-01-09 000003.SZ 7.2 7.5 7.1 7.4 7.2 990000 false
25 2024-01-09 600001.SH 15.6 15.7 15.4 15.5 15.6 860000 false
26 2024-01-10 000001.SZ 12.2 12.3 11.9 12.0 12.3 1450000 false
27 2024-01-10 000002.SZ 12.7 12.8 12.5 12.6 12.8 1220000 false
28 2024-01-10 000003.SZ 7.5 7.6 7.4 7.5 7.4 1000000 false
29 2024-01-10 600001.SH 15.4 15.5 15.1 15.2 15.5 870000 false
30 2024-01-11 000001.SZ 12.0 12.1 11.5 11.6 12.0 1420000 false
31 2024-01-11 000002.SZ 12.5 12.6 12.1 12.2 12.6 1210000 false
32 2024-01-11 000003.SZ 7.4 7.5 7.2 7.3 7.5 980000 false
33 2024-01-11 600001.SH 15.2 15.2 15.2 15.2 15.2 0 true
34 2024-01-12 000001.SZ 11.5 11.6 11.1 11.2 11.6 1380000 false
35 2024-01-12 000002.SZ 12.1 12.2 11.8 11.9 12.2 1190000 false
36 2024-01-12 000003.SZ 7.2 7.2 6.9 7.0 7.3 960000 false
37 2024-01-12 600001.SH 14.8 15.0 14.7 14.9 15.2 850000 false
+5 -5
View File
@@ -52,22 +52,22 @@ futures path. Confirmed aligned areas:
- [x] Rich explicit order styles exposed to platform scripts.
- [x] Minute-level `time_rule` semantics including market-open, market-close,
and physical-time style schedules.
- [x] Fine-grained daily, minute, and tick strategy execution entrypoints.
- [x] Fine-grained daily and minute execution quote strategy entrypoints.
- [x] Scheduled actions evaluated against explicit intraday times.
- [x] `update_universe`, `subscribe`, and `unsubscribe`.
- [x] Tick-frequency subscription guards at strategy API level.
- [x] Intraday subscription guards at strategy API level; intraday execution uses minute quote semantics.
- [x] VWAP and TWAP explicit action styles.
- [x] `order_target_portfolio_smart(..., order_prices=AlgoOrder, valuation_prices=...)`.
- [x] Trading PnL, position PnL, dividend receivable, and richer position
lifecycle fields.
- [x] Stock position aliases including `order_book_id`, `avg_price`,
`sellable`, `closable`, `equity`, and `position_prev_close`.
- [x] `history_bars` numeric helper for daily, intraday, and tick fields.
- [x] `history_bars` numeric helper for daily and minute execution quote fields.
- [x] `current_snapshot`, instrument metadata, all-instrument queries, and
active/historical instrument helpers.
- [x] Trading-date range, previous-date, and next-date helpers.
- [x] Phase-aware minute/tick history cursor semantics matching the active bar
or tick callback.
- [x] Phase-aware minute history cursor semantics matching the active bar or
intraday execution quote callback.
- [x] Suspension, ST, date-range price, active instrument, and instrument
history helpers.
- [x] Open-order status, unfilled quantity, final order lookup, average fill
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
fail() {
local message="$1"
local details="${2:-}"
printf '[FAIL] %s\n' "$message" >&2
if [[ -n "$details" ]]; then
printf '%s\n' "$details" >&2
fi
exit 1
}
runtime_hits="$(
rg -n \
--glob '!**/.git/**' \
--glob '!**/target/**' \
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!crates/fidc-core/src/strategy_ai.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'fidatacenter|FIDATACENTER|/v1/backtest/data|/v1/xuntou|ClickHouse|clickhouse|CLICKHOUSE|FIDC_BT_INSTRUMENT_METADATA_CSV|FIDC_BT_WRITE_SNAPSHOTS|FIDC_BT_WRITE_CSV_SNAPSHOTS|FIDC_BT_WRITE_COMBINED_SOURCE_ROW_CACHE|write_csv_snapshot_files|write_csv\(|FIDC_RISK_RUNTIME_FILE|FIDC_RISK_RUNTIME_URL|FIDC_FIRISK_RUNTIME_FILE|FiRisk runtime snapshot' \
crates Cargo.toml \
2>/dev/null || true
)"
if [[ -n "$runtime_hits" ]]; then
fail "legacy fidatacenter/ClickHouse/CSV snapshot/FiRisk runtime data source references are not allowed in fidc-backtest-engine" "$runtime_hits"
fi
manifest_hits="$(
rg -n --glob '!scripts/verify-no-legacy-data-source.sh' '\b(mysql|mariadb|clickhouse|clickhouse-rs|mysql_async|sqlx-mysql)\b' Cargo.toml crates 2>/dev/null || true
)"
if [[ -n "$manifest_hits" ]]; then
fail "legacy database dependencies are not allowed in fidc-backtest-engine manifests" "$manifest_hits"
fi
local_only_hits="$(
rg -n \
--glob '!**/.git/**' \
--glob '!**/target/**' \
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!crates/fidc-core/src/strategy_ai.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'FICLAW_DATA_AGENT_URL|ficlaw_data\.source_rows_v1|strategy-factory-source-lake://local|FIDC_BT_WRITE_COMBINED_SOURCE_ROW_CACHE' \
crates Cargo.toml \
2>/dev/null || true
)"
if [[ -n "$local_only_hits" ]]; then
fail "legacy or local-only data source references are not allowed in fidc-backtest-engine runtime" "$local_only_hits"
fi
json_query_hits="$(
rg -n \
--glob '!**/.git/**' \
--glob '!**/target/**' \
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!crates/fidc-core/src/strategy_ai.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'/v1/query/(source-rows|daily-execution-prices|minute-execution-prices|instruments|corporate-actions)\.json' \
crates Cargo.toml \
2>/dev/null || true
)"
if [[ -n "$json_query_hits" ]]; then
fail "JSON Source Lake high-throughput endpoints are not allowed in fidc-backtest-engine runtime; use Arrow endpoints" "$json_query_hits"
fi
fused_hits="$(
rg -n \
--glob '!**/.git/**' \
--glob '!**/target/**' \
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!crates/fidc-core/src/strategy_ai.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'exported_fused|fidc_fused|fusion|fused|wide_table|wide table|source_rows_export|exported_daily|merged_daily|daily_merged|merged_source|materialized[_ -]source|materialized_source_rows|source_rows_materialized|融合表|融合宽表' \
crates Cargo.toml \
2>/dev/null || true
)"
if [[ -n "$fused_hits" ]]; then
fail "exported fused tables are not allowed in fidc-backtest-engine runtime; use Strategy Factory Source Lake source rows directly" "$fused_hits"
fi
feature_store_hits="$(
rg -n \
--glob '!**/.git/**' \
--glob '!**/target/**' \
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!crates/fidc-core/src/strategy_ai.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'research_feature_store|feature_store|FEATURE_STORE|daily_minute_current|FIDC_STRATEGY_FACTORY_ENABLE_FEATURE_STORE_CACHE|ALPHA_FACTORY_ENABLE_FEATURE_STORE_CACHE|ENABLE_FEATURE_STORE_CACHE' \
crates Cargo.toml \
2>/dev/null || true
)"
if [[ -n "$feature_store_hits" ]]; then
fail "historical feature-store paths are not allowed in fidc-backtest-engine runtime; use Strategy Factory Source Lake raw/indicator/artifact partitions and discardable caches" "$feature_store_hits"
fi
truth_csv_hits="$(
rg -n \
--glob '!**/.git/**' \
--glob '!**/target/**' \
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!crates/fidc-core/src/strategy_ai.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'FIDC_BT_TRUTH_STOCK_LIST_CSV|OMNI_BT_TRUTH_STOCK_LIST_CSV|OMNI_BACKTEST_TRUTH_STOCK_LIST_CSV|selection_source=truth_csv|truth_stock_list|truth_csv' \
crates Cargo.toml \
2>/dev/null || true
)"
if [[ -n "$truth_csv_hits" ]]; then
fail "CSV truth stock-list overrides are not allowed in fidc-backtest-engine runtime; use Source Lake runtime spec selection only" "$truth_csv_hits"
fi
csv_snapshot_loader_hits="$(
rg -n \
--glob '!**/.git/**' \
--glob '!**/target/**' \
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'from_csv_dir|from_partitioned_dir|instruments\.csv|candidate_flags\.csv|market\.csv|benchmark\.csv' \
crates Cargo.toml \
2>/dev/null || true
)"
if [[ -n "$csv_snapshot_loader_hits" ]]; then
fail "CSV snapshot loaders are not allowed in fidc-backtest-engine runtime; construct DataSet from Source Lake components" "$csv_snapshot_loader_hits"
fi
printf '[OK] fidc-backtest-engine has no legacy runtime data-source references\n'
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
PYTHON_BIN="${PYTHON_BIN:-python3}"
fail() {
local message="$1"
local details="${2:-}"
printf '[FAIL] %s\n' "$message" >&2
if [[ -n "$details" ]]; then
printf '%s\n' "$details" >&2
fi
exit 1
}
run_core_test() {
local filter="$1"
local tmp
tmp="$(mktemp)"
printf '[INFO] cargo test -p fidc-core %s\n' "$filter"
if cargo test -p fidc-core "$filter" -- --nocapture 2>&1 | tee "$tmp"; then
local passed_count
passed_count="$(
"$PYTHON_BIN" - "$tmp" <<'PY'
import re
import sys
count = 0
for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
match = re.search(r"test result: ok\. (\d+) passed;", line)
if match:
count += int(match.group(1))
print(count)
PY
)"
rm -f "$tmp"
if [[ "$passed_count" -le 0 ]]; then
fail "cargo test filter matched 0 tests: package=fidc-core filter=${filter}"
fi
return 0
fi
local output
output="$(cat "$tmp")"
rm -f "$tmp"
fail "cargo test failed: package=fidc-core filter=${filter}" "$output"
}
run_core_test eligible_universe_does_not_require_candidate_risk_state_when_selection_risk_is_disabled
run_core_test next_bar_open_eligible_universe_helper_does_not_block_on_decision_day_risk
run_core_test platform_next_open_selection_records_risk_diagnostics_without_filtering
run_core_test next_open_buy_risk_uses_execution_date_not_signal_date
run_core_test next_open_buy_limit_risk_uses_open_not_close
run_core_test next_open_sell_risk_uses_execution_date_not_signal_date
run_core_test next_open_sell_limit_risk_uses_open_not_close
run_core_test next_bar_open_sell_respects_allow_sell_policy_on_execution_day
run_core_test volume_limit_uses_floor_for_odd_lot_sell
run_core_test configurable_upper_limit_buy_filter_can_be_disabled
printf '[OK] fidc-backtest-engine runtime risk contracts passed\n'