Compare commits

..

542 Commits

Author SHA1 Message Date
boris cf1b60c996 fix: accept scoped blacklist runtime context 2026-08-31 09:40:49 +08:00
boris d0ca09d4d8 test: normalize engine hook assertions 2026-08-31 08:51:06 +08:00
boris c1e66b31a5 预编译数值表达式助手参数 2026-08-31 06:52:44 +08:00
boris cb18a3f279 复用选股日快照视图 2026-08-31 05:03:20 +08:00
boris b634540047 固化定点金额与分钟流式验收 2026-08-31 02:44:20 +08:00
boris cd116bc3ae 减少选股状态热路径字符串分配 2026-08-30 19:02:40 +08:00
boris 70d72d5d02 Merge remote-tracking branch 'origin/main' 2026-08-29 14:52:08 +08:00
boris f3a37157fc 统一回测策略风控字段校验 2026-08-29 14:52:03 +08:00
boris 41854fe5bd 按策略引用投影额外因子字段 2026-08-29 08:16:41 +08:00
boris e793a5fcc1 按有序市值流提前停止选股 2026-08-29 07:49:43 +08:00
boris 362d85773a 复用标准rolling的交易日边界索引 2026-08-29 06:15:28 +08:00
boris c55caaf79b 优化等价市值排序的选股路径 2026-08-29 06:01:02 +08:00
boris 257088d7d9 优化策略选股的索引查找路径 2026-08-29 05:44:06 +08:00
boris ff2844884d 修正退市候选卖出风控边界 2026-08-29 04:03:32 +08:00
boris 56a38accc8 为股票序列增加有界交易日位置索引 2026-08-28 17:02:35 +08:00
boris de1e65a642 Revert "复用当前时点标准rolling值"
This reverts commit 2d516cf1eb.
2026-08-28 16:54:01 +08:00
boris 2d516cf1eb 复用当前时点标准rolling值 2026-08-28 16:47:07 +08:00
boris e261d93ce5 跳过已排序快照的重复排序 2026-08-28 15:49:02 +08:00
boris c1e7fc91e4 跳过无需求的滚动计算 2026-08-28 15:15:18 +08:00
boris 56511f9d15 批量计算标准滚动均线 2026-08-28 14:53:28 +08:00
boris 8691076cef 加速按日股票快照查找 2026-08-28 14:39:12 +08:00
boris 2de84e88eb 优化按日快照数据集构造 2026-08-28 14:24:56 +08:00
boris 43184a7afe 记录多时点调度的实际时间 2026-08-28 12:54:15 +08:00
boris 4f647ef529 保留平台策略全部交易时点 2026-08-28 12:44:20 +08:00
boris 59be3b5dd5 修复多时点调度与触发价撮合 2026-08-28 11:44:31 +08:00
boris 5bbb093f47 Revert "perf(core): reuse aligned snapshots in universe scan"
This reverts commit 096115bd94.
2026-08-28 08:44:13 +08:00
boris 520409f50f Revert "perf(core): specialize market candidate snapshot lookup"
This reverts commit e677a73f95.
2026-08-28 08:44:13 +08:00
boris e677a73f95 perf(core): specialize market candidate snapshot lookup 2026-08-28 08:38:22 +08:00
boris 096115bd94 perf(core): reuse aligned snapshots in universe scan 2026-08-28 08:31:40 +08:00
boris cb2319cd22 Revert "perf(core): reuse rolling endpoints per stock state"
This reverts commit 732c3cfbf5.
2026-08-28 08:23:04 +08:00
boris 732c3cfbf5 perf(core): reuse rolling endpoints per stock state 2026-08-28 08:16:04 +08:00
boris 3d2ab17d72 perf(core): align market factor candidate lookups 2026-08-28 08:00:31 +08:00
boris 87c18574a8 Revert "perf(core): build sorted dataset components linearly"
This reverts commit c46dcf817b.
2026-08-28 07:20:20 +08:00
boris c46dcf817b perf(core): build sorted dataset components linearly 2026-08-28 07:11:19 +08:00
boris 3f67ee9134 严格按实际委托时间选择盘后撮合 2026-08-28 03:17:21 +08:00
boris 1a2e247c8d 合并分钟行情与策略定时事件时钟 2026-08-28 01:42:58 +08:00
boris 6c47c33cab 按实际委托时间选择盘后撮合阶段 2026-08-28 00:12:19 +08:00
boris a9511f9a4a 拆分调仓日期与执行时钟 2026-08-28 00:00:30 +08:00
boris 85c38b0756 移除策略级盘后撮合模式 2026-08-27 21:05:57 +08:00
boris dd08358f1c 限制盘后固定价格撮合生效日期 2026-08-27 19:20:37 +08:00
boris b6f4b05844 支持盘后固定价格撮合合同 2026-08-27 17:56:41 +08:00
boris c86a0e2339 让分钟行情按流式迭代器处理 2026-08-27 13:25:28 +08:00
boris ed126a3630 优化分钟历史窗口读取并移除滚动双口径 2026-08-27 13:14:41 +08:00
boris 45cafa5c96 Revert "恢复Source Lake滚动因子运行模式"
This reverts commit d0639558b3.
2026-08-27 13:13:50 +08:00
boris d0639558b3 恢复Source Lake滚动因子运行模式 2026-08-27 10:14:50 +08:00
boris 8dccf8414f Merge remote-tracking branch 'refs/remotes/177/latest'
# Conflicts:
#	crates/fidc-core/src/events.rs
2026-08-27 09:19:36 +08:00
boris d84fad721d Merge remote-tracking branch 'origin/main'
# Conflicts:
#	crates/fidc-core/src/events.rs
#	crates/fidc-core/src/lib.rs
2026-08-27 09:07:51 +08:00
boris ce4d17c293 规范化策略规格别名避免运行时重复字段 2026-08-27 09:06:37 +08:00
boris 97e9a83dd2 分离过程事件分发与结果保留 2026-08-27 09:05:15 +08:00
boris 9db2a9f79c 分离过程事件分发与结果保留 2026-08-27 09:04:48 +08:00
boris 801a27dace 让显式动作继承运行调度 2026-08-27 08:37:33 +08:00
boris 00ec7a6d55 让显式动作继承运行调度 2026-08-27 08:37:12 +08:00
boris 9b00a0777a 增加类型化开放订单改单能力 2026-08-27 08:08:39 +08:00
boris 5a765766e3 增加类型化开放订单改单能力 2026-08-27 08:07:07 +08:00
boris 6ee1835ca5 记录分钟成交精确时间 2026-08-27 02:47:19 +08:00
boris cdbd8a67de 记录分钟成交精确时间 2026-08-27 02:46:38 +08:00
boris 8d7bb60c30 共享回测分钟报价索引 2026-08-27 01:38:56 +08:00
boris 21cfa65af2 共享回测分钟报价索引 2026-08-27 01:37:18 +08:00
boris 01d1e5073d 修正跨调度撮合流动性重复消费 2026-08-27 00:57:34 +08:00
boris 5c300f8181 修正跨调度撮合流动性重复消费 2026-08-27 00:56:48 +08:00
boris 78c5b72ed3 完善统一策略规格元数据字段 2026-08-26 23:39:11 +08:00
boris 32b3122457 完善统一策略规格元数据字段 2026-08-26 23:37:42 +08:00
boris 50690540cd 导出统一基准调仓规格类型 2026-08-26 23:29:18 +08:00
boris 71b4ffcecf 导出统一基准调仓规格类型 2026-08-26 23:27:32 +08:00
boris 7f66bcfff7 统一基准与调仓规格字段 2026-08-26 23:26:24 +08:00
boris 422e5f1021 统一基准与调仓规格字段 2026-08-26 23:24:20 +08:00
boris 68bff3f661 统一引擎执行规格字段 2026-08-26 23:21:16 +08:00
boris b92a09b5ed 统一引擎执行规格字段 2026-08-26 23:18:43 +08:00
boris e867aea3b1 统一执行参数规格字段 2026-08-26 23:08:09 +08:00
boris 32693dad30 统一执行参数规格字段 2026-08-26 23:06:20 +08:00
boris 723ce93623 导出统一风控策略规格类型 2026-08-26 23:02:04 +08:00
boris b05bd3fc1b 导出统一风控策略规格类型 2026-08-26 23:01:33 +08:00
boris 48acd66c30 修正风控别名测试断言 2026-08-26 22:59:52 +08:00
boris 861ed483b5 修正风控别名测试断言 2026-08-26 22:59:05 +08:00
boris 3926ac2985 统一风控策略执行别名 2026-08-26 22:56:50 +08:00
boris a72a4518d3 统一风控策略执行别名 2026-08-26 22:54:11 +08:00
boris 255fc2b878 限定订单有效期运行模式能力 2026-08-26 22:14:18 +08:00
boris dbaf7b45af 限定订单有效期运行模式能力 2026-08-26 22:11:16 +08:00
boris 8e238f9131 实现类型化订单有效期合同 2026-08-26 21:00:22 +08:00
boris 88f5a1a0ae 实现类型化订单有效期合同 2026-08-26 19:48:21 +08:00
boris bc0f5f6089 修正回测部分成交终态合同 2026-08-26 18:28:42 +08:00
boris 0793473210 修正回测部分成交终态合同 2026-08-26 18:28:19 +08:00
boris 8303a6477b 允许运行态追加结算交易日历 2026-08-26 14:28:20 +08:00
boris 935dd47e34 允许运行态追加结算交易日历 2026-08-26 14:27:00 +08:00
boris 8fcf34b3a9 保留延迟资金到账表达式 2026-08-26 13:59:59 +08:00
boris c18306aed9 保留延迟资金到账表达式 2026-08-26 13:59:18 +08:00
boris 9399a61b46 增加回测结束边界状态审计 2026-08-26 13:37:36 +08:00
boris 33370fb694 增加回测结束边界状态审计 2026-08-26 13:34:36 +08:00
boris 82604481b6 下推分钟报价订阅过滤 2026-08-26 07:58:55 +08:00
boris 283bf56e9f 下推分钟报价订阅过滤 2026-08-26 07:57:23 +08:00
Boris d3bacffd8b 减少每日诊断文本临时分配 2026-08-26 06:43:59 +08:00
boris 670686681d 减少每日诊断文本临时分配 2026-08-26 06:43:36 +08:00
Boris 5929fedf91 跳过零管理费无效集合复制 2026-08-26 06:39:13 +08:00
boris 8b246a63f0 跳过零管理费无效集合复制 2026-08-26 06:38:19 +08:00
Boris 0867655d85 减少文本因子读取临时分配 2026-08-26 05:08:34 +08:00
boris 782bc640ff 减少文本因子读取临时分配 2026-08-26 05:07:19 +08:00
boris 77622e164c 减少日频数据读取临时分配 2026-08-26 04:48:22 +08:00
boris 6604afd24f 减少日频数据读取临时分配 2026-08-26 04:46:08 +08:00
boris bf2e3af4eb 优化数值表达式helper执行路径 2026-08-26 03:27:00 +08:00
boris d071a8a190 优化数值表达式helper执行路径 2026-08-26 03:25:41 +08:00
boris afef38e45e 跳过干净因子映射重复规范化 2026-08-25 22:59:30 +08:00
boris ac30d86b6a 线性构建数据集价格序列 2026-08-25 21:55:18 +08:00
boris 01cffb947c 共享固定数值因子字段名 2026-08-25 20:25:05 +08:00
boris fac5078dbf 减少数据集按股票分组字符串分配 2026-08-25 19:20:23 +08:00
boris 68ebe76f24 约束期货策略生成的数据可用性 2026-08-25 17:22:45 +08:00
boris c284cc191e 增加通用期货策略动作并修正组合净值 2026-08-25 17:10:17 +08:00
boris 90da7f8a21 将期货现金账本切换为定点并修正日度盈亏 2026-08-25 16:38:45 +08:00
boris 2b94d5148f 将股票持仓盈亏切换为定点批次账本 2026-08-25 15:59:37 +08:00
boris 2574b9375d 按顺序结算多笔现金应收 2026-08-25 15:25:18 +08:00
boris e368bad7e4 移除净值读取定点重复转换 2026-08-25 15:08:33 +08:00
boris 5b6b3682dd 冻结交易费率定点配置 2026-08-25 14:38:51 +08:00
boris 92724c6ab0 将股票执行资金切换为定点账本 2026-08-25 14:36:15 +08:00
boris c9ddff46dd 支持任意交易阶段调度时间 2026-08-25 09:32:34 +08:00
boris 5ff8ddca92 共享选股状态并合并风控扫描 2026-08-25 08:53:34 +08:00
boris 85cfdca14c 移除引擎历史事件重复复制 2026-08-25 08:34:06 +08:00
boris 5482c8a52d 合并177回测引擎运行历史
# Conflicts:
#	crates/fidc-core/src/data.rs
2026-08-25 05:36:43 +08:00
boris 2a6bbb82a6 支持原生回测事实存储 2026-08-25 05:32:18 +08:00
boris 24e4ac9284 线性合并分钟行情窗口 2026-08-25 04:17:53 +08:00
boris 81d70f18b3 跳过无业务分钟回调 2026-08-25 04:02:33 +08:00
boris 85c9d03b99 校验分钟订阅行情覆盖 2026-08-25 03:07:56 +08:00
boris a147c495af 重构分钟线事件流与订阅加载 2026-08-25 01:41:50 +08:00
boris 4cf0224d2d 移除DataSet行级Arc分配 2026-08-24 21:53:00 +08:00
boris 7503dc8517 共享回测只读数据索引 2026-08-24 19:48:14 +08:00
boris 1c04318ecf 增加定点金额精度验收模型 2026-08-24 17:25:53 +08:00
boris 4b577517a9 增加数值表达式字节码虚拟机 2026-08-24 13:46:45 +08:00
boris c52478708f 用快速哈希优化回测内部索引 2026-08-24 12:09:09 +08:00
boris 1d7ac19886 移除回测稠密索引性能回归 2026-08-24 11:53:12 +08:00
boris 0686532be0 用稠密行索引和滚动游标加速回测 2026-08-24 11:46:55 +08:00
boris 911074ae95 优化日线候选和成交量窗口索引 2026-08-24 11:34:20 +08:00
boris 555f2ab9bd 按证券索引优化表达式数据访问 2026-08-24 11:21:39 +08:00
boris a79077af17 按表达式依赖裁剪策略前置声明 2026-08-24 10:05:46 +08:00
boris 61a4172bd4 统一策略表达式执行与默认配置 2026-08-24 09:28:33 +08:00
boris 589f94e5b2 增加逐日紧凑证券索引 2026-08-24 04:04:03 +08:00
boris 8254ebbb47 压缩类型化因子并减少运行分配 2026-08-24 03:55:03 +08:00
boris ea79fdae46 减少滚动窗口重复索引开销 2026-08-24 03:36:07 +08:00
boris 2013314e4f 区分指数与股票滚动复权口径 2026-08-24 03:19:16 +08:00
boris 869c14e2b0 改用真实行情验证滚动风控 2026-08-24 02:57:42 +08:00
boris cea079a770 统一复权滚动因子计算口径 2026-08-24 02:51:58 +08:00
boris 9a7e5c7903 前置校验策略表达式语法 2026-08-23 22:58:33 +08:00
boris 279d6a100f 统一成交量滚动有效样本口径 2026-08-23 13:10:46 +08:00
boris 7afb72dca8 统一成交量滚动有效样本口径 2026-08-23 13:09:18 +08:00
boris c8cbc5dc96 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	crates/fidc-core/src/metrics.rs
2026-08-22 18:58:44 +08:00
boris 6fba34d2e4 修正回测出入金现金流中性口径 2026-08-22 18:54:34 +08:00
boris 375b8b2df1 补充可配置过户费并纳入成交成本 2026-08-22 14:50:00 +08:00
boris b8776d7169 修正入金后的资金流收益指标 2026-08-22 14:32:52 +08:00
boris 7a1631efa3 补充策略执行频率字段 2026-08-22 10:59:38 +08:00
boris fe7e0f397f 修正无变化目标订单记录 2026-08-01 22:10:44 +08:00
boris 839ca1fa0d 预编译策略运行时辅助函数 2026-08-01 21:51:46 +08:00
boris d51d324977 复用策略表达式解析计划 2026-08-01 21:44:57 +08:00
boris 6f2c39aaf2 并行构建证券序列索引 2026-08-01 21:39:17 +08:00
boris 2a4a9d1290 构建无锁证券序列索引 2026-08-01 21:34:51 +08:00
boris 29fcd67bf8 按表达式需求构建运行作用域 2026-08-01 21:30:37 +08:00
boris faa8ac7c13 优化复权与成交量滚动窗口 2026-08-01 21:23:11 +08:00
boris 40e4c12cdd 移除策略生成默认收益门槛 2026-08-01 18:36:29 +08:00
boris 53c68250e4 区分业务选股与框架风控 2026-08-01 18:11:36 +08:00
boris 2749983267 修复策略前置表达式滚动函数执行 2026-08-01 17:14:37 +08:00
boris 2c93f4a1ed 补全调仓现金口径生成约束 2026-08-01 16:15:25 +08:00
boris 51acdf1d31 拒绝废弃的引擎配置档案字段 2026-08-01 12:47:23 +08:00
boris d21680ed4f 删除隐藏兼容模式并统一撮合风控 2026-08-01 12:37:31 +08:00
boris ca9732ecb2 修正模型排名缺失诊断 2026-07-22 09:13:42 +08:00
boris 4132793219 修正退市整理期交易状态判定 2026-07-19 08:44:11 +08:00
boris d9ce3eeb5c 修正满仓后既有目标调仓中断 2026-07-19 08:23:47 +08:00
boris 24528ecfeb 修复调仓卖出失败后的持仓槽位溢出 2026-07-19 04:51:49 +08:00
boris a77a00c70a 隔离退市持仓与模型目标状态 2026-07-18 18:20:26 +08:00
boris 117f7be9c8 修正退市持仓槽位与重复订单 2026-07-18 18:03:23 +08:00
boris bcb45077fb 修正策略生成滑点合同提示 2026-07-18 17:24:10 +08:00
boris 6c39acd54e 修正FIDC运行风控验证入口 2026-07-18 16:37:31 +08:00
boris 518aadb9fd 修正完成日触板候选判定 2026-07-18 09:04:59 +08:00
boris 0dca331950 严格使用真实上市天数过滤候选 2026-07-18 08:54:04 +08:00
boris 4c0fde7621 修正动态排名替换目标状态 2026-07-18 08:35:12 +08:00
boris 755fffda0f 修正显式目标单批次撮合顺序 2026-07-17 23:43:04 +08:00
boris af4cd25f47 修正复权序列未来数据污染 2026-07-17 23:22:39 +08:00
boris 59a500b879 修正当前复权均线预计算命中 2026-07-17 23:05:36 +08:00
boris 71b5acee30 修正持久目标组合调仓顺序 2026-07-17 22:41:03 +08:00
boris 031e4ee054 修正目标组合卖出后统一补仓 2026-07-17 22:22:14 +08:00
boris b6df63c79e 修正同批卖出资金复用 2026-07-17 21:46:23 +08:00
boris 6e8eeb984f 修正当前滚动因子日期取值 2026-07-17 16:31:47 +08:00
boris ffc9179cff 修正当前滚动因子运行语义 2026-07-17 15:47:51 +08:00
boris ef491340f6 支持运行态预计算滚动均线 2026-07-17 14:54:25 +08:00
boris 7f65fda790 修正未成交清仓意图持久化 2026-07-17 13:34:54 +08:00
boris 5918a03456 修正未成交模型持仓生命周期 2026-07-17 13:26:57 +08:00
boris 0337cc8a22 保留成交量空值滚动语义 2026-07-17 13:11:50 +08:00
boris 4d7245d8b0 修正延迟卖出意图的持仓槽位 2026-07-17 12:54:33 +08:00
boris 81ac623fca 修正延迟调仓剩余仓位预算 2026-07-17 12:43:53 +08:00
boris bdd5a41106 修正调仓后剩余买入预算 2026-07-17 12:32:25 +08:00
boris 60457389a3 支持显式止盈止损参考价口径 2026-07-17 12:07:07 +08:00
boris 8f098e4da1 修正股票日线复权滚动因子口径 2026-07-17 09:47:47 +08:00
boris a734cbeaec 修正延迟撮合选股与目标金额语义 2026-07-17 08:55:16 +08:00
boris 8f167e7de1 格式化表达式解析回归测试 2026-07-17 08:44:02 +08:00
boris 2c1a9be38e 修复嵌套三元表达式解析 2026-07-17 08:33:58 +08:00
boris 01b2ca02ff 完善生命周期持仓与当前日滚动语义 2026-07-17 07:34:59 +08:00
boris 9d47d06064 支持动态排名每日替换上限 2026-07-15 21:49:46 +08:00
boris e7d1c875fd 修正退市持仓虚假现金兑付 2026-07-15 20:48:17 +08:00
boris d63ac73903 修正目标金额零数量虚假订单 2026-07-15 19:23:41 +08:00
boris 26315e2016 修正组合回撤负向测试断言 2026-07-15 18:41:28 +08:00
boris 9f85625b83 增加组合回撤冷却风控状态机 2026-07-15 18:39:50 +08:00
boris e17c5ad3b0 新增信号日基准收盘字段 2026-07-13 15:51:24 +08:00
boris 5f5f0fcf16 补充目标仓位降仓回归测试 2026-07-12 16:44:39 +08:00
boris bacb70e327 修正下一开盘目标仓位计算 2026-07-12 14:59:12 +08:00
boris 0ea5fae69d 统一次日开盘新仓目标市值指令 2026-07-12 05:55:55 +08:00
boris 214872dfbf 修正次日开盘目标市值现金投影 2026-07-12 05:47:37 +08:00
boris 2b64fb7c7e Revert "修正次日开盘目标市值换股语义"
This reverts commit 438757ab54.
2026-07-12 05:42:00 +08:00
boris 438757ab54 修正次日开盘目标市值换股语义 2026-07-12 05:33:50 +08:00
boris 67f15f12ca 隔离次日执行价与信号日资金预算 2026-07-12 04:49:40 +08:00
boris 992d0e063c 保留次日执行目标市值指令 2026-07-12 04:41:58 +08:00
boris 428434d98d 修复次日开盘目标市值未来数据 2026-07-12 04:37:03 +08:00
boris 20b07ddd7d 移除决策日市值二次推算 2026-07-12 04:21:46 +08:00
boris c094e78bef 修正周期调仓等权资金预算 2026-07-12 01:45:33 +08:00
boris 57345e8230 修正下一交易日信号时点字段可见性 2026-07-12 01:27:50 +08:00
boris d5d67102ac 支持排名缓冲换仓策略 2026-07-12 01:05:56 +08:00
boris 30a4071ee0 对齐模型轮动目标调仓语义 2026-07-12 00:31:00 +08:00
boris 942ba84ca5 保留模型评分显式调仓日期 2026-07-11 23:50:00 +08:00
boris ab3c821e59 修复滞后执行卖出资金投影 2026-07-11 23:39:18 +08:00
boris 1953e92b7b 更新策略生成三年收益目标 2026-07-10 15:36:11 +08:00
boris 9cc625409f 统一退出信号与显式调仓语义 2026-07-10 14:41:18 +08:00
boris 558d92fe23 禁止退出信号股票当日补仓 2026-07-10 14:21:13 +08:00
boris 0aef8f9491 删除目标组合错误回补分支 2026-07-10 13:39:41 +08:00
boris e275f4632d 修正AiQuant目标权重fallback执行口径 2026-07-10 13:02:53 +08:00
boris 5166916926 修正目标组合全仓卖出失败回补语义 2026-07-10 12:40:42 +08:00
boris 56859dbe32 修正AiQuant目标组合执行风控延后语义 2026-07-10 12:01:11 +08:00
boris 1272e427a1 修正目标组合现金安全搜索 2026-07-10 11:37:12 +08:00
boris e396c895dc 修正AiQuant兼容持仓成本止损口径 2026-07-10 10:35:58 +08:00
boris f7d0889bbc 补充目标组合执行日展开测试 2026-07-10 04:21:17 +08:00
boris 9b84f3a1b9 补充目标仓位估值价回归测试 2026-07-10 04:07:06 +08:00
boris b1520fcca0 支持执行日行情价格映射表达式 2026-07-10 03:55:57 +08:00
boris bb51d91b76 修复开盘调仓估值价格口径 2026-07-10 03:41:38 +08:00
boris 2c43feec3e 兼容百分比滑点模型别名 2026-07-10 03:00:17 +08:00
boris 825de1d886 禁止目标组合调仓放大目标权重 2026-07-09 20:27:50 +08:00
boris 7397a2d69f 精简平台选股缺排名字段诊断 2026-07-08 11:14:43 +08:00
boris 7951ba67e3 修正执行日退市缺行情拒单原因 2026-07-08 11:07:30 +08:00
boris 2fcacb4313 修正弱市止盈前减仓顺序 2026-07-08 07:02:17 +08:00
boris 5e480cd69b 修正延迟卖出后止盈止损补仓槽位 2026-07-08 05:32:59 +08:00
boris bfbbac8952 修正AiQuant兼容策略退出槽位默认语义 2026-07-08 05:24:45 +08:00
boris bb04864436 增强补仓调试诊断 2026-07-08 05:20:05 +08:00
boris b87e1b4a02 修正延迟卖出后止损补仓槽位 2026-07-08 05:13:13 +08:00
boris 185ed49fe2 修正数字止损边界口径 2026-07-08 05:06:55 +08:00
boris a30face86a 修正分钟止损缺少quote误触发 2026-07-08 04:58:56 +08:00
boris 188376b75a 修正预计算rolling缺失回退 2026-07-08 04:52:18 +08:00
boris 6a98d9b0bd 释放全仓待清仓补仓槽位 2026-07-08 04:40:55 +08:00
boris a562a8e2ed 修正延迟日待清仓补仓槽位 2026-07-08 04:38:21 +08:00
boris 215c4046d1 修正满仓待清仓补仓槽位 2026-07-08 04:28:28 +08:00
boris d30c93989c 修正弱市部分止损补仓槽位 2026-07-08 04:21:15 +08:00
boris 4554f92fb4 Revert "修正普通日部分退出补仓槽位"
This reverts commit 556ed9b848.
2026-07-08 04:15:23 +08:00
boris 556ed9b848 修正普通日部分退出补仓槽位 2026-07-08 04:11:44 +08:00
boris 344e7e90c2 Revert "修正部分延迟卖出槽位计数"
This reverts commit c64bf16c8b.
2026-07-08 04:09:16 +08:00
boris c64bf16c8b 修正部分延迟卖出槽位计数 2026-07-08 04:06:34 +08:00
boris ce5ef3b77d 修正延迟卖出日止盈补仓槽位 2026-07-08 03:58:55 +08:00
boris 8f47ee3679 回退延迟卖出补仓槽位计数 2026-07-08 03:46:15 +08:00
boris f15f229a09 修正延迟卖出补仓槽位计数 2026-07-08 03:43:11 +08:00
boris da12cdddd4 Revert "修正延迟卖出后的补仓槽位"
This reverts commit 0bb47812e5.
2026-07-08 03:35:03 +08:00
boris 0bb47812e5 修正延迟卖出后的补仓槽位 2026-07-08 03:33:04 +08:00
boris 203e17ce87 修正指数择时使用信号指数 2026-07-08 03:27:06 +08:00
boris 32a417d6d1 Revert "支持强市目标仓位微调"
This reverts commit 2de7127f02.
2026-07-08 03:22:13 +08:00
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
boris 7dbd66b467 修复止盈关闭时的延迟卖出误触发 2026-05-20 17:51:29 +08:00
boris db8b0bf142 修复AiQuant回测撮合一致性 2026-05-20 12:09:01 +08:00
boris 6e54471e57 修复回测撮合与AiQuant兼容语义 2026-05-18 23:06:47 +08:00
boris 3f383c1a88 修复平台策略撮合限价与回补语义 2026-05-18 11:14:51 +08:00
boris 4577657c90 对齐 AiQuant RQAlpha 回测语义 2026-05-15 11:48:10 +08:00
boris 94662b6e75 chore: 更新 fidc-backtest-engine - 2026-05-13 2026-05-13 23:48:16 +08:00
boris 616cab0e7e chore: 更新 fidc-backtest-engine - 2026-05-13 2026-05-13 21:57:57 +08:00
boris db72f6f515 修复 AiQuant 微盘回测撮合语义 2026-05-13 18:43:02 +08:00
boris 2165831708 使用前一交易日指数价格计算市值区间,模拟实盘场景
- 修改trading_ratio()返回5个值,包含prev_level
- 使用prev_level计算市值区间,符合实盘决策逻辑
- 调整默认参数对齐AiQuant实际运行版本(xs=0.008, cap_span=10)
- 增强MA过滤调试日志,输出首个决策日所有股票的过滤详情
- 添加市值区间计算调试日志
2026-05-12 18:03:56 +08:00
boris 1a402f2048 实现市值区间padding机制
- 添加padding_ratio、min_padding、max_padding配置参数
- 在市值区间计算中应用padding扩大选股范围
- 更新OmniMicroCapConfig、CnSmallCapRotationConfig和DynamicMarketCapBandSelector
- AiQuant V1.0.4默认padding: ratio=0.5, min=12.5, max=30.0
- 目标:增加候选股票数量,匹配AiQuant行为
2026-05-11 20:38:12 +08:00
boris bbe60537ff 修复MA过滤器逻辑错误和成交量过滤器策略名称匹配
- 修复MA过滤器:第二个比较添加 * rsi_rate (ma10 * rsi_rate > ma30)
- 修复成交量过滤器:使用contains匹配策略名称而非精确匹配
- 添加调试日志用于诊断MA过滤问题
- 同时修复strategy.rs和platform_strategy_spec.rs中的逻辑
2026-05-11 20:13:52 +08:00
boris 3b033fd294 修复 core 执行层默认添加 new_listing 的问题
问题:
- platform expr 选股从 eligible_universe_on 开始
- eligible_universe_on 无条件过滤新股
- 导致即使 strategy_spec.universe.exclude 不含 new_listing,仍会过滤新股

修复:
- StrategyRuntimeSpec 补 universe_exclude 字段
- platform expr 选股从 factor/candidate/market 合并开始
- 按 strategy_spec.universe.exclude 自己决定是否排除 new_listing
- 补回归测试

相关:
- 保持旧策略默认排除不变
- 新策略可以显式不排除新股
2026-05-09 02:08:36 -07:00
boris d9de9715ef chore: 更新 fidc-backtest-engine - 2026-05-08 2026-05-08 19:57:49 -07:00
boris 65742d4d5e chore: 更新 fidc-backtest-engine - 2026-05-08 2026-05-08 07:34:04 -07:00
boris a47c7c3e49 chore: 更新 fidc-backtest-engine - 2026-05-07 2026-05-07 17:12:49 -07:00
boris adc2f12ddf chore: 更新 fidc-backtest-engine - 2026-05-07 2026-05-07 03:49:26 -07:00
boris e06a1e88e5 完善AI策略手册防未来函数规则 2026-04-30 09:24:05 -07:00
boris ce49301b98 修复平台策略次日开盘未来函数 2026-04-30 00:53:45 -07:00
boris e5439956eb 修复平台表达式嵌套三元执行 2026-04-30 03:57:43 +08:00
53 changed files with 58935 additions and 5339 deletions
Generated
+53 -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"
@@ -109,12 +99,43 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "either"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -125,8 +146,10 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
name = "fidc-core"
version = "0.1.0"
dependencies = [
"ahash",
"chrono",
"indexmap",
"rayon",
"rhai",
"serde",
"serde_json",
@@ -304,6 +327,26 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "rhai"
version = "1.23.6"
+2 -1
View File
@@ -1,7 +1,6 @@
[workspace]
members = [
"crates/fidc-core",
"crates/bt-demo",
]
resolver = "2"
@@ -12,9 +11,11 @@ version = "0.1.0"
authors = ["OpenAI Codex"]
[workspace.dependencies]
ahash = "=0.8.12"
chrono = { version = "=0.4.44", features = ["serde"] }
indexmap = { version = "=2.11.4", features = ["serde"] }
reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] }
rayon = "=1.12.0"
rhai = { version = "=1.23.6", features = ["sync"] }
serde = { version = "=1.0.228", features = ["derive"] }
serde_json = "=1.0.145"
+13 -42
View File
@@ -4,7 +4,7 @@
## 当前能力
- 日频分钟、tick 级策略生命周期与确定性回放。
- 日频分钟执行价策略生命周期与确定性回放。
- A 股行情、估值、因子、基准、候选资格、涨跌停触达、停牌和 ST 标记。
- 平台策略 DSL 与 `StrategyContext` 数据 API,不暴露非平台脚本语法。
- `BacktestConfig` 支持起止日期、初始资金、决策滞后、执行价格字段、基准代码。
@@ -14,6 +14,7 @@
- Broker 支持目标权重、显式金额、目标股数、限价、VWAP/TWAP、挂单、撤单和订单查询。
- 期货账户支持多空持仓、开平仓、今昨仓、保证金、手续费、结算和到期处理。
- 报告输出支持权益曲线、成交、持仓、月度收益、风险指标、基准序列和 JSON 分析包。
- 账户出入金以独立外部现金流记录保存;权益曲线同时输出 `externalCashFlow` 和现金流中性 `unitNav`,收益指标不把入金/出金计入交易收益。延迟出金在结算前做整批资金校验,不能把账户现金变成负数。
- 内置 `OmniMicroCapStrategy`,覆盖动态市值带、均线过滤、止损止盈、固定频率再平衡和盘中执行近似。
## Workspace 布局
@@ -22,7 +23,6 @@
.
├── Cargo.toml
├── crates
│ ├── bt-demo
│ └── fidc-core
│ └── src
│ ├── broker.rs
@@ -37,7 +37,6 @@
│ ├── scheduler.rs
│ ├── strategy.rs
│ └── strategy_ai.rs
├── data/demo
└── docs
```
@@ -51,7 +50,7 @@
- `futures`: 期货账户、合约参数、保证金、手续费和多空持仓。
- `rules`: 中国市场交易规则和风控校验。
- `broker`: 股票撮合、订单簿、滑点、成交量约束、限价和显式订单执行。
- `scheduler`: 日、周、月分钟、tick 调度规则。
- `scheduler`: 日、周、月分钟调度规则。
- `platform_expr_strategy`: 平台 DSL 解析后的表达式策略执行模型。
- `strategy`: 策略 trait、内置策略和运行时视图。
- `strategy_ai`: 策略 AI 手册、提示词生成和数据库字段目录合并。
@@ -82,6 +81,14 @@
任意数据库指标和自定义因子通过 `factor("field")``factor_value("field", lookback)``rolling_mean("field", n)``sma("close", n)` 等函数读取。未预计算的均线窗口可在回测中按已有历史数据实时计算。
Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与真实 `volume=0` 含义不同。依赖成交量的 rolling 窗口只要包含源空值就返回缺失,不得把空值补成 0;停牌日明确提供的 0 成交量仍是合法观测。该合同随 runner 快照版本冻结,旧快照不能跨版本复用。
盘后固定价格不是策略类型,也不是 `matchingType`。自 2026-07-06 起,只有实际同日提交时间落在 15:00–15:30 的普通委托才由 broker 进入盘后固定价格执行阶段;15:00–15:04 的委托等待到 15:0515:05–15:30 按官方收盘价和真实盘后成交量撮合,不叠加滑点,未成交余量不跨日。窗口外委托继续沿用连续竞价、当前收盘或下一交易日开盘合同;`next_bar_open` 策略即使在 15:00 生成信号,也不得被改写为同日盘后委托。缺失盘后行情时必须明确不成交,禁止回退全天成交量或 15:00 前分钟行情。
分钟回放使用行情时间戳与策略定时事件的有序合并时钟。`OnDay``Bar``Minute` 阶段只要声明显式 `physical_time`,就必须在各自真实分钟进入同一时间轴,即使该分钟没有预加载行情也必须触发,并由执行层按需查询该时点或之前最新有效价格;同一时间戳只形成一个事件,scheduler 回调先于 `on_minute`。日线 `current_bar_close` 无显式时间时使用官方日收盘,有显式时间时使用该触发点的 `Last` 行情,禁止读取下一分钟;多个时间点分别执行,禁止压成最后一个时间、把早盘单改成盘后单或依赖已有 BAR 才触发。`next_bar_open` 的 T 日信号时钟继续留在粗粒度决策阶段,不能延迟到 T+1 的同名分钟。
`holdUntilExit=true``stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
## 内置微盘策略
`OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线:
@@ -96,45 +103,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"
-516
View File
@@ -1,516 +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()?
}
_ => {
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
);
}
}
}
+2
View File
@@ -6,8 +6,10 @@ license.workspace = true
authors.workspace = true
[dependencies]
ahash.workspace = true
chrono.workspace = true
indexmap.workspace = true
rayon.workspace = true
rhai.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -0,0 +1,17 @@
//! 把 DSP 运行时 schema 序列化为 JSON 输出到 stdout。
//!
//! 用法(在 fidc-backtest-engine 仓库根):
//! cargo run -p fidc-core --bin dump_platform_runtime_schema \
//! > ../omniquant/src/generated/platformRuntimeSchema.json
//!
//! 这是 omniquant 前端编译期校验表达式标识符的事实源;任何对
//! reserved_scope_names / is_runtime_helper / register_fn 清单的修改,记得
//! 重新跑这个命令并把生成文件提交到 omniquant。
use fidc_core::runtime_schema_json;
fn main() {
let schema = runtime_schema_json();
let output = serde_json::to_string_pretty(&schema).expect("serialize schema");
println!("{output}");
}
File diff suppressed because it is too large Load Diff
+217 -59
View File
@@ -3,18 +3,32 @@ use std::collections::BTreeMap;
use chrono::NaiveDate;
use crate::events::OrderSide;
pub const STOCK_PIT_TAX_CHANGE_DATE: (i32, u32, u32) = (2023, 8, 28);
use crate::fixed_point::{FixedChinaAShareCostModel, FixedMoney, FixedTradingCost};
use crate::risk_control::TradingConstraintConfig;
#[derive(Debug, Clone, Copy)]
pub struct TradingCost {
pub commission: f64,
pub stamp_tax: f64,
pub transfer_fee: f64,
}
impl TradingCost {
pub fn total(self) -> f64 {
self.commission + self.stamp_tax
self.fixed_total().to_f64()
}
pub fn fixed_total(self) -> FixedMoney {
FixedMoney::checked_sum_f64([self.commission, self.stamp_tax, self.transfer_fee])
.expect("trading costs must be finite fixed-point money")
}
fn from_fixed(value: FixedTradingCost) -> Self {
Self {
commission: value.commission.to_f64(),
stamp_tax: value.stamp_tax.to_f64(),
transfer_fee: value.transfer_fee.to_f64(),
}
}
}
@@ -35,50 +49,133 @@ pub trait CostModel {
#[derive(Debug, Clone, Copy)]
pub struct ChinaAShareCostModel {
pub commission_rate: f64,
pub stamp_tax_rate_before_change: f64,
pub stamp_tax_rate_after_change: f64,
pub minimum_commission: f64,
fixed: FixedChinaAShareCostModel,
}
impl Default for ChinaAShareCostModel {
fn default() -> Self {
Self {
commission_rate: 0.0003,
stamp_tax_rate_before_change: 0.001,
stamp_tax_rate_after_change: 0.0005,
minimum_commission: 5.0,
}
Self::from_trading_constraints(TradingConstraintConfig::default())
}
}
impl ChinaAShareCostModel {
pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self {
Self {
fixed: FixedChinaAShareCostModel {
commission_rate: Self::fixed_money(config.commission_rate, "commission rate"),
stamp_tax_rate_before_change: Self::fixed_money(
config.stamp_tax_rate_before_change,
"stamp tax rate before change",
),
stamp_tax_rate_after_change: Self::fixed_money(
config.stamp_tax_rate_after_change,
"stamp tax rate after change",
),
stamp_tax_change_date: config.stamp_tax_change_date,
minimum_commission: Self::fixed_money(
config.minimum_commission,
"minimum commission",
),
transfer_fee_rate: Self::fixed_money(config.transfer_fee_rate, "transfer fee rate"),
},
}
}
pub fn set_commission_rate(&mut self, value: f64) {
self.fixed.commission_rate = Self::fixed_money(value, "commission rate");
}
pub fn set_minimum_commission(&mut self, value: f64) {
self.fixed.minimum_commission = Self::fixed_money(value, "minimum commission");
}
pub fn set_transfer_fee_rate(&mut self, value: f64) {
self.fixed.transfer_fee_rate = Self::fixed_money(value, "transfer fee rate");
}
pub fn set_stamp_tax_rate_before_change(&mut self, value: f64) {
self.fixed.stamp_tax_rate_before_change =
Self::fixed_money(value, "stamp tax rate before change");
}
pub fn set_stamp_tax_rate_after_change(&mut self, value: f64) {
self.fixed.stamp_tax_rate_after_change =
Self::fixed_money(value, "stamp tax rate after change");
}
pub fn set_stamp_tax_change_date(&mut self, value: NaiveDate) {
self.fixed.stamp_tax_change_date = value;
}
pub fn commission_rate(&self) -> f64 {
self.fixed.commission_rate.to_f64()
}
pub fn minimum_commission(&self) -> f64 {
self.fixed.minimum_commission.to_f64()
}
pub fn transfer_fee_rate(&self) -> f64 {
self.fixed.transfer_fee_rate.to_f64()
}
pub fn stamp_tax_rate_before_change(&self) -> f64 {
self.fixed.stamp_tax_rate_before_change.to_f64()
}
pub fn stamp_tax_rate_after_change(&self) -> f64 {
self.fixed.stamp_tax_rate_after_change.to_f64()
}
pub fn stamp_tax_change_date(&self) -> NaiveDate {
self.fixed.stamp_tax_change_date
}
pub fn with_commission_rate(mut self, value: f64) -> Self {
self.set_commission_rate(value);
self
}
pub fn with_minimum_commission(mut self, value: f64) -> Self {
self.set_minimum_commission(value);
self
}
pub fn with_stamp_tax_rates(mut self, before: f64, after: f64) -> Self {
self.set_stamp_tax_rate_before_change(before);
self.set_stamp_tax_rate_after_change(after);
self
}
pub fn commission_for(&self, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 {
return 0.0;
}
(gross_amount * self.commission_rate).max(self.minimum_commission)
self.fixed_model()
.commission_for(Self::fixed_money(gross_amount, "gross amount"))
.to_f64()
}
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 {
self.stamp_tax_rate_before_change
} else {
self.stamp_tax_rate_after_change
}
self.fixed.stamp_tax_rate_for(date).to_f64()
}
pub fn stamp_tax_for(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 || side == OrderSide::Buy {
return 0.0;
}
gross_amount * self.stamp_tax_rate_for(date)
self.fixed_model()
.stamp_tax_for(date, side, Self::fixed_money(gross_amount, "gross amount"))
.to_f64()
}
pub fn transfer_fee_for(&self, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 {
return 0.0;
}
self.fixed_model()
.transfer_fee_for(Self::fixed_money(gross_amount, "gross amount"))
.to_f64()
}
pub fn commission_for_order_fill(
@@ -91,31 +188,29 @@ impl ChinaAShareCostModel {
return 0.0;
}
let raw_commission = gross_amount * self.commission_rate;
let Some(order_id) = order_id else {
return raw_commission.max(self.minimum_commission);
return self.commission_for(gross_amount);
};
let remaining_minimum = commission_state
.entry(order_id)
.or_insert(self.minimum_commission);
if raw_commission > *remaining_minimum {
let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 {
raw_commission
} else {
raw_commission - *remaining_minimum
};
*remaining_minimum = 0.0;
charged
} else {
let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 {
self.minimum_commission
} else {
0.0
};
*remaining_minimum -= raw_commission;
charged
}
.or_insert(self.fixed.minimum_commission.to_f64());
let mut fixed_remaining = Self::fixed_money(*remaining_minimum, "remaining commission");
let charged = self.fixed_model().commission_for_order_fill_remaining(
Self::fixed_money(gross_amount, "gross amount"),
&mut fixed_remaining,
);
*remaining_minimum = fixed_remaining.to_f64();
charged.to_f64()
}
fn fixed_money(value: f64, label: &str) -> FixedMoney {
FixedMoney::from_f64(value)
.unwrap_or_else(|| panic!("{label} is not representable as fixed-point money: {value}"))
}
fn fixed_model(&self) -> FixedChinaAShareCostModel {
self.fixed
}
}
@@ -125,16 +220,15 @@ impl CostModel for ChinaAShareCostModel {
return TradingCost {
commission: 0.0,
stamp_tax: 0.0,
transfer_fee: 0.0,
};
}
let commission = self.commission_for(gross_amount);
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
TradingCost {
commission,
stamp_tax,
}
TradingCost::from_fixed(self.fixed_model().calculate(
date,
side,
Self::fixed_money(gross_amount, "gross amount"),
))
}
fn calculate_with_order_state(
@@ -149,15 +243,79 @@ impl CostModel for ChinaAShareCostModel {
return TradingCost {
commission: 0.0,
stamp_tax: 0.0,
transfer_fee: 0.0,
};
}
let commission = self.commission_for_order_fill(gross_amount, order_id, commission_state);
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
TradingCost {
let fixed_model = self.fixed_model();
let fixed_gross = Self::fixed_money(gross_amount, "gross amount");
let commission = if let Some(order_id) = order_id {
let remaining = commission_state
.entry(order_id)
.or_insert(self.fixed.minimum_commission.to_f64());
let mut fixed_remaining = Self::fixed_money(*remaining, "remaining commission");
let commission =
fixed_model.commission_for_order_fill_remaining(fixed_gross, &mut fixed_remaining);
*remaining = fixed_remaining.to_f64();
commission
} else {
fixed_model.commission_for(fixed_gross)
};
TradingCost::from_fixed(FixedTradingCost {
commission,
stamp_tax,
}
stamp_tax: fixed_model.stamp_tax_for(date, side, fixed_gross),
transfer_fee: fixed_model.transfer_fee_for(fixed_gross),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_quantizes_fees_to_micro_yuan() {
let model = ChinaAShareCostModel::default();
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date");
assert!((model.commission_for(248_059.812) - 74.417944).abs() < 1e-12);
assert!(
(model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.873504).abs() < 1e-12
);
}
#[test]
fn cost_model_can_use_configurable_stamp_tax_change_date() {
let config = TradingConstraintConfig {
commission_rate: 0.0003,
minimum_commission: 5.0,
transfer_fee_rate: 0.00001,
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.transfer_fee_for(10_000.0) - 0.1).abs() < 1e-12);
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
);
}
}
+3766 -970
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9
View File
@@ -125,6 +125,15 @@ impl ProcessEventBus {
loader.install_enabled(self, enabled_names)
}
pub fn has_listeners_for(&self, kinds: &[ProcessEventKind]) -> bool {
!self.any_listeners.is_empty()
|| kinds.iter().any(|kind| {
self.listeners
.get(kind)
.is_some_and(|listeners| !listeners.is_empty())
})
}
pub fn publish(&mut self, event: &ProcessEvent) {
if let Some(listeners) = self.listeners.get_mut(&event.kind) {
for listener in listeners {
+321 -7
View File
@@ -1,4 +1,4 @@
use chrono::NaiveDate;
use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize};
mod date_format {
@@ -23,6 +23,62 @@ 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()
}
}
mod optional_datetime_format {
use chrono::NaiveDateTime;
use serde::{self, Deserialize, Deserializer, Serializer};
const FORMAT: &str = "%Y-%m-%d %H:%M:%S%.f";
pub fn serialize<S>(datetime: &Option<NaiveDateTime>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match datetime {
Some(datetime) => serializer.serialize_some(&datetime.format(FORMAT).to_string()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDateTime>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
value
.map(|text| {
NaiveDateTime::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom)
})
.transpose()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum OrderSide {
Buy,
@@ -45,6 +101,7 @@ pub enum OrderStatus {
PartiallyFilled,
Canceled,
Rejected,
Expired,
}
impl OrderStatus {
@@ -55,6 +112,7 @@ impl OrderStatus {
Self::PartiallyFilled => "partially_filled",
Self::Canceled => "canceled",
Self::Rejected => "rejected",
Self::Expired => "expired",
}
}
}
@@ -63,6 +121,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,
@@ -73,10 +137,72 @@ pub struct OrderEvent {
pub reason: String,
}
impl OrderEvent {
pub fn validate(&self) -> Result<(), String> {
if self.symbol.trim().is_empty() || self.requested_quantity == 0 {
return Err(format!(
"invalid order identity/quantity order_id={:?} symbol={} requested={}",
self.order_id, self.symbol, self.requested_quantity
));
}
if self.filled_quantity > self.requested_quantity {
return Err(format!(
"order overfill order_id={:?} requested={} filled={}",
self.order_id, self.requested_quantity, self.filled_quantity
));
}
let quantity_valid = match self.status {
OrderStatus::Pending => self.filled_quantity < self.requested_quantity,
OrderStatus::Filled => self.filled_quantity == self.requested_quantity,
OrderStatus::PartiallyFilled => {
self.filled_quantity > 0 && self.filled_quantity < self.requested_quantity
}
OrderStatus::Canceled => self.filled_quantity < self.requested_quantity,
OrderStatus::Rejected => self.filled_quantity == 0,
OrderStatus::Expired => self.filled_quantity < self.requested_quantity,
};
if !quantity_valid {
return Err(format!(
"order status/quantity mismatch order_id={:?} status={} requested={} filled={}",
self.order_id,
self.status.as_str(),
self.requested_quantity,
self.filled_quantity
));
}
if self.reason.trim().is_empty() {
return Err(format!(
"order reason is empty order_id={:?} status={}",
self.order_id,
self.status.as_str()
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
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,
with = "optional_datetime_format",
skip_serializing_if = "Option::is_none"
)]
pub execution_start_timestamp: Option<NaiveDateTime>,
#[serde(
default,
with = "optional_datetime_format",
skip_serializing_if = "Option::is_none"
)]
pub execution_timestamp: Option<NaiveDateTime>,
#[serde(default)]
pub order_id: Option<u64>,
pub symbol: String,
@@ -86,10 +212,47 @@ pub struct FillEvent {
pub gross_amount: f64,
pub commission: f64,
pub stamp_tax: f64,
pub transfer_fee: f64,
pub net_cash_flow: f64,
pub reason: String,
}
impl FillEvent {
pub fn validate(&self) -> Result<(), String> {
if self.symbol.trim().is_empty()
|| self.quantity == 0
|| !self.price.is_finite()
|| self.price <= 0.0
{
return Err(format!(
"invalid fill identity/quantity/price order_id={:?} symbol={} quantity={} price={}",
self.order_id, self.symbol, self.quantity, self.price
));
}
if let (Some(start), Some(end)) = (self.execution_start_timestamp, self.execution_timestamp)
{
if start > end {
return Err(format!(
"fill execution timestamp order is invalid order_id={:?} start={} end={}",
self.order_id, start, end
));
}
if start.date() != self.date || end.date() != self.date {
return Err(format!(
"fill execution timestamp date mismatch order_id={:?} fill_date={} start={} end={}",
self.order_id, self.date, start, end
));
}
} else if self.execution_start_timestamp.is_some() || self.execution_timestamp.is_some() {
return Err(format!(
"fill execution timestamp range is incomplete order_id={:?}",
self.order_id
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PositionEvent {
#[serde(with = "date_format")]
@@ -123,9 +286,9 @@ pub enum ProcessEventKind {
PreBar,
Bar,
PostBar,
PreTick,
Tick,
PostTick,
PreMinute,
Minute,
PostMinute,
PreScheduled,
PostScheduled,
PreOnDay,
@@ -143,6 +306,9 @@ pub enum ProcessEventKind {
OrderPendingCancel,
OrderCancellationPass,
OrderCancellationReject,
OrderPendingUpdate,
OrderUpdatePass,
OrderUpdateReject,
OrderUnsolicitedUpdate,
Trade,
UniverseUpdated,
@@ -165,9 +331,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",
@@ -185,6 +351,9 @@ impl ProcessEventKind {
Self::OrderPendingCancel => "order_pending_cancel",
Self::OrderCancellationPass => "order_cancellation_pass",
Self::OrderCancellationReject => "order_cancellation_reject",
Self::OrderPendingUpdate => "order_pending_update",
Self::OrderUpdatePass => "order_update_pass",
Self::OrderUpdateReject => "order_update_reject",
Self::OrderUnsolicitedUpdate => "order_unsolicited_update",
Self::Trade => "trade",
Self::UniverseUpdated => "universe_updated",
@@ -195,6 +364,38 @@ impl ProcessEventKind {
Self::AccountManagementFee => "account_management_fee",
}
}
/// Returns whether the event is part of the durable business lifecycle
/// audit. Phase boundary events are useful during interactive debugging,
/// but retaining every minute phase marker for a long run is unnecessary.
pub fn is_business_lifecycle(&self) -> bool {
matches!(
*self,
Self::PreScheduled
| Self::PostScheduled
| Self::PreOnDay
| Self::OnDay
| Self::PostOnDay
| Self::OrderPendingNew
| Self::OrderCreationPass
| Self::OrderCreationReject
| Self::OrderPendingCancel
| Self::OrderCancellationPass
| Self::OrderCancellationReject
| Self::OrderPendingUpdate
| Self::OrderUpdatePass
| Self::OrderUpdateReject
| Self::OrderUnsolicitedUpdate
| Self::Trade
| Self::UniverseUpdated
| Self::UniverseSubscribed
| Self::UniverseUnsubscribed
| Self::AccountDepositWithdraw
| Self::AccountFinanceRepay
| Self::AccountManagementFee
| Self::Settlement
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -210,3 +411,116 @@ pub struct ProcessEvent {
pub side: Option<OrderSide>,
pub detail: String,
}
#[cfg(test)]
mod tests {
use chrono::{NaiveDate, NaiveDateTime};
use super::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEventKind};
fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent {
OrderEvent {
date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
decision_date: None,
order_created_date: None,
execution_date: None,
order_id: Some(1),
symbol: "600000.SH".to_string(),
side: OrderSide::Buy,
requested_quantity: 100,
filled_quantity,
status,
reason: "test".to_string(),
}
}
#[test]
fn order_event_status_quantity_contract_is_explicit() {
assert!(order_event(OrderStatus::Pending, 0).validate().is_ok());
assert!(
order_event(OrderStatus::PartiallyFilled, 40)
.validate()
.is_ok()
);
assert!(order_event(OrderStatus::Filled, 100).validate().is_ok());
assert!(order_event(OrderStatus::Canceled, 40).validate().is_ok());
assert!(order_event(OrderStatus::Rejected, 0).validate().is_ok());
assert!(order_event(OrderStatus::Expired, 40).validate().is_ok());
assert!(
order_event(OrderStatus::PartiallyFilled, 0)
.validate()
.is_err()
);
assert!(order_event(OrderStatus::Filled, 99).validate().is_err());
assert!(order_event(OrderStatus::Canceled, 100).validate().is_err());
assert!(order_event(OrderStatus::Rejected, 1).validate().is_err());
assert!(order_event(OrderStatus::Expired, 100).validate().is_err());
}
fn fill_event(start: Option<NaiveDateTime>, end: Option<NaiveDateTime>) -> FillEvent {
FillEvent {
date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
decision_date: None,
order_created_date: None,
execution_date: None,
execution_start_timestamp: start,
execution_timestamp: end,
order_id: Some(1),
symbol: "600000.SH".to_string(),
side: OrderSide::Buy,
quantity: 100,
price: 10.0,
gross_amount: 1_000.0,
commission: 5.0,
stamp_tax: 0.0,
transfer_fee: 0.0,
net_cash_flow: -1_005.0,
reason: "test".to_string(),
}
}
#[test]
fn fill_execution_timestamp_range_is_explicit_and_backward_compatible() {
let start = NaiveDate::from_ymd_opt(2025, 1, 2)
.unwrap()
.and_hms_opt(10, 18, 0)
.unwrap();
let end = start + chrono::Duration::seconds(3);
assert!(fill_event(Some(start), Some(end)).validate().is_ok());
assert!(fill_event(Some(end), Some(start)).validate().is_err());
assert!(fill_event(Some(start), None).validate().is_err());
let next_day = start + chrono::Duration::days(1);
assert!(
fill_event(Some(next_day), Some(next_day))
.validate()
.is_err()
);
let legacy = fill_event(None, None);
let legacy_json = serde_json::to_value(&legacy).unwrap();
assert!(legacy_json.get("execution_start_timestamp").is_none());
assert!(legacy_json.get("execution_timestamp").is_none());
let decoded: FillEvent = serde_json::from_value(legacy_json).unwrap();
assert_eq!(decoded.execution_start_timestamp, None);
assert_eq!(decoded.execution_timestamp, None);
let timestamped_json = serde_json::to_value(fill_event(Some(start), Some(end))).unwrap();
assert_eq!(
timestamped_json["execution_start_timestamp"],
"2025-01-02 10:18:00"
);
assert_eq!(
timestamped_json["execution_timestamp"],
"2025-01-02 10:18:03"
);
}
#[test]
fn process_event_business_lifecycle_filter_keeps_audit_events_only() {
assert!(ProcessEventKind::OrderUpdateReject.is_business_lifecycle());
assert!(ProcessEventKind::Settlement.is_business_lifecycle());
assert!(!ProcessEventKind::PreMinute.is_business_lifecycle());
assert!(!ProcessEventKind::PostBar.is_business_lifecycle());
}
}
+566
View File
@@ -0,0 +1,566 @@
//! Fixed-point execution primitives for money and fee arithmetic.
//!
//! Market data and analytics remain floating point at their API boundaries.
//! The execution kernel quantizes monetary values to micro-yuan before fee,
//! budget and cash-ledger arithmetic so repeated fills and external cash flows
//! do not accumulate binary floating-point drift.
use std::collections::{BTreeMap, VecDeque};
use chrono::NaiveDate;
use crate::events::OrderSide;
pub const MONEY_SCALE: i128 = 1_000_000;
const MONEY_SCALE_F64: f64 = MONEY_SCALE as f64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct FixedMoney(i128);
impl FixedMoney {
pub const ZERO: Self = Self(0);
pub const fn from_raw(raw: i128) -> Self {
Self(raw)
}
pub const fn raw(self) -> i128 {
self.0
}
pub fn from_decimal_str(value: &str) -> Result<Self, String> {
let value = value.trim();
if value.is_empty() {
return Err("fixed money value is empty".to_string());
}
let (negative, unsigned) = match value.as_bytes()[0] {
b'-' => (true, &value[1..]),
b'+' => (false, &value[1..]),
_ => (false, value),
};
let mut parts = unsigned.split('.');
let whole = parts.next().unwrap_or_default();
let fractional = parts.next().unwrap_or_default();
if parts.next().is_some()
|| whole.is_empty()
|| !whole.bytes().all(|byte| byte.is_ascii_digit())
|| !fractional.bytes().all(|byte| byte.is_ascii_digit())
{
return Err(format!("invalid fixed money decimal: {value}"));
}
let whole = whole
.parse::<i128>()
.map_err(|_| format!("fixed money whole part is out of range: {value}"))?;
let mut fractional_digits = fractional.as_bytes().to_vec();
let round_up = fractional_digits.len() > 6 && fractional_digits[6] >= b'5';
fractional_digits.truncate(6);
while fractional_digits.len() < 6 {
fractional_digits.push(b'0');
}
let fractional = if fractional_digits.is_empty() {
0
} else {
std::str::from_utf8(&fractional_digits)
.expect("fractional digits are ASCII")
.parse::<i128>()
.map_err(|_| format!("fixed money fractional part is invalid: {value}"))?
};
let mut raw = whole
.checked_mul(MONEY_SCALE)
.and_then(|raw| raw.checked_add(fractional))
.ok_or_else(|| format!("fixed money value is out of range: {value}"))?;
if round_up {
raw = raw
.checked_add(1)
.ok_or_else(|| format!("fixed money value is out of range: {value}"))?;
}
Ok(Self(if negative { -raw } else { raw }))
}
pub fn from_f64(value: f64) -> Option<Self> {
if !value.is_finite() {
return None;
}
let raw = (value * MONEY_SCALE_F64).round();
if !raw.is_finite() || raw < i128::MIN as f64 || raw > i128::MAX as f64 {
return None;
}
Some(Self(raw as i128))
}
pub fn to_f64(self) -> f64 {
self.0 as f64 / MONEY_SCALE_F64
}
pub fn checked_add(self, other: Self) -> Option<Self> {
self.0.checked_add(other.0).map(Self)
}
pub fn checked_sub(self, other: Self) -> Option<Self> {
self.0.checked_sub(other.0).map(Self)
}
pub fn checked_mul_quantity(self, quantity: u64) -> Option<Self> {
self.0.checked_mul(i128::from(quantity)).map(Self)
}
pub fn checked_neg(self) -> Option<Self> {
self.0.checked_neg().map(Self)
}
pub fn checked_mul_rate(self, rate: Self) -> Option<Self> {
let product = self.0.checked_mul(rate.0)?;
let half = MONEY_SCALE / 2;
let rounded = if product >= 0 {
product.checked_add(half)? / MONEY_SCALE
} else {
product.checked_sub(half)? / MONEY_SCALE
};
Some(Self(rounded))
}
pub fn checked_sum_f64(values: impl IntoIterator<Item = f64>) -> Option<Self> {
values.into_iter().try_fold(Self::ZERO, |total, value| {
total.checked_add(Self::from_f64(value)?)
})
}
pub fn f64_fits_within(value: f64, limit: f64) -> Option<bool> {
let value = Self::from_f64(value)?;
if limit == f64::INFINITY {
return Some(true);
}
Some(value <= Self::from_f64(limit)?)
}
pub fn abs(self) -> Self {
Self(self.0.abs())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FixedTradingCost {
pub commission: FixedMoney,
pub stamp_tax: FixedMoney,
pub transfer_fee: FixedMoney,
}
impl FixedTradingCost {
pub fn total(self) -> FixedMoney {
FixedMoney::from_raw(self.commission.raw() + self.stamp_tax.raw() + self.transfer_fee.raw())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FixedChinaAShareCostModel {
pub commission_rate: FixedMoney,
pub stamp_tax_rate_before_change: FixedMoney,
pub stamp_tax_rate_after_change: FixedMoney,
pub stamp_tax_change_date: NaiveDate,
pub minimum_commission: FixedMoney,
pub transfer_fee_rate: FixedMoney,
}
impl FixedChinaAShareCostModel {
pub fn commission_for(self, gross_amount: FixedMoney) -> FixedMoney {
if gross_amount.raw() <= 0 {
return FixedMoney::ZERO;
}
let raw = gross_amount
.checked_mul_rate(self.commission_rate)
.expect("fixed commission multiplication overflow");
raw.max(self.minimum_commission)
}
pub fn stamp_tax_rate_for(self, date: NaiveDate) -> FixedMoney {
if date < self.stamp_tax_change_date {
self.stamp_tax_rate_before_change
} else {
self.stamp_tax_rate_after_change
}
}
pub fn stamp_tax_for(
self,
date: NaiveDate,
side: OrderSide,
gross_amount: FixedMoney,
) -> FixedMoney {
if gross_amount.raw() <= 0 || side == OrderSide::Buy {
return FixedMoney::ZERO;
}
gross_amount
.checked_mul_rate(self.stamp_tax_rate_for(date))
.expect("fixed stamp tax multiplication overflow")
}
pub fn transfer_fee_for(self, gross_amount: FixedMoney) -> FixedMoney {
if gross_amount.raw() <= 0 {
return FixedMoney::ZERO;
}
gross_amount
.checked_mul_rate(self.transfer_fee_rate)
.expect("fixed transfer fee multiplication overflow")
}
pub fn calculate(
self,
date: NaiveDate,
side: OrderSide,
gross_amount: FixedMoney,
) -> FixedTradingCost {
FixedTradingCost {
commission: self.commission_for(gross_amount),
stamp_tax: self.stamp_tax_for(date, side, gross_amount),
transfer_fee: self.transfer_fee_for(gross_amount),
}
}
pub fn commission_for_order_fill(
self,
gross_amount: FixedMoney,
order_id: Option<u64>,
commission_state: &mut BTreeMap<u64, FixedMoney>,
) -> FixedMoney {
if gross_amount.raw() <= 0 {
return FixedMoney::ZERO;
}
let raw = gross_amount
.checked_mul_rate(self.commission_rate)
.expect("fixed commission multiplication overflow");
let Some(order_id) = order_id else {
return raw.max(self.minimum_commission);
};
let remaining = commission_state
.entry(order_id)
.or_insert(self.minimum_commission);
self.commission_for_order_fill_remaining(gross_amount, remaining)
}
pub fn commission_for_order_fill_remaining(
self,
gross_amount: FixedMoney,
remaining: &mut FixedMoney,
) -> FixedMoney {
if gross_amount.raw() <= 0 {
return FixedMoney::ZERO;
}
let raw = gross_amount
.checked_mul_rate(self.commission_rate)
.expect("fixed commission multiplication overflow");
if raw > *remaining {
let charged = if *remaining == self.minimum_commission {
raw
} else {
raw.checked_sub(*remaining)
.expect("fixed remaining commission underflow")
};
*remaining = FixedMoney::ZERO;
charged
} else {
let charged = if *remaining == self.minimum_commission {
self.minimum_commission
} else {
FixedMoney::ZERO
};
*remaining = remaining
.checked_sub(raw)
.expect("fixed remaining commission underflow");
charged
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FixedLot {
pub acquired_date: NaiveDate,
pub quantity: u64,
pub entry_price: FixedMoney,
}
#[derive(Debug, Clone, Default)]
pub struct FixedLotBook {
lots: VecDeque<FixedLot>,
pub realized_pnl: FixedMoney,
pub quantity: u64,
}
impl FixedLotBook {
pub fn buy(&mut self, date: NaiveDate, quantity: u64, price: FixedMoney) {
if quantity == 0 {
return;
}
self.lots.push_back(FixedLot {
acquired_date: date,
quantity,
entry_price: price,
});
self.quantity = self.quantity.saturating_add(quantity);
}
pub fn sell(&mut self, quantity: u64, price: FixedMoney) -> Result<FixedMoney, String> {
if quantity > self.quantity {
return Err(format!(
"fixed sell quantity {} exceeds current quantity {}",
quantity, self.quantity
));
}
let mut remaining = quantity;
let mut realized = FixedMoney::ZERO;
while remaining > 0 {
let Some(mut lot) = self.lots.pop_front() else {
return Err("fixed lot book is empty while selling".to_string());
};
let sold = remaining.min(lot.quantity);
let price_delta = price
.checked_sub(lot.entry_price)
.and_then(|delta| delta.checked_mul_quantity(sold))
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
realized = realized
.checked_add(price_delta)
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
lot.quantity -= sold;
remaining -= sold;
if lot.quantity > 0 {
self.lots.push_front(lot);
}
}
self.quantity -= quantity;
self.realized_pnl = self
.realized_pnl
.checked_add(realized)
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
Ok(realized)
}
pub fn market_value(&self, mark_price: FixedMoney) -> FixedMoney {
mark_price
.checked_mul_quantity(self.quantity)
.expect("fixed market value overflow")
}
pub fn unrealized_pnl(&self, mark_price: FixedMoney) -> FixedMoney {
self.lots.iter().fold(FixedMoney::ZERO, |total, lot| {
let delta = mark_price
.checked_sub(lot.entry_price)
.and_then(|value| value.checked_mul_quantity(lot.quantity))
.expect("fixed unrealized PnL overflow");
total
.checked_add(delta)
.expect("fixed unrealized PnL overflow")
})
}
}
#[derive(Debug, Clone)]
pub struct FixedAccount {
pub cash: FixedMoney,
pub units: FixedMoney,
pub external_cash_flow_total: FixedMoney,
}
impl FixedAccount {
pub fn new(initial_cash: FixedMoney) -> Self {
Self {
cash: initial_cash,
units: initial_cash,
external_cash_flow_total: FixedMoney::ZERO,
}
}
pub fn apply_external_cash_flow(
&mut self,
amount: FixedMoney,
unit_nav: FixedMoney,
) -> Result<(), String> {
if unit_nav.raw() <= 0 {
return Err("fixed unit NAV must be positive".to_string());
}
let exact_units_raw = amount
.raw()
.checked_mul(MONEY_SCALE)
.and_then(|value| value.checked_div(unit_nav.raw()))
.ok_or_else(|| "fixed external flow unit conversion overflow".to_string())?;
self.cash = self
.cash
.checked_add(amount)
.ok_or_else(|| "fixed cash overflow".to_string())?;
self.units = self
.units
.checked_add(FixedMoney::from_raw(exact_units_raw))
.ok_or_else(|| "fixed units overflow".to_string())?;
self.external_cash_flow_total = self
.external_cash_flow_total
.checked_add(amount)
.ok_or_else(|| "fixed external flow overflow".to_string())?;
Ok(())
}
pub fn unit_nav(&self, total_equity: FixedMoney) -> Result<FixedMoney, String> {
if self.units.raw() <= 0 {
return Err("fixed account has no units".to_string());
}
let raw = total_equity
.raw()
.checked_mul(MONEY_SCALE)
.and_then(|value| value.checked_div(self.units.raw()))
.ok_or_else(|| "fixed unit NAV overflow".to_string())?;
Ok(FixedMoney::from_raw(raw))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cost::{ChinaAShareCostModel, CostModel};
use crate::risk_control::TradingConstraintConfig;
fn fixed_model() -> FixedChinaAShareCostModel {
let config = TradingConstraintConfig::default();
FixedChinaAShareCostModel {
commission_rate: FixedMoney::from_f64(config.commission_rate).unwrap(),
stamp_tax_rate_before_change: FixedMoney::from_f64(config.stamp_tax_rate_before_change)
.unwrap(),
stamp_tax_rate_after_change: FixedMoney::from_f64(config.stamp_tax_rate_after_change)
.unwrap(),
stamp_tax_change_date: config.stamp_tax_change_date,
minimum_commission: FixedMoney::from_f64(config.minimum_commission).unwrap(),
transfer_fee_rate: FixedMoney::from_f64(config.transfer_fee_rate).unwrap(),
}
}
#[test]
fn decimal_parser_rounds_only_beyond_money_scale() {
assert_eq!(
FixedMoney::from_decimal_str("1.234567").unwrap().raw(),
1_234_567
);
assert_eq!(
FixedMoney::from_decimal_str("1.2345675").unwrap().raw(),
1_234_568
);
assert_eq!(
FixedMoney::from_decimal_str("-0.0000014").unwrap().raw(),
-1
);
}
#[test]
fn runtime_cost_model_matches_fixed_execution_primitive() {
let fixed = fixed_model();
let float = ChinaAShareCostModel::default();
let dates = [
NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
];
for gross in [0.01, 10.0, 16_666.67, 248_059.812, 1_000_000.01] {
let fixed_gross = FixedMoney::from_f64(gross).unwrap();
for date in dates {
for side in [OrderSide::Buy, OrderSide::Sell] {
let expected = float.calculate(date, side, gross);
let actual = fixed.calculate(date, side, fixed_gross);
for (actual, expected) in [
(actual.commission, expected.commission),
(actual.stamp_tax, expected.stamp_tax),
(actual.transfer_fee, expected.transfer_fee),
] {
assert_eq!(actual.to_f64(), expected);
}
}
}
}
}
#[test]
fn runtime_split_commission_matches_fixed_execution_primitive() {
let fixed = fixed_model();
let float = ChinaAShareCostModel::default();
let mut fixed_state = BTreeMap::new();
let mut float_state = BTreeMap::new();
let mut fixed_total = FixedMoney::ZERO;
let mut float_total = 0.0;
for gross in [1000.0, 2000.0, 4000.0, 40_000.0] {
let fixed_fee = fixed.commission_for_order_fill(
FixedMoney::from_f64(gross).unwrap(),
Some(42),
&mut fixed_state,
);
let float_fee = float.commission_for_order_fill(gross, Some(42), &mut float_state);
fixed_total = fixed_total.checked_add(fixed_fee).unwrap();
float_total += float_fee;
}
assert_eq!(fixed_total.to_f64(), float_total);
}
#[test]
fn fixed_budget_never_exceeds_cash_after_cost() {
let model = fixed_model();
let date = NaiveDate::from_ymd_opt(2025, 2, 3).unwrap();
let cash = FixedMoney::from_decimal_str("99880.00").unwrap();
let price = FixedMoney::from_decimal_str("19.9731").unwrap();
let mut quantity = 5_000u64;
while quantity > 0 {
let gross = price.checked_mul_quantity(quantity).unwrap();
if gross
.checked_add(model.calculate(date, OrderSide::Buy, gross).total())
.unwrap()
<= cash
{
break;
}
quantity -= 100;
}
let gross = price.checked_mul_quantity(quantity).unwrap();
let total = gross
.checked_add(model.calculate(date, OrderSide::Buy, gross).total())
.unwrap();
assert!(total <= cash);
assert!(quantity < 5_000);
}
#[test]
fn fixed_budget_comparison_rejects_one_micro_yuan_overrun() {
assert_eq!(FixedMoney::f64_fits_within(100.0, 100.0), Some(true));
assert_eq!(FixedMoney::f64_fits_within(100.000001, 100.0), Some(false));
assert_eq!(
FixedMoney::f64_fits_within(100.000001, f64::INFINITY),
Some(true)
);
}
#[test]
fn fixed_fifo_pnl_and_external_flow_are_deterministic() {
let day_one = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let day_two = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut book = FixedLotBook::default();
book.buy(day_one, 100, FixedMoney::from_decimal_str("10.01").unwrap());
book.buy(day_two, 100, FixedMoney::from_decimal_str("10.03").unwrap());
let realized = book
.sell(150, FixedMoney::from_decimal_str("10.11").unwrap())
.unwrap();
assert_eq!(realized.raw(), 14_000_000);
assert_eq!(book.quantity, 50);
assert_eq!(
book.unrealized_pnl(FixedMoney::from_decimal_str("10.20").unwrap())
.raw(),
8_500_000
);
let mut account = FixedAccount::new(FixedMoney::from_decimal_str("100.00").unwrap());
account
.apply_external_cash_flow(
FixedMoney::from_decimal_str("50.00").unwrap(),
FixedMoney::from_decimal_str("1.00").unwrap(),
)
.unwrap();
assert_eq!(account.units.raw(), 150 * MONEY_SCALE);
assert_eq!(
account
.unit_nav(FixedMoney::from_decimal_str("150.00").unwrap())
.unwrap()
.raw(),
MONEY_SCALE
);
assert_eq!(account.external_cash_flow_total.raw(), 50 * MONEY_SCALE);
}
}
+321 -75
View File
@@ -7,6 +7,24 @@ use crate::events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind,
};
use crate::fixed_point::FixedMoney;
fn futures_money(value: f64, label: &str) -> Result<FixedMoney, String> {
FixedMoney::from_f64(value)
.ok_or_else(|| format!("{label} is not representable as fixed-point money: {value}"))
}
fn futures_money_or_panic(value: f64, label: &str) -> FixedMoney {
futures_money(value, label).unwrap_or_else(|error| panic!("{error}"))
}
fn sum_futures_money(values: impl IntoIterator<Item = FixedMoney>, label: &str) -> FixedMoney {
values.into_iter().fold(FixedMoney::ZERO, |total, value| {
total
.checked_add(value)
.unwrap_or_else(|| panic!("fixed-point {label} overflow"))
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FuturesDirection {
@@ -345,6 +363,14 @@ pub struct FuturesExecutionReport {
}
impl FuturesContractSpec {
pub fn unresolved() -> Self {
Self {
contract_multiplier: f64::NAN,
long_margin_rate: f64::NAN,
short_margin_rate: f64::NAN,
}
}
pub fn new(contract_multiplier: f64, long_margin_rate: f64, short_margin_rate: f64) -> Self {
Self {
contract_multiplier: contract_multiplier.max(1.0),
@@ -359,6 +385,15 @@ impl FuturesContractSpec {
FuturesDirection::Short => self.short_margin_rate,
}
}
pub fn is_resolved(&self) -> bool {
self.contract_multiplier.is_finite()
&& self.contract_multiplier > 0.0
&& self.long_margin_rate.is_finite()
&& self.long_margin_rate >= 0.0
&& self.short_margin_rate.is_finite()
&& self.short_margin_rate >= 0.0
}
}
#[derive(Debug, Clone)]
@@ -366,15 +401,16 @@ pub struct FuturesPosition {
pub symbol: String,
pub direction: FuturesDirection,
pub old_quantity: u32,
day_start_quantity: u32,
pub quantity: u32,
pub avg_price: f64,
pub last_price: f64,
pub prev_close: f64,
pub contract_multiplier: f64,
pub margin_rate: f64,
pub transaction_cost: f64,
transaction_cost: FixedMoney,
trade_quantity_delta: i32,
trade_cost: f64,
trade_value: FixedMoney,
}
impl FuturesPosition {
@@ -390,15 +426,16 @@ impl FuturesPosition {
symbol: symbol.into(),
direction,
old_quantity: init_quantity,
day_start_quantity: init_quantity,
quantity: init_quantity,
avg_price: init_price.max(0.0),
last_price: init_price.max(0.0),
prev_close: init_price.max(0.0),
contract_multiplier: spec.contract_multiplier,
margin_rate,
transaction_cost: 0.0,
transaction_cost: FixedMoney::ZERO,
trade_quantity_delta: 0,
trade_cost: 0.0,
trade_value: FixedMoney::ZERO,
}
}
@@ -407,18 +444,39 @@ impl FuturesPosition {
}
pub fn market_value(&self) -> f64 {
self.quantity as f64 * self.last_price * self.contract_multiplier
self.market_value_money().to_f64()
}
fn market_value_money(&self) -> FixedMoney {
futures_money_or_panic(
self.quantity as f64 * self.last_price * self.contract_multiplier,
"futures position market value",
)
}
pub fn margin(&self) -> f64 {
self.market_value() * self.margin_rate
self.margin_money().to_f64()
}
fn margin_money(&self) -> FixedMoney {
futures_money_or_panic(
self.market_value_money().to_f64() * self.margin_rate,
"futures position margin",
)
}
pub fn equity(&self) -> f64 {
(self.last_price - self.avg_price)
* self.quantity as f64
* self.contract_multiplier
* self.direction.factor()
self.equity_money().to_f64()
}
fn equity_money(&self) -> FixedMoney {
futures_money_or_panic(
(self.last_price - self.avg_price)
* self.quantity as f64
* self.contract_multiplier
* self.direction.factor(),
"futures position equity",
)
}
pub fn pnl(&self) -> f64 {
@@ -426,22 +484,47 @@ impl FuturesPosition {
}
pub fn trading_pnl(&self) -> f64 {
(self.trade_quantity_delta as f64 * self.last_price - self.trade_cost)
* self.contract_multiplier
* self.direction.factor()
self.trading_pnl_money().to_f64()
}
fn trading_pnl_money(&self) -> FixedMoney {
let marked_trade_value = futures_money_or_panic(
self.trade_quantity_delta as f64 * self.last_price * self.contract_multiplier,
"futures marked trade value",
);
let pnl = marked_trade_value
.checked_sub(self.trade_value)
.expect("fixed-point futures trading PnL overflow");
if self.direction == FuturesDirection::Short {
pnl.checked_neg()
.expect("fixed-point futures short trading PnL overflow")
} else {
pnl
}
}
pub fn position_pnl(&self) -> f64 {
if self.old_quantity == 0 {
0.0
self.position_pnl_money().to_f64()
}
fn position_pnl_money(&self) -> FixedMoney {
if self.day_start_quantity == 0 {
FixedMoney::ZERO
} else {
self.old_quantity as f64
* (self.last_price - self.prev_close)
* self.contract_multiplier
* self.direction.factor()
futures_money_or_panic(
self.day_start_quantity as f64
* (self.last_price - self.prev_close)
* self.contract_multiplier
* self.direction.factor(),
"futures position daily PnL",
)
}
}
pub fn transaction_cost(&self) -> f64 {
self.transaction_cost.to_f64()
}
pub fn open(&mut self, quantity: u32, price: f64, transaction_cost: f64) {
if quantity == 0 {
return;
@@ -450,9 +533,20 @@ impl FuturesPosition {
self.quantity += quantity;
self.avg_price = (old_value + price * quantity as f64) / self.quantity as f64;
self.last_price = price;
self.transaction_cost += transaction_cost.max(0.0);
let transaction_cost =
futures_money_or_panic(transaction_cost.max(0.0), "futures open transaction cost");
self.transaction_cost = self
.transaction_cost
.checked_add(transaction_cost)
.expect("fixed-point futures transaction cost overflow");
self.trade_quantity_delta += quantity as i32;
self.trade_cost += price * quantity as f64;
self.trade_value = self
.trade_value
.checked_add(futures_money_or_panic(
price * quantity as f64 * self.contract_multiplier,
"futures open trade value",
))
.expect("fixed-point futures trade value overflow");
}
pub fn close(
@@ -476,6 +570,17 @@ impl FuturesPosition {
transaction_cost: f64,
effect: FuturesPositionEffect,
) -> Result<f64, String> {
self.close_with_effect_money(quantity, price, transaction_cost, effect)
.map(FixedMoney::to_f64)
}
fn close_with_effect_money(
&mut self,
quantity: u32,
price: f64,
transaction_cost: f64,
effect: FuturesPositionEffect,
) -> Result<FixedMoney, String> {
if effect == FuturesPositionEffect::Open {
return Err("close_with_effect does not accept open effect".to_string());
}
@@ -489,7 +594,7 @@ impl FuturesPosition {
));
}
if quantity == 0 {
return Ok(0.0);
return Ok(FixedMoney::ZERO);
}
match effect {
FuturesPositionEffect::Open => unreachable!(),
@@ -523,19 +628,34 @@ impl FuturesPosition {
}
}
let realized = (price - self.avg_price)
* quantity as f64
* self.contract_multiplier
* self.direction.factor()
- transaction_cost.max(0.0);
let transaction_cost =
futures_money(transaction_cost.max(0.0), "futures close transaction cost")?;
let realized = futures_money(
(price - self.avg_price)
* quantity as f64
* self.contract_multiplier
* self.direction.factor(),
"futures realized PnL",
)?
.checked_sub(transaction_cost)
.ok_or_else(|| "fixed-point futures realized PnL overflow".to_string())?;
self.quantity -= quantity;
if self.quantity == 0 {
self.avg_price = 0.0;
}
self.last_price = price;
self.transaction_cost += transaction_cost.max(0.0);
self.transaction_cost = self
.transaction_cost
.checked_add(transaction_cost)
.ok_or_else(|| "fixed-point futures transaction cost overflow".to_string())?;
self.trade_quantity_delta -= quantity as i32;
self.trade_cost -= price * quantity as f64;
self.trade_value = self
.trade_value
.checked_sub(futures_money(
price * quantity as f64 * self.contract_multiplier,
"futures close trade value",
)?)
.ok_or_else(|| "fixed-point futures trade value overflow".to_string())?;
Ok(realized)
}
@@ -547,98 +667,163 @@ impl FuturesPosition {
pub fn begin_trading_day(&mut self) {
self.old_quantity = self.quantity;
self.day_start_quantity = self.quantity;
self.prev_close = self.last_price;
self.transaction_cost = 0.0;
self.transaction_cost = FixedMoney::ZERO;
self.trade_quantity_delta = 0;
self.trade_cost = 0.0;
self.trade_value = FixedMoney::ZERO;
}
pub fn settlement(&mut self, settlement_price: f64) -> f64 {
self.settlement_money(settlement_price).to_f64()
}
fn settlement_money(&mut self, settlement_price: f64) -> FixedMoney {
self.mark_price(settlement_price);
let cash_delta = self.equity();
let cash_delta = self.equity_money();
self.avg_price = self.last_price;
self.prev_close = self.last_price;
self.old_quantity = self.quantity;
cash_delta
}
}
#[derive(Debug, Clone)]
pub struct FuturesAccountState {
starting_cash: f64,
total_cash: f64,
frozen_cash: f64,
starting_cash: FixedMoney,
total_cash: FixedMoney,
frozen_cash: FixedMoney,
closed_day_trading_pnl: FixedMoney,
closed_day_position_pnl: FixedMoney,
closed_day_transaction_cost: FixedMoney,
positions: BTreeMap<(String, FuturesDirection), FuturesPosition>,
}
impl FuturesAccountState {
pub fn new(total_cash: f64) -> Self {
let total_cash = futures_money_or_panic(total_cash, "futures starting cash");
Self {
starting_cash: total_cash,
total_cash,
frozen_cash: 0.0,
frozen_cash: FixedMoney::ZERO,
closed_day_trading_pnl: FixedMoney::ZERO,
closed_day_position_pnl: FixedMoney::ZERO,
closed_day_transaction_cost: FixedMoney::ZERO,
positions: BTreeMap::new(),
}
}
pub fn starting_cash(&self) -> f64 {
self.starting_cash
self.starting_cash.to_f64()
}
pub fn total_cash(&self) -> f64 {
self.total_cash
self.total_cash.to_f64()
}
pub fn frozen_cash(&self) -> f64 {
self.frozen_cash
self.frozen_cash.to_f64()
}
pub fn cash(&self) -> f64 {
self.total_cash - self.margin() - self.frozen_cash
self.cash_money().to_f64()
}
fn cash_money(&self) -> FixedMoney {
self.total_cash
.checked_sub(self.margin_money())
.and_then(|cash| cash.checked_sub(self.frozen_cash))
.expect("fixed-point futures available cash overflow")
}
pub fn margin(&self) -> f64 {
self.positions.values().map(FuturesPosition::margin).sum()
self.margin_money().to_f64()
}
fn margin_money(&self) -> FixedMoney {
sum_futures_money(
self.positions.values().map(FuturesPosition::margin_money),
"futures account margin",
)
}
pub fn market_value(&self) -> f64 {
self.positions
.values()
.map(FuturesPosition::market_value)
.sum()
sum_futures_money(
self.positions
.values()
.map(FuturesPosition::market_value_money),
"futures account market value",
)
.to_f64()
}
pub fn position_equity(&self) -> f64 {
self.positions.values().map(FuturesPosition::equity).sum()
self.position_equity_money().to_f64()
}
fn position_equity_money(&self) -> FixedMoney {
sum_futures_money(
self.positions.values().map(FuturesPosition::equity_money),
"futures account position equity",
)
}
pub fn total_value(&self) -> f64 {
self.total_cash + self.position_equity()
self.total_cash
.checked_add(self.position_equity_money())
.expect("fixed-point futures total value overflow")
.to_f64()
}
pub fn daily_pnl(&self) -> f64 {
self.trading_pnl() + self.position_pnl() - self.transaction_cost()
self.trading_pnl_money()
.checked_add(self.position_pnl_money())
.and_then(|pnl| pnl.checked_sub(self.transaction_cost_money()))
.expect("fixed-point futures daily PnL overflow")
.to_f64()
}
pub fn trading_pnl(&self) -> f64 {
self.positions
.values()
.map(FuturesPosition::trading_pnl)
.sum()
self.trading_pnl_money().to_f64()
}
fn trading_pnl_money(&self) -> FixedMoney {
sum_futures_money(
std::iter::once(self.closed_day_trading_pnl).chain(
self.positions
.values()
.map(FuturesPosition::trading_pnl_money),
),
"futures account trading PnL",
)
}
pub fn position_pnl(&self) -> f64 {
self.positions
.values()
.map(FuturesPosition::position_pnl)
.sum()
self.position_pnl_money().to_f64()
}
fn position_pnl_money(&self) -> FixedMoney {
sum_futures_money(
std::iter::once(self.closed_day_position_pnl).chain(
self.positions
.values()
.map(FuturesPosition::position_pnl_money),
),
"futures account position PnL",
)
}
pub fn transaction_cost(&self) -> f64 {
self.positions
.values()
.map(|position| position.transaction_cost)
.sum()
self.transaction_cost_money().to_f64()
}
fn transaction_cost_money(&self) -> FixedMoney {
sum_futures_money(
std::iter::once(self.closed_day_transaction_cost).chain(
self.positions
.values()
.map(|position| position.transaction_cost),
),
"futures account transaction cost",
)
}
pub fn positions(&self) -> &BTreeMap<(String, FuturesDirection), FuturesPosition> {
@@ -667,7 +852,13 @@ impl FuturesAccountState {
.entry((symbol.clone(), direction))
.or_insert_with(|| FuturesPosition::new(symbol, direction, spec, 0, price));
position.open(quantity, price, transaction_cost);
self.total_cash -= transaction_cost.max(0.0);
self.total_cash = self
.total_cash
.checked_sub(futures_money_or_panic(
transaction_cost.max(0.0),
"futures open transaction cost",
))
.expect("fixed-point futures cash overflow");
}
pub fn close(
@@ -702,12 +893,30 @@ impl FuturesAccountState {
.positions
.get_mut(&key)
.ok_or_else(|| format!("missing futures position {symbol} {}", direction.as_str()))?;
let cash_delta = position.close_with_effect(quantity, price, transaction_cost, effect)?;
self.total_cash += cash_delta;
let cash_delta =
position.close_with_effect_money(quantity, price, transaction_cost, effect)?;
self.total_cash = self
.total_cash
.checked_add(cash_delta)
.ok_or_else(|| "fixed-point futures cash overflow".to_string())?;
if position.quantity == 0 {
self.closed_day_trading_pnl = self
.closed_day_trading_pnl
.checked_add(position.trading_pnl_money())
.ok_or_else(|| "fixed-point closed futures trading PnL overflow".to_string())?;
self.closed_day_position_pnl = self
.closed_day_position_pnl
.checked_add(position.position_pnl_money())
.ok_or_else(|| "fixed-point closed futures position PnL overflow".to_string())?;
self.closed_day_transaction_cost = self
.closed_day_transaction_cost
.checked_add(position.transaction_cost)
.ok_or_else(|| {
"fixed-point closed futures transaction cost overflow".to_string()
})?;
self.positions.remove(&key);
}
Ok(cash_delta)
Ok(cash_delta.to_f64())
}
pub fn execute_order(
@@ -746,6 +955,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,
@@ -779,7 +991,7 @@ impl FuturesAccountState {
intent.price,
intent.transaction_cost,
);
if projected.cash() < -1e-8 {
if projected.cash_money().raw() < 0 {
Err(format!(
"insufficient futures margin available_cash={:.2} required_margin_after={:.2}",
self.cash(),
@@ -794,7 +1006,13 @@ impl FuturesAccountState {
intent.price,
intent.transaction_cost,
);
Ok(-intent.transaction_cost.max(0.0))
Ok(futures_money_or_panic(
intent.transaction_cost.max(0.0),
"futures open transaction cost",
)
.checked_neg()
.expect("fixed-point futures open cash delta overflow")
.to_f64())
}
}
FuturesPositionEffect::Close
@@ -819,18 +1037,32 @@ impl FuturesAccountState {
.position(&intent.symbol, intent.direction)
.map(|position| position.avg_price)
.unwrap_or(0.0);
let notional =
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier;
let notional = futures_money_or_panic(
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier,
"futures fill notional",
)
.to_f64();
let transaction_cost = futures_money_or_panic(
intent.transaction_cost.max(0.0),
"futures fill transaction cost",
)
.to_f64();
report.fill_events.push(FillEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
execution_start_timestamp: None,
execution_timestamp: None,
order_id,
symbol: intent.symbol.clone(),
side,
quantity: intent.quantity,
price: intent.price,
gross_amount: notional,
commission: intent.transaction_cost.max(0.0),
commission: transaction_cost,
stamp_tax: 0.0,
transfer_fee: 0.0,
net_cash_flow: cash_delta,
reason: format!(
"{} direction={} effect={}",
@@ -889,6 +1121,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 +1150,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,
@@ -997,22 +1235,30 @@ impl FuturesAccountState {
}
pub fn begin_trading_day(&mut self) {
self.closed_day_trading_pnl = FixedMoney::ZERO;
self.closed_day_position_pnl = FixedMoney::ZERO;
self.closed_day_transaction_cost = FixedMoney::ZERO;
for position in self.positions.values_mut() {
position.begin_trading_day();
}
}
pub fn settle(&mut self, settlement_prices: &BTreeMap<String, f64>) -> f64 {
let mut cash_delta = 0.0;
let mut cash_delta = FixedMoney::ZERO;
for position in self.positions.values_mut() {
let price = settlement_prices
.get(&position.symbol)
.copied()
.unwrap_or(position.last_price);
cash_delta += position.settlement(price);
cash_delta = cash_delta
.checked_add(position.settlement_money(price))
.expect("fixed-point futures settlement overflow");
}
self.total_cash += cash_delta;
cash_delta
self.total_cash = self
.total_cash
.checked_add(cash_delta)
.expect("fixed-point futures cash settlement overflow");
cash_delta.to_f64()
}
}
+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())
}
}
+39 -12
View File
@@ -5,38 +5,51 @@ pub mod data;
pub mod engine;
pub mod event_bus;
pub mod events;
pub mod fixed_point;
pub mod futures;
pub mod instrument;
pub mod metrics;
mod numeric_expr_vm;
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::{
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote,
IntradayOrderBookDepthLevel, PriceBar, PriceField, SecuritiesMarginRecord, SplitRecord,
YieldCurvePoint,
IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar, PriceField, SecuritiesMarginRecord,
SplitRecord, YieldCurvePoint,
};
pub use engine::{
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
BacktestResult, DailyEquityPoint, FuturesValidationConfig,
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
ProcessEventRetention,
};
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
pub use events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind,
};
pub use fixed_point::{
FixedAccount, FixedChinaAShareCostModel, FixedLotBook, FixedMoney, FixedTradingCost,
MONEY_SCALE,
};
pub use futures::{
FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
FuturesExecutionReport, FuturesOrderIntent, FuturesPosition, FuturesPositionEffect,
@@ -47,33 +60,47 @@ pub use metrics::{BacktestMetrics, compute_backtest_metrics};
pub use platform_expr_strategy::{
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
PlatformUniverseActionKind,
};
pub use platform_runtime_schema::{
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
rhai_builtin_functions, rhai_keywords, runtime_helper_functions, runtime_schema,
runtime_schema_json,
};
pub use platform_strategy_spec::{
DynamicRangeConfig, IndexThrottleConfig, MovingAverageFilterConfig, SkipWindowConfig,
StrategyBenchmarkSpec, StrategyEngineConfig, StrategyExecutionSpec,
StrategyExpressionActionConfig, StrategyExpressionAllocationConfig,
StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig,
StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig,
StrategyExpressionTradingConfig, StrategyRuntimeEnvironment, StrategyRuntimeExpressions,
StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value,
StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig,
StrategyRebalanceSpec, StrategyRiskPolicySpec, StrategyRuntimeEnvironment,
StrategyRuntimeExpressions, StrategyRuntimeSpec, StrategyUniverseSpec,
platform_expr_config_from_spec, platform_expr_config_from_value,
validate_strategy_risk_policy_fields,
};
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,
};
pub use strategy::{
AlgoOrderStyle, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, OmniMicroCapConfig,
OmniMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, PortfolioRuntimeView,
Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing,
OmniMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, OrderTimeInForce,
PortfolioRuntimeView, Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing,
};
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,
+156 -19
View File
@@ -4,7 +4,7 @@ use chrono::{Datelike, NaiveDate};
use serde::{Deserialize, Serialize};
use crate::engine::DailyEquityPoint;
use crate::events::FillEvent;
use crate::events::{AccountEvent, FillEvent};
use crate::portfolio::HoldingSummary;
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
@@ -47,6 +47,11 @@ pub struct BacktestMetrics {
pub cash_balance: f64,
pub unit_nav: f64,
pub initial_cash: f64,
/// Sum of external deposits (positive) and withdrawals (negative). This
/// is reported separately so callers cannot mistake a cash transfer for
/// trading performance.
#[serde(default)]
pub external_cash_flow_total: f64,
pub excess_win_rate: f64,
pub monthly_sharpe: f64,
pub monthly_volatility: f64,
@@ -56,6 +61,7 @@ pub fn compute_backtest_metrics(
equity_curve: &[DailyEquityPoint],
fills: &[FillEvent],
daily_holdings: &[HoldingSummary],
account_events: &[AccountEvent],
initial_cash: f64,
) -> BacktestMetrics {
let Some(first_point) = equity_curve.first() else {
@@ -81,12 +87,28 @@ pub fn compute_backtest_metrics(
} else {
first_point.benchmark_close
};
let mut returns = Vec::with_capacity(equity_curve.len());
returns.push(pct_change(initial_cash, first_point.total_equity));
returns.extend(
let explicit_unit_nav = equity_curve.iter().any(|point| {
point.external_cash_flow.abs() > f64::EPSILON
|| (point.unit_nav.is_finite()
&& point.unit_nav > 0.0
&& (point.unit_nav - safe_div(point.total_equity, initial_cash, 1.0)).abs() > 1e-12)
});
let portfolio_nav = if explicit_unit_nav {
equity_curve
.iter()
.map(|point| point_nav(point, initial_cash))
.collect::<Vec<_>>()
} else {
flow_neutral_nav_series(equity_curve, account_events, initial_cash)
};
let mut returns = Vec::with_capacity(portfolio_nav.len());
if let Some(first_nav) = portfolio_nav.first().copied() {
returns.push(pct_change(1.0, first_nav));
}
returns.extend(
portfolio_nav
.windows(2)
.map(|window| pct_change(window[0].total_equity, window[1].total_equity)),
.map(|window| pct_change(window[0], window[1])),
);
let mut benchmark_returns = Vec::with_capacity(equity_curve.len());
benchmark_returns.push(pct_change(benchmark_start, first_point.benchmark_close));
@@ -107,15 +129,12 @@ pub fn compute_backtest_metrics(
last_point.benchmark_close / benchmark_start
};
let benchmark_cumulative_return = benchmark_net_value - 1.0;
let total_return = if initial_cash.abs() < f64::EPSILON {
0.0
} else {
(last_point.total_equity / initial_cash) - 1.0
};
let final_nav = portfolio_nav.last().copied().unwrap_or(1.0);
let total_return = final_nav - 1.0;
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
total_return
} else {
(last_point.total_equity / initial_cash) / benchmark_net_value - 1.0
portfolio_nav.last().copied().unwrap_or(0.0) / benchmark_net_value - 1.0
};
let excess_return = total_return - benchmark_cumulative_return;
let annual_return = annualize_return(total_return, trade_days);
@@ -132,10 +151,7 @@ pub fn compute_backtest_metrics(
let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
let equity_nav = equity_curve
.iter()
.map(|point| safe_div(point.total_equity, initial_cash, 1.0))
.collect::<Vec<_>>();
let equity_nav = portfolio_nav;
let benchmark_nav_series = equity_curve
.iter()
.map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0))
@@ -154,8 +170,7 @@ pub fn compute_backtest_metrics(
let win_rate = ratio(winning_days, returns.len());
let excess_win_rate = ratio(excess_winning_days, excess_returns.len());
let monthly_portfolio_returns =
group_monthly_returns(equity_curve, initial_cash, |point| point.total_equity);
let monthly_portfolio_returns = group_monthly_returns_from_values(equity_curve, &equity_nav);
let monthly_benchmark_returns =
group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close);
let monthly_excess_returns = monthly_portfolio_returns
@@ -257,14 +272,30 @@ pub fn compute_backtest_metrics(
average_daily_turnover,
total_assets: last_point.total_equity,
cash_balance: last_point.cash,
unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0),
unit_nav: final_nav,
initial_cash,
external_cash_flow_total: if explicit_unit_nav {
equity_curve
.iter()
.map(|point| point.external_cash_flow)
.sum()
} else {
external_flow_total_from_events(account_events)
},
excess_win_rate,
monthly_sharpe,
monthly_volatility,
}
}
fn point_nav(point: &DailyEquityPoint, initial_cash: f64) -> f64 {
if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
point.unit_nav
} else {
safe_div(point.total_equity, initial_cash, 1.0)
}
}
fn pct_change(previous: f64, current: f64) -> f64 {
if previous.abs() < f64::EPSILON {
0.0
@@ -384,6 +415,80 @@ fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
(max_drawdown, max_duration)
}
fn flow_neutral_nav_series(
equity_curve: &[DailyEquityPoint],
account_events: &[AccountEvent],
initial_cash: f64,
) -> Vec<f64> {
let mut external_flow_by_date = BTreeMap::<NaiveDate, f64>::new();
for event in account_events {
if !(event.note.starts_with("deposit_withdraw amount=")
|| event.note.starts_with("deposit_withdraw_settled amount="))
{
continue;
}
*external_flow_by_date.entry(event.date).or_default() +=
event.cash_after - event.cash_before;
}
let mut units = initial_cash;
let mut previous_equity = initial_cash;
let mut navs = Vec::with_capacity(equity_curve.len());
for point in equity_curve {
let unit_nav_before_flow = safe_div(previous_equity, units, 1.0);
let external_flow = external_flow_by_date
.get(&point.date)
.copied()
.unwrap_or_default();
if external_flow.abs() > f64::EPSILON && unit_nav_before_flow.is_finite() {
units += external_flow / unit_nav_before_flow;
}
let unit_nav = safe_div(point.total_equity, units, 0.0);
navs.push(unit_nav);
previous_equity = point.total_equity;
}
navs
}
fn external_flow_total_from_events(account_events: &[AccountEvent]) -> f64 {
account_events
.iter()
.filter(|event| {
event.note.starts_with("deposit_withdraw amount=")
|| event.note.starts_with("deposit_withdraw_settled amount=")
})
.map(|event| event.cash_after - event.cash_before)
.sum()
}
fn group_monthly_returns_from_values(
equity_curve: &[DailyEquityPoint],
values: &[f64],
) -> Vec<f64> {
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
let mut month_first = BTreeMap::<(i32, u32), f64>::new();
let mut previous_value = 1.0;
for (point, value) in equity_curve.iter().zip(values.iter().copied()) {
let key = (point.date.year(), point.date.month());
month_first.entry(key).or_insert(previous_value);
month_last.insert(key, value);
previous_value = value;
}
let mut keys = month_last.keys().copied().collect::<Vec<_>>();
keys.sort_unstable();
keys.into_iter()
.filter_map(|key| {
let first = month_first.get(&key).copied().unwrap_or_default();
let last = month_last.get(&key).copied().unwrap_or_default();
if first.abs() < f64::EPSILON {
None
} else {
Some((last / first) - 1.0)
}
})
.collect()
}
fn group_monthly_returns<F>(
equity_curve: &[DailyEquityPoint],
initial_value: f64,
@@ -486,6 +591,8 @@ mod tests {
cash: total_equity,
market_value: 0.0,
total_equity,
external_cash_flow: 0.0,
unit_nav: total_equity / 100.0,
benchmark_close,
benchmark_prev_close,
notes: String::new(),
@@ -499,8 +606,38 @@ mod tests {
equity_point("2025-01-02", 100.0, 5797.089, 5957.717),
equity_point("2025-12-31", 120.0, 7595.285, 7597.299),
];
let metrics = compute_backtest_metrics(&curve, &[], &[], 100.0);
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0);
let expected = 7595.285 / 5957.717 - 1.0;
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
}
#[test]
fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
let curve = vec![
equity_point("2025-01-02", 100.0, 100.0, 100.0),
DailyEquityPoint {
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
cash: 220.0,
market_value: 0.0,
total_equity: 220.0,
external_cash_flow: 100.0,
unit_nav: 1.1,
benchmark_close: 100.0,
benchmark_prev_close: 100.0,
notes: String::new(),
diagnostics: String::new(),
},
];
let events = vec![AccountEvent {
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
cash_before: 100.0,
cash_after: 200.0,
total_equity: 200.0,
note: "deposit_withdraw amount=100.00 reason=test".to_string(),
}];
let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0);
assert!((metrics.total_return - 0.1).abs() < 1e-12);
assert!((metrics.unit_nav - 1.1).abs() < 1e-12);
assert!((metrics.external_cash_flow_total - 100.0).abs() < 1e-12);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,354 @@
//! DSP 运行时变量与函数 schema 导出。
//!
//! 这是前后端共享的"事实源":把引擎里 reserved_scope_names 和 is_runtime_helper
//! 等清单按 JSON Schema 暴露出来,供 omniquant 前端在编译期做表达式标识符校验。
//!
//! 维护原则:
//! - 任何对 platform_expr_strategy.rs 中变量名 / 函数名清单的修改都必须在这里
//! 同步一份。两侧一致由 unit test `runtime_schema_matches_strategy_runtime`
//! 守住。
//! - 该 schema 的 version 字段需要与 omniquant/src/platformSchema.ts 里
//! PLATFORM_RUNTIME_SCHEMA_VERSION 保持一致。前端读到不同版本时应给出诊断。
use serde::Serialize;
use serde_json::Value;
/// 当前 schema 版本号。每次 reserved/runtime 列表的破坏性变更需要 +1。
pub const PLATFORM_RUNTIME_SCHEMA_VERSION: &str = "1";
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlatformRuntimeSchema {
pub version: &'static str,
pub reserved_scope_names: Vec<&'static str>,
pub runtime_helper_functions: Vec<&'static str>,
pub rhai_builtin_functions: Vec<&'static str>,
pub rhai_keywords: Vec<&'static str>,
}
/// reserved scope names 列表。镜像 PlatformExprStrategy::reserved_scope_names。
pub fn reserved_scope_names() -> &'static [&'static str] {
RESERVED_SCOPE_NAMES
}
/// runtime helper functions 列表。镜像 PlatformExprStrategy::is_runtime_helper。
pub fn runtime_helper_functions() -> &'static [&'static str] {
RUNTIME_HELPER_FUNCTIONS
}
/// rhai engine 注册的内置函数列表。镜像 PlatformExprStrategy::new 中 register_fn
/// 的清单。
pub fn rhai_builtin_functions() -> &'static [&'static str] {
RHAI_BUILTIN_FUNCTIONS
}
/// rhai 控制流关键字(避免被前端校验视为未知)。
pub fn rhai_keywords() -> &'static [&'static str] {
RHAI_KEYWORDS
}
/// 构造完整 schema。
pub fn runtime_schema() -> PlatformRuntimeSchema {
PlatformRuntimeSchema {
version: PLATFORM_RUNTIME_SCHEMA_VERSION,
reserved_scope_names: RESERVED_SCOPE_NAMES.to_vec(),
runtime_helper_functions: RUNTIME_HELPER_FUNCTIONS.to_vec(),
rhai_builtin_functions: RHAI_BUILTIN_FUNCTIONS.to_vec(),
rhai_keywords: RHAI_KEYWORDS.to_vec(),
}
}
/// 把 schema 序列化为 JSON Value。给 fidc-data-center / strategy-runtime 接口使用。
pub fn runtime_schema_json() -> Value {
serde_json::to_value(runtime_schema()).expect("runtime schema serialization is infallible")
}
const RESERVED_SCOPE_NAMES: &[&str] = &[
// day-level
"signal_close",
"benchmark_close",
"benchmark_signal_close",
"signal_ma5",
"signal_ma10",
"signal_ma20",
"signal_ma30",
"benchmark_ma5",
"benchmark_ma10",
"benchmark_ma20",
"benchmark_ma30",
"benchmark_ma_short",
"benchmark_ma_long",
"cash",
"available_cash",
"frozen_cash",
"market_value",
"total_equity",
"total_value",
"portfolio_value",
"starting_cash",
"unit_net_value",
"static_unit_net_value",
"daily_pnl",
"daily_returns",
"total_returns",
"cash_liabilities",
"management_fee_rate",
"management_fees",
"current_exposure",
"position_count",
"max_positions",
"refresh_rate",
"year",
"month",
"quarter",
"day_of_month",
"day_of_year",
"week_of_year",
"weekday",
"is_month_start",
"is_month_end",
"has_open_orders",
"open_order_count",
"open_buy_order_count",
"open_sell_order_count",
"open_buy_qty",
"open_sell_qty",
"latest_open_order_id",
"latest_open_order_status",
"latest_open_order_unfilled_qty",
"has_process_events",
"process_event_count",
"current_process_kind",
"current_process_order_id",
"current_process_symbol",
"current_process_side",
"current_process_detail",
"latest_process_kind",
"latest_process_order_id",
"latest_process_symbol",
"latest_process_side",
"latest_process_detail",
"process_event_counts",
"day_factors",
// stock-level
"symbol",
"market_cap",
"free_float_cap",
"pe_ttm",
"volume",
"minute_volume",
"bid1_volume",
"ask1_volume",
"turnover_ratio",
"effective_turnover_ratio",
"up_days_stock",
"open",
"high",
"low",
"close",
"last",
"last_price",
"prev_close",
"amount",
"upper_limit",
"lower_limit",
"price_tick",
"round_lot",
"paused",
"is_st",
"is_star_st",
"is_kcb",
"is_bjse",
"is_one_yuan",
"is_new_listing",
"allow_buy",
"allow_sell",
"touched_upper_limit",
"touched_lower_limit",
"hit_upper_limit",
"hit_lower_limit",
"listed_days",
"symbol_open_order_count",
"symbol_open_buy_qty",
"symbol_open_sell_qty",
"latest_symbol_open_order_id",
"latest_symbol_open_order_status",
"latest_symbol_open_order_unfilled_qty",
"stock_ma_short",
"stock_ma_mid",
"stock_ma_long",
"stock_ma5",
"stock_ma10",
"stock_ma20",
"stock_ma30",
"ma5",
"ma10",
"ma20",
"ma30",
"factors",
"order_book_id",
// position-level
"avg_cost",
"avg_price",
"current_price",
"position_prev_close",
"prev_position_close",
"holding_return",
"quantity",
"sellable_qty",
"sellable",
"closable",
"old_quantity",
"buy_quantity",
"sell_quantity",
"bought_quantity",
"sold_quantity",
"buy_avg_price",
"sell_avg_price",
"bought_value",
"sold_value",
"transaction_cost",
"position_market_value",
"equity",
"value_percent",
"unrealized_pnl",
"realized_pnl",
"pnl",
"day_trade_quantity_delta",
"profit_pct",
"trading_pnl",
"position_pnl",
"dividend_receivable",
"at_upper_limit",
"at_lower_limit",
];
const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
"factor",
"day_factor",
"rolling_mean",
"rolling_mean_current",
"rolling_max_current",
"rolling_return_stddev_current",
"ma",
"sma",
"vma",
"rolling_sum",
"rolling_min",
"rolling_max",
"rolling_stddev",
"stddev",
"rolling_zscore",
"pct_change",
"factor_value",
"get_factor_value",
"factor_text",
"get_factor_text",
"dividend_cash",
"has_dividend",
"split_ratio",
"has_split",
"securities_margin",
"get_securities_margin_value",
"shares",
"get_shares_value",
"turnover_rate",
"get_turnover_rate_value",
"price_change_rate",
"get_price_change_rate_value",
"stock_connect",
"get_stock_connect_value",
"current_performance",
"fundamental",
"get_fundamentals_value",
"financial",
"get_financials_value",
"pit_financial",
"get_pit_financials_value",
"industry_code",
"get_industry_code",
"industry_name",
"get_industry_name",
"yield_curve",
"get_yield_curve_value",
"is_margin_stock",
"dominant_future",
"get_dominant_future",
"dominant_future_price",
"get_dominant_future_price_value",
];
const RHAI_BUILTIN_FUNCTIONS: &[&str] = &[
"round",
"floor",
"ceil",
"abs",
"min",
"max",
"sqrt",
"pow",
"log",
"exp",
"clamp",
"between",
"nz",
"safe_div",
"iff",
"contains",
"starts_with",
"ends_with",
"lower",
"upper",
"trim",
"strlen",
];
const RHAI_KEYWORDS: &[&str] = &[
"if", "else", "while", "loop", "for", "in", "break", "continue", "return", "fn", "let",
"const", "true", "false", "switch", "do",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runtime_schema_serializes_to_json_object() {
let value = runtime_schema_json();
assert!(value.is_object());
assert_eq!(value["version"], "1");
assert!(value["reservedScopeNames"].is_array());
assert!(value["runtimeHelperFunctions"].is_array());
assert!(value["rhaiBuiltinFunctions"].is_array());
assert!(value["rhaiKeywords"].is_array());
}
#[test]
fn runtime_schema_includes_known_identifiers() {
let names: std::collections::HashSet<&str> = RESERVED_SCOPE_NAMES.iter().copied().collect();
for required in [
"signal_close",
"benchmark_close",
"benchmark_signal_close",
"close",
"avg_cost",
"current_price",
"stock_ma_short",
"up_days_stock",
] {
assert!(
names.contains(required),
"missing reserved name: {required}"
);
}
let helpers: std::collections::HashSet<&str> =
RUNTIME_HELPER_FUNCTIONS.iter().copied().collect();
for required in ["rolling_mean", "factor", "pct_change"] {
assert!(
helpers.contains(required),
"missing helper function: {required}"
);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
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()
+35 -2
View File
@@ -7,7 +7,7 @@ pub enum ScheduleStage {
BeforeTrading,
OpenAuction,
Bar,
Tick,
Minute,
OnDay,
AfterTrading,
Settlement,
@@ -164,6 +164,16 @@ impl<'a> Scheduler<'a> {
.collect()
}
/// Evaluate only the trading-calendar frequency of a rule.
///
/// Strategy callbacks and order execution clocks are separate contracts:
/// a 15:00 schedule is still due on the same daily/weekly/monthly trading
/// date even when the engine's coarse `on_day` callback runs at another
/// default time. Exact clock matching remains in `triggered_rules_at`.
pub fn is_due_on(&self, date: NaiveDate, rule: &ScheduleRule) -> bool {
self.matches(date, rule)
}
fn matches(&self, date: NaiveDate, rule: &ScheduleRule) -> bool {
match &rule.frequency {
ScheduleFrequency::Daily => true,
@@ -225,7 +235,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")),
@@ -265,6 +275,29 @@ mod tests {
])
}
#[test]
fn date_due_is_independent_from_the_order_execution_clock() {
let calendar = sample_calendar();
let scheduler = Scheduler::new(&calendar);
let daily = ScheduleRule::daily("close_signal", ScheduleStage::OnDay)
.with_time_rule(ScheduleTimeRule::physical_time(15, 0));
assert!(scheduler.is_due_on(d(2025, 1, 30), &daily));
assert!(scheduler.is_due_on(d(2025, 1, 31), &daily));
assert!(scheduler.triggered_rules_at(
d(2025, 1, 30),
ScheduleStage::OnDay,
Some(NaiveTime::from_hms_opt(15, 0, 0).unwrap()),
std::slice::from_ref(&daily),
).len() == 1);
assert!(scheduler.triggered_rules_at(
d(2025, 1, 30),
ScheduleStage::OnDay,
Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()),
std::slice::from_ref(&daily),
).is_empty());
}
#[test]
fn scheduler_matches_daily_weekly_and_monthly_rules() {
let calendar = sample_calendar();
File diff suppressed because it is too large Load Diff
+185 -36
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 PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT: &str = "收益验收合同:收益、回撤、年度收益、样本外区间及比较运算符只能来自用户目标、请求约束或不可变 candidate/promotion contract;不得注入 120% 或其他默认数值,也不得提高、降低或替换已经明确的门槛。没有明确数值合同时只做策略有效性、数据时序和风险审计,禁止声称收益已经达标;存在冻结合同时必须逐项按原运算符验证,不能只看总收益。";
const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "max_order_quantity=1000000、max_order_notional=100000000、max_symbol_position=10000000、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 = "max_order_quantity=1000000, max_order_notional=100000000, max_symbol_position=10000000, 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,10 +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 会在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数;不得把执行日 open/high/low/close 当成下单前已知信息,也不得用 T+1 prev_close 或 T 日估算股数直接成交;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close。".to_string(),
"自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(),
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
],
@@ -165,6 +200,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
title: "诊断解释".to_string(),
detail: "结果为空或收益异常时优先展示 diagnostics、选股数量、过滤原因、缺失字段、窗口不足、涨跌停/停牌拒单、快照缓存命中情况。不要只返回 JSON;要给用户自然语言结论和下一步优化建议。".to_string(),
},
ManualSection {
title: "收益合理性复核".to_string(),
detail: "展示或用于优化前,应按 finalEquity / initialCash - 1 复算总收益。若小资金回测出现极端收益、指标与资金不一致、或历史 run 来自旧引擎,应检查交易明细并用当前编译后的回测引擎重新回测,不要把异常 run 当成成功样本。".to_string(),
},
],
optimization_playbook: vec![
ManualSection {
@@ -198,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(),
@@ -209,21 +248,33 @@ 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(...) 支持 max_order_quantity、max_order_notional、max_symbol_position,以及 ST/*ST、停牌、退市、新股、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量、流动性和交易成本等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。框架默认基础风控必须走 risk.policy 或运行态 RiskLimits,不能被转换器隐式写进 universe.exclude 或 filter.stock_expr;源策略明确写出的业务选股排除属于策略本身,必须原样保留,不能反向修改冻结的 reject_*_selection 开关;冻结的 `reject_*_selection` 值不得改变。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 成交;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 在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数,保证执行金额加手续费不超过分配金额;禁止把执行日 open/high/low/close 解释为下单前已知数据,也禁止用 T+1 prev_close 或 T 日估算股数直接成交;next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。金额预算始终严格,execution.strict_value_budget(false) 会被拒绝。分钟线回测使用当前分钟价格成交,只能写 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.002) / 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(),
title: "期货 runtime action 与提交校验".to_string(),
detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓".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(),
title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(),
detail: "支持股票显式下单、期货 runtime action、撤单、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, time_in_force=\"gtc\")、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})、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)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForceDAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
},
ManualSection {
title: "order.time_in_force target runtime scope".to_string(),
detail: "回测支持 DAY/IOC/FOK/GTCpaper/live 当前只支持 DAY/IOC/FOK。GTC 需要持久化跨交易日 parent/child 重挂账本和券商适配器能力,在该合同实现前只允许回测,paper/live 必须明确拒绝并禁止降级为 DAY。生成策略前必须按目标运行模式选择能力。".to_string(),
},
ManualSection {
title: "order.modify".to_string(),
detail: "回测中可用 order.modify(order_id, total_quantity=?, limit_price=?) 原位修改仍未完成的限价单。total_quantity 是新的总委托量而不是增量,不能低于已成交量;改价或增量会重置盘口队列优先级,减少总量且不改价保留优先级,同时保留 order_id、有效期、累计成交和费用状态。paper/live 在适配器提供持久且确认的 cancel-replace 合同前必须拒绝该动作,不得静默转换为撤单加新订单。".to_string(),
},
ManualSection {
title: "when / unless / else".to_string(),
@@ -237,6 +288,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
fields: vec![
ManualField { name: "signal_open/signal_close".to_string(), field_type: "float".to_string(), detail: "信号指数当日开盘价与前一日收盘价。".to_string() },
ManualField { name: "benchmark_open/benchmark_close".to_string(), field_type: "float".to_string(), detail: "基准当日开盘价与前一日收盘价。".to_string() },
ManualField { name: "benchmark_signal_close".to_string(), field_type: "float".to_string(), detail: "信号日收盘后可见的基准当日收盘价;用于 T 日生成信号、T+1 开盘成交的条件,不能在盘中或 T 日开盘决策中使用。".to_string() },
ManualField { name: "signal_ma5/signal_ma10/signal_ma20/signal_ma30".to_string(), field_type: "float".to_string(), detail: "信号指数滚动均线。".to_string() },
ManualField { name: "benchmark_ma5/benchmark_ma10/benchmark_ma20/benchmark_ma30".to_string(), field_type: "float".to_string(), detail: "基准指数滚动均线。".to_string() },
ManualField { name: "cash/available_cash/frozen_cash/market_value/total_equity".to_string(), field_type: "float".to_string(), detail: "账户可用资金、挂单冻结资金、市值与总权益;available_cash 会扣减当前买入挂单冻结估算。".to_string() },
@@ -262,17 +314,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() },
],
@@ -299,13 +351,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() },
@@ -322,14 +374,14 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
ManualFunction { name: "get_dominant_future / dominant_future / dominant_future_price".to_string(), signature: "dominant_future(\"IF\") / dominant_future_price(\"IF\", \"close\", lookback=1)".to_string(), detail: "主力合约 API。dominant_future 返回当前日期匹配前缀的主力期货合约代码;dominant_future_price 读取该主力合约最近 N 个交易日指定字段的最新价格。Rust Context 可用 ctx.get_dominant_future(...) 和 ctx.get_dominant_future_price(...)。".to_string() },
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: "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 与 management_fee 按账户合同结算。模拟盘只接受由 runtime 明确返回的即时 deposit_withdraw,并通过幂等现金流账本落库;延迟流、融资/管理费动作必须显式失败。实盘禁止策略侧改变现金,必须以券商资产和已核验资金流水为真相,策略返回上述动作会在下单前 fail-closed".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![
@@ -355,12 +407,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: "来自 futures_contract_spec_history 与 futures_cost_margin_history字段包括 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。回测按交易日选择不晚于当前日期的最新参数。schema catalog 未同时发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 时,该能力视为不可用".to_string(),
fields: vec![],
},
],
@@ -378,8 +430,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(),
@@ -427,13 +479,23 @@ 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 注入。源策略明确写出的业务选股排除必须保留为策略表达式,不能改写冻结的 selection 风控开关。\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(...)` 只能传一个表达式;`execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
out.push_str(
"- `risk.index_exposure(...)` 只能传一个表达式;不要生成 `risk.exposure(...)`。\n",
);
out.push_str("- `filter.stock_expr(...)` 只写 alpha 或策略明确声明的业务过滤条件;转换器不得自行把框架默认基础风控注入过滤表达式。源策略明确写出的 `!is_st`、`!is_star_st`、`!is_kcb`、`!is_bjse` 等业务选股排除必须原样保留,同时冻结的 `reject_*_selection` 值不得改变。\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("- 日线目标金额、目标比例和目标权重在 `next_bar_open` 下冻结 T 日收盘目标,T+1 按实际 open、滑点、卖后买延迟滑点、手续费和证券数量步长重算股数;禁止用 T+1 prev_close、T 日估算股数或 T+1 开盘后权益替代。金额预算始终严格,不能生成 `execution.strict_value_budget(false)`。\n");
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
out.push_str("## 语句块\n");
for item in &manual.statement_blocks {
out.push_str(&format!("- `{}`: {}\n", item.title, item.detail));
@@ -504,13 +566,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(PERFORMANCE_ACCEPTANCE_CONTRACT_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 只能传一个数值表达式,例如 ((signal_close < signal_ma20) ? 0.35 : 1.0)execution.matching_type 只能取 next_tick_last、next_tick_best_own、next_tick_best_counterparty、counterparty_offer、vwap、current_bar_close、next_bar_open、open_auctionexecution.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.rebalance_cash_mode、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;日线 execution.rebalance_cash_mode 只允许 same_point_net、sell_then_buy、pre_open_cash,分钟线固定使用 sell_then_buy;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_typenext_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图并在收盘冻结目标金额或目标权益,T+1 按实际 open、滑点、手续费和证券数量步长重算股数,不能用 T+1 prev_close 或 T 日估算股数直接成交;涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单;金额预算始终严格,禁止 execution.strict_value_budget(false)execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 请求滑点率,例如 0.002),并且请求里指定固定滑点时必须使用请求值\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((signal_close < signal_ma20) ? 0.35 : 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.002)\n}\n\n");
prompt.push_str("用户目标:\n");
prompt.push_str(&format!("- {}\n", request.user_goal));
if !request.constraints.is_empty() {
@@ -536,8 +604,11 @@ pub fn build_optimization_prompt(
prompt.push_str("你是 OmniQuant 平台策略脚本优化器。必须输出完整、可运行的平台策略脚本,不要输出解释文本。\n");
prompt.push_str("输出格式硬约束:回复第一行必须是 strategy(\"...\")、let、fn、const 或 //;回复中不得包含 Markdown、解释、思考过程、手册复述、JSON 包装或自然语言总结。\n");
prompt.push_str("长度硬约束:策略代码目标 80 行以内,只保留必要 let/fn/strategy 块;不要复制下面的手册片段、历史策略全文或字段清单。\n");
prompt.push_str("只修改与优化目标相关的少量参数或过滤条件,保留原策略的市场、基准、信号指数和核心风控;不引入手册未列出的字段或外部平台 API 名称。\n");
prompt.push_str("优化可以调整调仓周期、持仓数、市值带、filter.stock_expr、ordering.rank_expr、allocation.buy_scale、止盈止损;如上一轮无交易或质量分过低,必须先放宽过滤条件并优先使用已入库指标因子、rolling_mean/ma/vma/rolling_stddev/pct_change 等支持函数\n");
prompt.push_str("优化不限制在原策略已有参数或少量扰动。只要 OmniQuant/FIDC 已支持,可以自由增加、修改、删除策略代码、参数、候选池、过滤函数、排序、仓位、止盈止损、调仓周期、指标因子和辅助函数;不引入手册未列出的字段或外部平台 API 名称。\n");
prompt.push_str("持仓数量属于策略合同,不是优化自由参数。原策略或用户目标明确 stocknum、selection.limit、目标持仓N只或不少于N只时,优化后必须保留该目标槽位或满足最低槽位,不能为了收益或交易次数擅自改小\n");
prompt.push_str(PERFORMANCE_ACCEPTANCE_CONTRACT_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");
@@ -559,3 +630,81 @@ 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_uses_explicit_performance_acceptance_contract() {
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("只能来自用户目标、请求约束或不可变 candidate/promotion contract"));
assert!(prompt.contains("不得注入 120% 或其他默认数值"));
assert!(!prompt.contains("总收益严格 > 120%"));
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("execution.rebalance_cash_mode"));
assert!(prompt.contains("same_point_net、sell_then_buy、pre_open_cash"));
assert!(prompt.contains("分钟线固定使用 sell_then_buy"));
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 manual_separates_explicit_business_selection_from_framework_risk_policy() {
let markdown = render_manual_markdown(&built_in_strategy_manual());
assert!(markdown.contains("源策略明确写出的业务选股排除属于策略本身"));
assert!(markdown.contains("不能反向修改冻结的 reject_*_selection 开关"));
assert!(markdown.contains("冻结的 `reject_*_selection` 值不得改变"));
assert!(markdown.contains("time_in_force=\"day|ioc|fok|gtc\""));
assert!(markdown.contains("FOK 必须全量可成交否则零成交"));
assert!(markdown.contains("GTC 仅支持限价单并跨交易日保留"));
assert!(markdown.contains("paper/live 当前只支持 DAY/IOC/FOK"));
assert!(markdown.contains("paper/live 必须明确拒绝并禁止降级为 DAY"));
}
#[test]
fn optimization_prompt_uses_explicit_performance_acceptance_contract() {
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("只能来自用户目标、请求约束或不可变 candidate/promotion contract"));
assert!(prompt.contains("不得注入 120% 或其他默认数值"));
assert!(!prompt.contains("总收益严格 > 120%"));
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
assert!(prompt.contains("不要回退 ficlaw-data"));
assert!(prompt.contains("ClickHouse"));
}
}
+348 -7
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_snapshot_rows_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 {
@@ -78,6 +122,9 @@ pub struct DynamicMarketCapBandSelector {
pub cap_span: f64,
pub xs: f64,
pub top_n: usize,
pub padding_ratio: f64,
pub min_padding: f64,
pub max_padding: f64,
}
impl DynamicMarketCapBandSelector {
@@ -87,6 +134,9 @@ impl DynamicMarketCapBandSelector {
cap_span: f64,
xs: f64,
top_n: usize,
padding_ratio: f64,
min_padding: f64,
max_padding: f64,
) -> Self {
Self {
base_index_level,
@@ -94,11 +144,14 @@ impl DynamicMarketCapBandSelector {
cap_span,
xs,
top_n,
padding_ratio,
min_padding,
max_padding,
}
}
pub fn demo(top_n: usize) -> Self {
Self::new(2000.0, 7.0, 10.0, 4.0 / 500.0, top_n)
Self::new(2000.0, 7.0, 10.0, 4.0 / 500.0, top_n, 0.5, 8.0, 20.0)
}
pub fn regime(&self, benchmark_level: f64) -> BandRegime {
@@ -114,7 +167,18 @@ impl DynamicMarketCapBandSelector {
pub fn band_for_level(&self, benchmark_level: f64) -> (f64, f64) {
let start = ((benchmark_level - self.base_index_level) * self.xs) + self.base_cap_floor;
let low = start.round();
(low, low + self.cap_span)
let high = low + self.cap_span;
// Apply padding to expand the range
let span = high - low;
let padding = (span * self.padding_ratio)
.max(self.min_padding)
.min(self.max_padding);
let lower_bound = (low - padding).max(0.0);
let upper_bound = high + padding;
(lower_bound, upper_bound)
}
}
@@ -146,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.factor_total = ctx.data.factor_snapshot_rows_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());
@@ -201,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);
}
}
+48 -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,29 @@ fn china_cost_model_applies_minimum_commission_and_stamp_tax() {
assert!((sell.stamp_tax - 100.0).abs() < 1e-9);
}
#[test]
fn configured_cost_model_matches_declared_run_options() {
let model =
ChinaAShareCostModel::from_trading_constraints(fidc_core::TradingConstraintConfig {
commission_rate: 0.0003,
minimum_commission: 5.0,
stamp_tax_rate_before_change: 0.0005,
stamp_tax_rate_after_change: 0.0005,
..fidc_core::TradingConstraintConfig::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 +200,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 +209,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 +231,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 +268,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()),
+194 -10
View File
@@ -49,14 +49,30 @@ fn portfolio_settles_cash_receivable_on_payable_date() {
amount: 500.0,
reason: "cash_dividend 0.5".to_string(),
});
portfolio.add_cash_receivable(CashReceivable {
symbol: "000002.SZ".to_string(),
ex_date: d(2025, 1, 2),
payable_date: d(2025, 1, 5),
amount: 250.0,
reason: "cash_dividend 0.25".to_string(),
});
let settled_early = portfolio.settle_cash_receivables(d(2025, 1, 4));
assert!(settled_early.is_empty());
let due_early = portfolio.take_due_cash_receivables(d(2025, 1, 4));
assert!(due_early.is_empty());
assert!((portfolio.cash() - 1_000_000.0).abs() < 1e-9);
let settled = portfolio.settle_cash_receivables(d(2025, 1, 5));
assert_eq!(settled.len(), 1);
assert!((portfolio.cash() - 1_000_500.0).abs() < 1e-9);
let due = portfolio.take_due_cash_receivables(d(2025, 1, 5));
assert_eq!(due.len(), 2);
let mut cash_chain = Vec::new();
for receivable in &due {
let cash_before = portfolio.cash();
portfolio.settle_cash_receivable(receivable).unwrap();
cash_chain.push((cash_before, portfolio.cash()));
}
assert_eq!(
cash_chain,
vec![(1_000_000.0, 1_000_500.0), (1_000_500.0, 1_000_750.0)]
);
assert!(portfolio.cash_receivables().is_empty());
}
@@ -88,10 +104,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 +205,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 +228,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 +251,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 +298,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![
@@ -295,12 +384,14 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
first_date: buy_date,
},
BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaAShareCostModel::default()
.with_commission_rate(0.0008)
.with_minimum_commission(0.0),
ChinaEquityRuleHooks::default(),
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 +418,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"
);
}
+101 -24
View File
@@ -34,6 +34,7 @@ impl Strategy for BuyThenHoldStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
});
}
Ok(StrategyDecision::default())
@@ -41,9 +42,10 @@ impl Strategy for BuyThenHoldStrategy {
}
#[test]
fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() {
fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
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(),
@@ -244,24 +306,33 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
);
let result = engine.run().expect("backtest succeeds");
assert_eq!(result.fills.len(), 2);
assert_eq!(result.fills.len(), 1);
assert!(
result
.fills
.iter()
.any(|fill| fill.reason.contains("delisted_cash_settlement")
&& fill.symbol == "000001.SZ")
);
assert!(
result
.holdings_summary
.iter()
.all(|holding| holding.symbol != "000001.SZ")
.all(|fill| !fill.reason.contains("delisted_cash_settlement"))
);
let unresolved = result
.holdings_summary
.iter()
.find(|holding| holding.symbol == "000001.SZ")
.expect("unresolved delisted holding remains auditable");
assert_eq!(unresolved.quantity, 900);
assert_eq!(unresolved.last_price, 0.0);
assert_eq!(unresolved.market_value, 0.0);
assert!(result.equity_curve.iter().any(|point| {
point
.notes
.contains("unresolved_delisted_position symbol=000001.SZ")
&& point.notes.contains("settlement_action=missing")
&& point.notes.contains("valuation_policy=zero")
&& point.notes.contains("no_order=true")
}));
}
#[test]
fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
let date1 = d(2025, 1, 2);
let date2 = d(2025, 1, 3);
let data = DataSet::from_components_with_actions(
@@ -273,7 +344,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 +371,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 +394,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 +417,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 +464,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![
@@ -492,7 +569,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
.iter()
.find(|holding| holding.symbol == "000002.SZ")
.expect("successor holding exists");
assert_eq!(successor_holding.quantity, 500);
assert_eq!(successor_holding.quantity, 450);
assert!(
result
.holdings_summary
@@ -503,6 +580,6 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
event
.note
.contains("successor_conversion 000001.SZ->000002.SZ")
&& event.note.contains("cash=1000.00")
&& event.note.contains("cash=900.00")
}));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+131
View File
@@ -208,3 +208,134 @@ fn futures_expiration_settlement_closes_all_contract_directions() {
);
assert!((account.total_cash() - 1_003_000.0).abs() < 1e-6);
}
#[test]
fn futures_full_close_preserves_closed_position_daily_metrics() {
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
let mut account = FuturesAccountState::new(100_000.0);
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 1.0);
account.begin_trading_day();
let realized = account
.close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 2.0)
.expect("close overnight position");
assert!(account.positions().is_empty());
assert!((realized - 98.0).abs() < 1e-12);
assert!((account.position_pnl() - 100.0).abs() < 1e-12);
assert!(account.trading_pnl().abs() < 1e-12);
assert!((account.transaction_cost() - 2.0).abs() < 1e-12);
assert!((account.daily_pnl() - 98.0).abs() < 1e-12);
assert!((account.total_cash() - 100_097.0).abs() < 1e-12);
account.begin_trading_day();
assert!(account.daily_pnl().abs() < 1e-12);
assert!(account.transaction_cost().abs() < 1e-12);
}
#[test]
fn futures_intraday_roundtrip_preserves_closed_trading_pnl() {
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
let mut account = FuturesAccountState::new(100_000.0);
account.begin_trading_day();
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 1.0);
account
.close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 2.0)
.expect("close intraday position");
assert!(account.positions().is_empty());
assert!((account.trading_pnl() - 100.0).abs() < 1e-12);
assert!(account.position_pnl().abs() < 1e-12);
assert!((account.transaction_cost() - 3.0).abs() < 1e-12);
assert!((account.daily_pnl() - 97.0).abs() < 1e-12);
assert!((account.total_cash() - 100_097.0).abs() < 1e-12);
}
#[test]
fn futures_partial_close_offsets_later_mark_with_trading_pnl() {
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
let mut account = FuturesAccountState::new(100_000.0);
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 2, 100.0, 0.0);
account.begin_trading_day();
account
.close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 0.0)
.expect("partially close overnight position");
account.mark_price("IF2506.CCFX", FuturesDirection::Long, 120.0);
assert!((account.position_pnl() - 400.0).abs() < 1e-12);
assert!((account.trading_pnl() + 100.0).abs() < 1e-12);
assert!((account.daily_pnl() - 300.0).abs() < 1e-12);
assert!((account.total_value() - 100_300.0).abs() < 1e-12);
}
#[test]
fn futures_settlement_keeps_same_day_pnl_visible_until_next_day() {
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
let mut account = FuturesAccountState::new(100_000.0);
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 0.0);
account.begin_trading_day();
account.mark_price("IF2506.CCFX", FuturesDirection::Long, 110.0);
let settled = account.settle(&BTreeMap::from([("IF2506.CCFX".to_string(), 110.0)]));
assert!((settled - 100.0).abs() < 1e-12);
assert!((account.daily_pnl() - 100.0).abs() < 1e-12);
assert!((account.total_cash() - 100_100.0).abs() < 1e-12);
assert!((account.total_value() - 100_100.0).abs() < 1e-12);
account.begin_trading_day();
assert!(account.daily_pnl().abs() < 1e-12);
}
#[test]
fn futures_cash_and_closed_cost_accumulate_micro_yuan_exactly() {
let spec = FuturesContractSpec::new(1.0, 0.0, 0.0);
let mut account = FuturesAccountState::new(1_000_000.0);
account.begin_trading_day();
for _ in 0..10_000 {
account.open(
"IF2506.CCFX",
FuturesDirection::Long,
spec,
1,
100.0,
0.000001,
);
account
.close("IF2506.CCFX", FuturesDirection::Long, 1, 100.0, 0.000001)
.expect("close micro-cost position");
}
assert!((account.total_cash() - 999_999.98).abs() < 1e-12);
assert!((account.transaction_cost() - 0.02).abs() < 1e-12);
assert!((account.daily_pnl() + 0.02).abs() < 1e-12);
}
#[test]
fn futures_margin_gate_and_fill_cash_use_exact_micro_yuan() {
let date = d(2025, 1, 2);
let spec = FuturesContractSpec::new(1.0, 1.0, 1.0);
let intent = FuturesOrderIntent::open(
"IF2506.CCFX",
FuturesDirection::Long,
spec,
1,
100.0,
0.000001,
"micro margin boundary",
);
let mut insufficient = FuturesAccountState::new(100.0);
let rejected = insufficient.execute_order(date, Some(1), intent.clone());
assert_eq!(rejected.order_events[0].status, OrderStatus::Rejected);
assert!((insufficient.total_cash() - 100.0).abs() < 1e-12);
let mut exact = FuturesAccountState::new(100.000001);
let filled = exact.execute_order(date, Some(2), intent);
assert_eq!(filled.order_events[0].status, OrderStatus::Filled);
assert_eq!(filled.fill_events.len(), 1);
assert!((filled.fill_events[0].gross_amount - 100.0).abs() < 1e-12);
assert!((filled.fill_events[0].commission - 0.000001).abs() < 1e-12);
assert!((filled.fill_events[0].net_cash_flow + 0.000001).abs() < 1e-12);
assert!(exact.cash().abs() < 1e-12);
}
@@ -0,0 +1,222 @@
use std::hint::black_box;
use std::time::Instant;
use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use fidc_core::{
BenchmarkSnapshot, DailyMarketSnapshot, DataSet, Instrument, IntradayExecutionQuote,
};
const SYMBOL: &str = "000001.SZ";
fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
let start = NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid start date");
let dates = (0..day_count)
.map(|offset| start + Duration::days(offset as i64))
.collect::<Vec<_>>();
let markets = dates
.iter()
.map(|date| DailyMarketSnapshot {
date: *date,
symbol: SYMBOL.to_string(),
timestamp: None,
day_open: 10.0,
open: 10.0,
high: 10.5,
low: 9.5,
close: 10.0,
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 10.0,
volume: 1_000_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,
})
.collect::<Vec<_>>();
let benchmarks = dates
.iter()
.map(|date| BenchmarkSnapshot {
date: *date,
benchmark: "000852.SH".to_string(),
open: 1_000.0,
close: 1_000.0,
prev_close: 1_000.0,
volume: 10_000_000,
})
.collect::<Vec<_>>();
let mut quotes = Vec::with_capacity(day_count * bars_per_day);
for date in &dates {
let session_start = date
.and_hms_opt(9, 30, 0)
.expect("valid session start");
for offset in 0..bars_per_day {
let timestamp = session_start + Duration::minutes(offset as i64);
quotes.push(IntradayExecutionQuote {
date: *date,
symbol: SYMBOL.to_string(),
timestamp,
last_price: 10.0 + offset as f64 / 10_000.0,
bid1: 9.99,
ask1: 10.01,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 1_000,
amount_delta: 10_000.0,
trading_phase: Some("continuous".to_string()),
});
}
}
let data = DataSet::from_components_with_actions_and_quotes(
vec![Instrument {
symbol: SYMBOL.to_string(),
name: "平安银行".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(start - Duration::days(1_000)),
delisted_at: None,
status: "active".to_string(),
}],
markets,
Vec::new(),
Vec::new(),
benchmarks,
Vec::new(),
quotes,
)
.expect("build intraday history dataset");
(data, dates)
}
fn timestamp(date: NaiveDate, time: &str) -> NaiveDateTime {
let time = NaiveTime::parse_from_str(time, "%H:%M:%S").expect("valid time");
date.and_time(time)
}
#[test]
fn intraday_history_is_bounded_by_visibility_and_preserves_order() {
let (data, dates) = dataset(3, 4);
let rows = data.history_intraday_quotes_at(
dates[1],
Some(timestamp(dates[1], "09:32:00")),
SYMBOL,
3,
false,
);
assert_eq!(
rows.iter().map(|row| row.timestamp).collect::<Vec<_>>(),
vec![
timestamp(dates[0], "09:33:00"),
timestamp(dates[1], "09:30:00"),
timestamp(dates[1], "09:31:00"),
]
);
let including_now = data.history_intraday_quotes_at(
dates[1],
Some(timestamp(dates[1], "09:32:00")),
SYMBOL,
3,
true,
);
assert_eq!(
including_now
.iter()
.map(|row| row.timestamp)
.collect::<Vec<_>>(),
vec![
timestamp(dates[1], "09:30:00"),
timestamp(dates[1], "09:31:00"),
timestamp(dates[1], "09:32:00"),
]
);
}
#[test]
#[ignore = "manual release-mode intraday history benchmark"]
fn benchmark_bounded_intraday_history() {
let (data, dates) = dataset(250, 240);
let active_datetime = timestamp(*dates.last().expect("last date"), "13:29:00");
for _ in 0..5 {
black_box(data.history_intraday_quotes_at(
active_datetime.date(),
Some(active_datetime),
SYMBOL,
30,
true,
));
}
let started = Instant::now();
let mut checksum = 0_i64;
for _ in 0..200 {
let rows = data.history_intraday_quotes_at(
active_datetime.date(),
Some(active_datetime),
SYMBOL,
30,
true,
);
checksum += rows
.last()
.expect("history row")
.timestamp
.and_utc()
.timestamp();
black_box(&rows);
}
let elapsed = started.elapsed();
eprintln!(
"intraday_history_benchmark iterations=200 rows_per_dataset=60000 elapsed_seconds={:.6} checksum={checksum}",
elapsed.as_secs_f64(),
);
}
#[test]
#[ignore = "manual release-mode quote-stream benchmark"]
fn benchmark_borrowed_execution_quote_stream() {
let (data, dates) = dataset(250, 240);
let date = *dates.last().expect("last date");
let symbols = std::collections::BTreeSet::from([SYMBOL.to_string()]);
for _ in 0..5 {
black_box(data.execution_quotes_on_date_for_symbols(date, Some(&symbols)));
black_box(
data.execution_quotes_iter_on_date_for_symbols(date, Some(&symbols))
.count(),
);
}
let materialized_started = Instant::now();
let mut materialized_checksum = 0_i64;
for _ in 0..5_000 {
let rows = data.execution_quotes_on_date_for_symbols(date, Some(&symbols));
materialized_checksum += rows
.iter()
.map(|quote| quote.timestamp.and_utc().timestamp())
.sum::<i64>();
black_box(rows);
}
let materialized_seconds = materialized_started.elapsed().as_secs_f64();
let streamed_started = Instant::now();
let mut streamed_checksum = 0_i64;
for _ in 0..5_000 {
let count = data
.execution_quotes_iter_on_date_for_symbols(date, Some(&symbols))
.map(|quote| quote.timestamp.and_utc().timestamp())
.sum::<i64>();
streamed_checksum += count;
black_box(count);
}
let streamed_seconds = streamed_started.elapsed().as_secs_f64();
eprintln!(
"quote_stream_benchmark iterations=5000 rows_per_day=240 materialized_seconds={materialized_seconds:.6} streamed_seconds={streamed_seconds:.6} materialized_checksum={materialized_checksum} streamed_checksum={streamed_checksum}"
);
}
@@ -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
+20 -7
View File
@@ -10,7 +10,7 @@ The roadmap focuses on making the engine complete enough for editable platform
strategies, long-range A-share backtests, futures strategies, intraday order
simulation, AI-generated strategy code, and service-level result downloads.
## Re-Audit Findings (2026-04-24)
## Re-Audit Findings (2026-08-31)
The latest re-audit focused on the engine's execution model, account model,
order lifecycle, data helper surface, analyzer output, extension hooks, and
@@ -20,7 +20,9 @@ futures path. Confirmed aligned areas:
pending limit orders, cancellation, open order views, and final order lookup.
- Stock account and portfolio runtime fields including cash, frozen cash, total
value, transaction cost, trading/position PnL, management fees, financing
liability, deposit/withdraw, and position aliases.
liability, deposit/withdraw, and position aliases. External deposits and
withdrawals are unitized separately from trading PnL; delayed withdrawals
are preflighted atomically at settlement.
- Scheduler, dynamic universe, subscription guard, `history_bars`,
`current_snapshot`, `get_price`, instruments, trading-date APIs, suspension
and ST helpers.
@@ -37,6 +39,8 @@ futures path. Confirmed aligned areas:
| P0 | Futures intraday matching | Closed for daily/open/close, tick-price futures fills, and true multi-level order-book sweeping when optional `order_book_depth` data exists. L1-only data still uses the existing L1 matcher and is not inflated into fake depth. | Extend depth fields only if production vendors expose more levels or exchange-specific fields. |
| P0 | Futures open-order lifecycle | Closed for futures pending limit orders, cross-day rematching, cancellation by id/symbol/all, and merged open-order runtime views. | Add more order status transitions only if UI requires extra intermediate event names. |
| P0 | Combined multi-account NAV | Closed. `DailyEquityPoint`, progress events, and metrics use aggregate stock + futures initial cash and total equity. | None. |
| P0 | Fixed-point execution money | Closed. Stock execution freezes fee rates once and uses signed micro-yuan `i128` for gross amount, commission, stamp tax, transfer fee, strict budget checks, cash, liabilities, management fees, external flows, account units, position lot cost and realized PnL. The standalone futures account uses the same fixed-point money boundary for cash, margin, transaction cost and daily PnL. Market indicators and return statistics remain `f64` outside the execution boundary. | None. |
| P0 | Bounded minute-data processing | Closed for the engine data model. Intraday history uses a sorted date index and scans backward only until the requested bar count is satisfied. Daily minute processing consumes a borrowed timestamp-ordered k-way merge and does not clone/materialize the full selected quote day before event dispatch. | Keep Source Lake and service clients batch-streamed; do not reintroduce whole-window row materialization. |
| P1 | Futures trading parameter data source | Closed for engine-side trading-parameter ingestion/resolution via `futures_trading_parameters.csv` or component data. | Add more exchange metadata columns only when source data exposes them. |
| P1 | Futures transaction cost decider | Closed. `FuturesTransactionCostModel` calculates by-money/by-volume open/close/close-today costs from trading parameters. | None. |
| P1 | Futures settlement price mode | Closed. Engine supports configurable settlement price mode and resolves settlement/prev-settlement from factor fields with close/prev_close fallback. | Add dedicated settlement columns if the storage layer later separates them from factors. |
@@ -52,22 +56,31 @@ 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] Stock broker fee, budget and cash-ledger arithmetic uses a micro-yuan
fixed-point execution primitive; one-micro over-budget orders fail.
- [x] Stock position lots, realized/unrealized PnL, dividends and external cash
flows preserve fixed-point value conservation.
- [x] Futures cash, margin, transaction cost and daily realized/position PnL use
the fixed-point ledger.
- [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] Bounded intraday history lookup and borrowed minute quote streaming avoid
full-history scans and full-day quote clones while preserving timestamp
order and visibility boundaries.
- [x] Suspension, ST, date-range price, active instrument, and instrument
history helpers.
- [x] Open-order status, unfilled quantity, final order lookup, average fill
@@ -0,0 +1,75 @@
# Fixed-Point and Minute-Stream Acceptance
Acceptance date: 2026-08-31
Engine commit: `cd116bc3ae77cac0989eb80185bb04d7440b8834`
## Scope
This acceptance separates execution precision from minute-data throughput. It
does not use a strategy-specific shortcut and does not change strategy,
matching, risk, slippage, commission, tax, or future-data semantics.
## Fixed-Point Boundary
- Execution money is signed micro-yuan `i128`.
- Stock gross amount, commission, stamp tax, transfer fee, cash, liabilities,
external cash flow, account units, position lot cost and PnL are fixed-point.
- Futures cash, margin, transaction cost, realized PnL and position PnL are
fixed-point.
- Market indicators and return statistics remain `f64`; conversion occurs only
at the execution-money boundary.
- One-micro-yuan budget overruns fail instead of being hidden by float epsilon.
Verification command:
```bash
cargo test -p fidc-core fixed -- --nocapture
```
Result: 8 passed, 0 failed.
## Minute Data Boundary
- `history_intraday_quotes_at` uses a sorted execution-date index and scans
backward until the requested bar count is satisfied.
- The active timestamp and `include_now` flag control visibility; later bars are
never visible.
- Daily minute execution consumes a borrowed k-way merge ordered by timestamp
and symbol. It does not clone the complete selected quote day before engine
dispatch.
- Execution quotes are released by trading date after the day finishes.
Release benchmark command:
```bash
cargo test -p fidc-core --test intraday_history_performance --release -- --ignored --nocapture
```
Observed on the local acceptance host:
| Case | Workload | Result |
| --- | --- | --- |
| Bounded history | 200 queries over 60,000 rows | 0.000227 seconds, checksum 351450348000 |
| Full-day materialization | 5,000 iterations x 240 rows | 0.049361 seconds |
| Borrowed quote stream | 5,000 iterations x 240 rows | 0.012232 seconds |
The materialized and streamed timestamp checksums were both
`2108693484000000`. The observed component speedup was about 4.04x. These
numbers are component evidence only and are not an end-to-end SLA.
## Regression
```bash
cargo test -p fidc-core --all-targets
```
Result: 528 passed, 0 failed, 1 ignored manual benchmark. This includes
execution-day risk, next-open open-price limits, minute timestamp visibility,
slippage, minimum commission, stamp tax, volume limits, corporate actions,
external cash-flow NAV treatment and futures account precision.
## Deployment Gate
This documentation-only correction does not require a service restart. Any
future Source Lake or engine deployment still requires the official managed
entrypoint and must fail closed while FIDC-managed factor work is active.
@@ -0,0 +1,51 @@
# Market Day View Component Benchmark
Date: 2026-08-31
## Scope
The platform-expression selection loop already iterates one factor slice for a
single trading date. The previous implementation still resolved the same date
in the market and candidate `BTreeMap` for every symbol. `DailySnapshotView`
borrows the existing immutable market/factor/candidate slices and dense row
position arrays once per date, then performs only `symbol_id -> row` lookups.
The view does not copy snapshots, cache strategy results, share account state,
or change missing-row behavior. The optimization is independent of strategy
text, thresholds, rolling windows, execution mode and portfolio size.
## Release Component A/B
Contract:
- 6,000 symbols;
- 200 complete lookup rounds;
- each lookup reads market close and candidate `allow_buy`;
- baseline and view checksums must be exactly equal;
- `cargo test --release`, system allocator, local macOS host.
| Round | Baseline seconds | Day view seconds |
| ---: | ---: | ---: |
| 1 | 0.009000 | 0.002939 |
| 2 | 0.004370 | 0.001555 |
| 3 | 0.004274 | 0.001578 |
Median component time changed from `0.004370s` to `0.001578s`, an observed
reduction of about `63.9%` (`2.77x`). This is a component result only and is
not a complete backtest SLA.
## Correctness Gates
- sparse market-only symbols remain absent from factor/candidate views;
- dense and binary-search fallback lookup semantics remain unchanged;
- full engine suite: 529 passed, 3 ignored manual benchmarks;
- next-open execution-day risk, minute matching, fees, slippage, volume limits,
corporate actions, delisting and futures tests all passed.
## Deployment Status
Not deployed. The 177 FIDC-managed Boris factor task is still active, so no
Source Lake, backtest service or engine restart is allowed. After the task
ends naturally, acceptance must use the same frozen bundle and compare daily
selection, orders, fills, holdings, NAV, risk facts and canonical digest for
multiple daily/minute and fixed/dynamic-universe strategies.
+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'
+64
View File
@@ -0,0 +1,64 @@
#!/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_selection_uses_complete_static_risk_policy_before_universe_output
run_core_test platform_next_open_defers_complete_static_selection_risk_before_universe_output
run_core_test platform_next_open_selection_ignores_execution_day_limit_state
run_core_test platform_next_open_selection_applies_explicit_signal_day_risk_filter
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'