Compare commits

..

323 Commits

Author SHA1 Message Date
boris 4edc70c4c6 fix(backtest): advance resting limit orders on subsequent quote events 2026-09-12 05:05:39 +08:00
boris 9804851133 fix(stock-pool): reconcile completed entry quantities before repricing 2026-09-12 04:29:40 +08:00
boris 6ffa0346aa feat(stock-pool): unify target execution, durable intent state and ETF rules 2026-09-12 03:55:00 +08:00
boris 29eafc79e2 fix(backtest): 停牌证券保留预算且不参与退出权重分配 2026-09-11 22:50:27 +08:00
boris b6859a3360 统一 FIDC FiPanda 品牌标识与资源 2026-09-11 20:17:51 +08:00
boris a00dea58b2 test: assert causal TWAP entitlement and unfilled remainder 2026-09-11 17:11:53 +08:00
boris 2445dc925a fix: size TWAP slices from frozen clocks instead of future quotes 2026-09-11 17:04:25 +08:00
boris 1e8d38f2ee Merge remote-tracking branch 'origin/main' 2026-09-11 16:14:55 +08:00
boris d2071e4311 docs: compare execution frameworks and specify remaining causal capacity work 2026-09-11 16:11:57 +08:00
boris 0fba16342f fix: share explicit execution price selection and reject stale-price fallback 2026-09-11 15:44:38 +08:00
boris bd389de57f test: exercise full odd-lot liquidation against the remaining quote quota 2026-09-11 15:28:19 +08:00
boris bfbf898027 test: qualify execution clock regression time types 2026-09-11 15:19:18 +08:00
boris 2b6d031a55 fix: separate as-of quote time from execution clock and retain volume consumption 2026-09-11 15:12:42 +08:00
boris 6160a74d2a 合并主分支最新因子元数据合同 2026-09-11 15:01:29 +08:00
boris 4acecda79d feat: introduce causal capacity primitives and exact participation quotas 2026-09-11 15:00:18 +08:00
boris d847cb5c28 修正回撤指标的初始净值基线并补充回归测试 2026-09-11 15:00:02 +08:00
boris fa0b316a8b refactor: separate expression metadata and tests from numerical identity 2026-09-11 13:26:39 +08:00
boris 21786187c9 feat: publish typed native indicator parameter domains 2026-09-11 12:52:28 +08:00
boris e0bed38184 Merge remote-tracking branch 'refs/remotes/highmem177/main' 2026-09-11 12:14:35 +08:00
boris c0b78846d6 fix: preserve frozen stock pool candidate order through execution 2026-09-11 12:14:33 +08:00
boris 9d72567b99 test: use the projection module state in calibration failure regression 2026-09-11 12:09:33 +08:00
boris e47228beff fix: reject invalid historical slippage bounds before execution 2026-09-11 11:57:59 +08:00
boris 1fc8a3a9e6 test: enforce causal historical slippage and reject missing calibration 2026-09-11 11:42:21 +08:00
boris 98199c02a2 refactor: isolate historical slippage calibration and propagate pricing errors 2026-09-11 11:35:58 +08:00
boris 6eaa06c1d6 docs: record per-leg price risk release and unchanged minute replay 2026-09-11 10:44:15 +08:00
boris 7e0877b586 fix: validate price risk on every execution leg before and after slippage 2026-09-11 10:21:46 +08:00
boris 36833b7a6a docs: distinguish merged trading tests from published runtime 2026-09-11 10:12:38 +08:00
boris 4c0157b66c docs: record pre-existing realtime quota outage and merged-main test scope 2026-09-11 10:08:40 +08:00
boris f7d16fb664 Merge remote-tracking branch 'origin/main' 2026-09-11 10:03:34 +08:00
boris 97cdfa5972 docs: record execution-price release and open capacity audit blockers 2026-09-11 10:01:37 +08:00
boris f2e228e0a3 合并最新执行风控与策略保护内核 2026-09-11 09:52:51 +08:00
boris 33924b1fba 统一策略成交保护与锁定周期并修正日期条件覆盖 2026-09-11 09:52:04 +08:00
boris d3c36e9478 fix: retain precise missing execution quote diagnostics during risk checks 2026-09-11 09:14:58 +08:00
boris c32807db1d test: distinguish next-open one-yuan execution from later close classification 2026-09-11 09:11:28 +08:00
boris 20d723e2a1 test: inspect broker fill events in shared signal account audit 2026-09-11 09:08:57 +08:00
boris c2b939e818 fix: price one-yuan buy risk at execution and audit account-local signal exits 2026-09-11 09:02:52 +08:00
boris 6684f48f95 test: provide the required benchmark in clock-only dataset fixtures 2026-09-11 05:28:40 +08:00
boris adbfadcc07 test: use canonical local timestamps in signal consumption cases 2026-09-11 05:25:18 +08:00
boris cb6f57be6f fix: bind signal availability to the actual consumption clock 2026-09-11 05:23:33 +08:00
boris e75db2d7b0 test: declare daily market coverage for both minute subscriptions 2026-09-11 04:58:00 +08:00
boris e42dc6938b test: align minute fixtures and missing-data assertions with strict contracts 2026-09-11 04:56:25 +08:00
boris 4f4c1ab7e0 fix: canonicalize all execution limit aliases consistently 2026-09-11 04:48:56 +08:00
boris e549a23c66 统一登记信号引用的策略字段别名 2026-09-11 01:45:46 +08:00
boris 4b21fc4f3f 开放共享策略别名规范化入口供服务统一使用 2026-09-11 01:32:05 +08:00
boris e4bac1cf40 合并最新内核并保留严格数值别名校验 2026-09-11 01:04:27 +08:00
boris 1de96494b3 统一策略数值别名并保留精确冲突校验 2026-09-11 01:03:22 +08:00
boris d9bac529d6 feat: expose frozen signal generator identity to execution clients 2026-09-11 00:04:08 +08:00
boris 3e8d652af1 merge: keep verified dependency lock and canonical v2 signal kernel 2026-09-10 23:50:04 +08:00
boris 23043ee18b build: preserve generated signal client dependency resolution 2026-09-10 23:48:54 +08:00
boris c7c2e69b88 合并已登记信号读取与稳定跨语言数值摘要 2026-09-10 23:30:41 +08:00
boris e6746a7a0e 统一第二版信号簿字段并提供原生校验入口 2026-09-10 23:27:43 +08:00
boris 123467d7ae build: lock shared signal client dependencies 2026-09-10 23:15:59 +08:00
boris b3a3bdbdfd test: provide native signal book identity probe 2026-09-10 23:00:50 +08:00
boris 3f9cff1ee5 merge: retain audited signal identity and cross-language semantic hashing 2026-09-10 22:50:54 +08:00
boris db88abb9e0 feat: share verified signal books across backtest and trading clients 2026-09-10 22:40:59 +08:00
boris 75e5e32281 绑定信号簿内容摘要并按决策日校验日线输入 2026-09-10 22:22:03 +08:00
boris 7d05f8f7c7 为执行报价预取测试补齐明确证券生命周期 2026-09-10 22:18:50 +08:00
boris d01f32ca5b Merge branch 'main' of /tmp/fidc-signal-transfer.2sgDaY/signal.bundle 2026-09-10 22:12:56 +08:00
boris 3dd7b2bd50 fix: distinguish signal consumption and strategy decision schedule dates 2026-09-10 21:20:43 +08:00
boris c8f6ed102c feat: introduce typed account-independent signal execution contract 2026-09-10 21:19:04 +08:00
boris 4664f1a2d3 排除仅作参考的基准并记录周期买入风控拒绝 2026-09-10 20:21:44 +08:00
boris 40481e8825 按日期区分生命周期缺价并保留上市前现金区间 2026-09-10 19:09:21 +08:00
boris 2473cc04bb 为原生因子研究冻结计算内核身份 2026-09-10 16:32:21 +08:00
boris 3fa1004ec5 校正封板成功率为封板家数除以触板家数 2026-09-10 14:46:32 +08:00
boris c4632bacf1 独立保留市场统计并标明行业缺失日期 2026-09-10 14:08:05 +08:00
boris 7dcaae594a 接入完整市场与申万行业事件研究内核 2026-09-10 13:35:46 +08:00
boris 999bf5bd01 研究计算隔离证券数据异常并保持交易严格校验 2026-09-10 09:04:08 +08:00
boris b281045df5 修复事件跨服务序列化生成空窗口 2026-09-09 23:54:22 +08:00
boris 35acb1c7e7 修复分钟事件轮动仅执行最后时点的问题 2026-09-09 23:52:35 +08:00
boris bbbd9cf3e0 统一日线事件上下文并接入完成分钟事件回测 2026-09-09 23:29:54 +08:00
boris 5dc5ef9df5 补全指数与完整范围排名的只读事件计算 2026-09-09 19:57:03 +08:00
boris fe8f6c1c26 增加均量突破回踩与真实涨停整理条件 2026-09-09 13:55:45 +08:00
boris 30e8227099 接通真实昨收成交额并前置校验表达式字段 2026-09-09 11:33:58 +08:00
boris 588da4958f 合并组合亏损控制器与因子内核依赖 2026-09-09 10:40:26 +08:00
boris bc4754288e 合并主线缺值语义与因子键优化记录 2026-09-09 10:31:26 +08:00
boris 6b0cdbcecc 增加共享因子事件表达式与完整截面算子 2026-09-09 10:31:25 +08:00
boris 5ff05e0d3d merge latest engine main before portfolio risk integration 2026-09-09 10:23:02 +08:00
boris bab4d47b46 revert: remove ineffective borrowed factor key optimization 2026-09-09 09:48:41 +08:00
boris fdd26667c9 test: enable rebalance actions in portfolio risk lifecycle fixture 2026-09-09 09:40:18 +08:00
boris 29522b69fe test: trace completed risk observations in engine regression 2026-09-09 09:40:18 +08:00
boris a54489fe92 test: expose lifecycle execution diagnostics on failure 2026-09-09 09:40:18 +08:00
boris 20c14437c6 test: bind accounting lifecycle fixture to its real sample symbols 2026-09-09 09:40:18 +08:00
boris dce5454ec8 test: import explicit engine accounting fixture types 2026-09-09 09:40:18 +08:00
boris 72b64451ac test: verify portfolio loss against finalized engine accounting 2026-09-09 09:40:18 +08:00
boris d17d67d6ca build: lock existing SHA256 dependency without unrelated upgrades 2026-09-09 09:40:18 +08:00
boris 63c577bd76 feat: connect portfolio loss to finalized accounting and daily risk clock 2026-09-09 09:40:18 +08:00
boris 8c190597ae feat: add serialized causal portfolio loss controller for runtime integration 2026-09-09 09:40:18 +08:00
boris ad063264cf fix: borrow factor identifiers during lookup 2026-09-09 09:36:10 +08:00
boris 0108c91bae perf: preserve borrowed factor keys in stock state 2026-09-09 09:35:25 +08:00
boris ee2865829d Merge remote-tracking branch 'origin/main' 2026-09-09 09:31:46 +08:00
boris e66460c4e9 对齐次日交易信号基线与基准收益起点 2026-09-09 07:43:29 +08:00
boris 2811886a52 区分次日回放调度标签与实际日线可见时点 2026-09-09 07:21:13 +08:00
boris 3b5a7cd318 修复恒定小数价格累加误差产生虚假均线信号 2026-09-09 07:03:55 +08:00
boris 3fe2da3ee0 统一日线形态计算与次日分阶段信号 2026-09-09 06:41:15 +08:00
boris ee77028907 docs: record missing-value semantics and real replay gates 2026-09-09 06:07:01 +08:00
boris 1bcaa0b3d8 合并主线数值校验与买入阶段约束 2026-09-09 05:49:45 +08:00
boris 1703a7aa5e 保留已有行情和策略代码格式整理 2026-09-09 05:49:44 +08:00
boris e3f1028667 fix: reject missing numeric sizing and execution parameters 2026-09-09 04:34:09 +08:00
boris ea58ab2166 fix: enforce numeric guards in Rhai operator dispatch 2026-09-09 04:02:11 +08:00
boris 3b1aa2ebcb test: identify the dynamic missing-value comparison path 2026-09-09 03:58:49 +08:00
boris fda2e70456 fix: preserve unknown numeric conditions through boolean expressions 2026-09-09 03:56:44 +08:00
boris 3cea91467d test: provide dated amount facts in next-open selection fixture 2026-09-09 02:58:33 +08:00
boris dd6b37be16 fix: preserve missing numeric factors and reject nonfinite truthiness 2026-09-09 02:51:26 +08:00
boris 078839b0f3 fix: preserve authoritative STAR market classification in risk checks 2026-09-08 22:42:33 +08:00
boris 326438aac9 fix: evaluate buy quote conditions at the active schedule clock 2026-09-08 01:45:39 +08:00
boris 30da6eaead feat: evaluate trading buy filters into decision-scoped constraints 2026-09-08 01:27:42 +08:00
boris 3784246e6b docs: specify buy-constrained amendment behavior and validation scope 2026-09-08 01:09:14 +08:00
boris fa6f189cdd test: verify amendment rejection without order-state events 2026-09-08 01:04:51 +08:00
boris daa9d8d341 fix: apply decision buy denials to exposure-increasing amendments 2026-09-08 01:00:52 +08:00
boris c85afb59ab docs: define factor decision phase integration and acceptance gates 2026-09-08 00:53:48 +08:00
boris 7d293f092e test: cover next-open side flips and trim redundant default fields 2026-09-08 00:43:54 +08:00
boris da27204a71 test: assert broker fill events for scoped buy denials 2026-09-08 00:41:11 +08:00
boris bac721e593 feat: add decision-scoped buy denials to broker submission 2026-09-08 00:40:10 +08:00
boris bc666c6433 docs: record paper and live deployment of native factor timing fix 2026-09-07 22:30:14 +08:00
boris 4a71992752 docs: record intraday native factor and five-year next-open replays 2026-09-07 22:14:59 +08:00
boris 1b78186c4e docs: record scoped native daily factor visibility tests 2026-09-07 21:56:12 +08:00
boris cb97aa193d test: verify native daily values respect intraday availability 2026-09-07 21:54:26 +08:00
boris a02ac6e853 fix: gate bound daily indicator fields by completed session 2026-09-07 21:51:51 +08:00
boris f3cc790659 fix(data): reject normalized reserved adjustment keys 2026-09-07 19:38:28 +08:00
boris 5ffbf76565 docs(data): record typed adjustment snapshot acceptance 2026-09-07 18:13:31 +08:00
boris 04b45adf98 perf(data): type adjustment factor snapshots 2026-09-07 17:53:36 +08:00
boris 9714c051c5 精确预分配按股行情分组 2026-09-07 13:57:12 +08:00
boris fbf5a6d61a 按符号索引一次构建行情序列 2026-09-07 13:46:59 +08:00
boris 94632f42d6 拆分共享日线序列与日内字段 2026-09-07 13:34:38 +08:00
boris 3f6017d98b 保持日内覆盖与按股序列一致 2026-09-07 13:25:07 +08:00
boris d5af51c02b 支持复用只读日线基础面板 2026-09-07 12:54:49 +08:00
boris 1ec0bb65f7 记录行情计划冷路径验收 2026-09-07 12:34:50 +08:00
boris c934a948c6 流式构建总市值行情计划 2026-09-07 12:17:26 +08:00
boris 950bcaa7aa 记录通用行情覆盖层否决结论 2026-09-07 11:34:25 +08:00
boris 728ed7998d Revert "以运行覆盖层隔离补充行情"
This reverts commit 757b5665ca.
2026-09-07 11:28:33 +08:00
boris 757b5665ca 以运行覆盖层隔离补充行情 2026-09-07 11:22:20 +08:00
boris c280bbc1c3 记录分层行情索引否决结论 2026-09-07 11:15:38 +08:00
boris 68c186f649 Revert "分层共享执行行情索引"
This reverts commit f2de8b281a.
2026-09-07 11:09:02 +08:00
boris f2de8b281a 分层共享执行行情索引 2026-09-07 11:03:08 +08:00
boris 19f725dbaa 记录市值区间切片性能验收 2026-09-07 10:59:32 +08:00
boris df7a1ee382 按共享市值索引裁剪候选区间 2026-09-07 10:50:20 +08:00
boris 4fe1f0d77c 记录共享行情释放性能验收 2026-09-07 10:38:29 +08:00
boris f45b3a71fa 避免共享行情释放触发整图复制 2026-09-07 10:27:39 +08:00
boris 1aa7c28616 复用预计算行情证券范围 2026-09-07 09:41:10 +08:00
boris e542e52bdb 跳过无加载器的行情规划 2026-09-07 09:22:15 +08:00
boris 0afbdc2210 由执行风控处理无行情买单 2026-09-07 09:04:07 +08:00
boris fc6dea12eb 增加类型化静态股票池合同 2026-09-07 07:27:44 +08:00
boris c3f88ebf12 对齐持仓未实现盈亏口径 2026-09-07 06:26:34 +08:00
boris 1d1c93f8e2 补充持仓移动平均成交价 2026-09-07 05:53:30 +08:00
boris 929b105173 支持按持仓成交均价止盈止损 2026-09-07 05:36:23 +08:00
boris e00777ebc2 按完整目标集合约束持仓槽位 2026-09-07 05:18:57 +08:00
boris e4f6cdd025 对齐目标组合提交前过滤 2026-09-07 05:10:00 +08:00
boris 2a705a361a 说明目标组合退出语义 2026-09-07 05:04:34 +08:00
boris b4ec5da808 区分股票池退出后重新入场 2026-09-07 04:51:27 +08:00
boris 4d8761cc3c 阻止未完成退出反向补买 2026-09-07 04:44:12 +08:00
boris fdd0dd0525 修复目标生命周期退出后复活 2026-09-07 04:39:16 +08:00
boris df29c8d3ec 区分退出后权重重分配语义 2026-09-07 04:33:40 +08:00
boris ef24402747 保留策略目标资金比例 2026-09-07 04:19:15 +08:00
boris 78e872b609 修复盘中止盈止损行情缓存 2026-09-07 04:17:37 +08:00
boris 27e523a1dc 仅在成功清仓后释放目标权重 2026-09-07 04:01:25 +08:00
boris f9d9f06d3f 统一每日目标组合执行路径 2026-09-07 04:01:25 +08:00
boris d45f39f1bf 统一止盈退出后的目标权重重分配 2026-09-07 04:01:25 +08:00
boris 5fa3d3bf54 Revert "统一止盈退出后的目标权重重分配"
This reverts commit 3f14d9de54.
2026-09-07 03:29:34 +08:00
boris 7bc832f3c6 Revert "统一每日目标组合执行路径"
This reverts commit 0542a44afe.
2026-09-07 03:29:34 +08:00
boris 46c647d676 Merge remote-tracking branch 'origin/main' 2026-09-07 03:29:34 +08:00
boris 24b961ba61 Reapply "统一止盈退出后的目标权重重分配"
This reverts commit d2cf021194.
2026-09-07 03:29:34 +08:00
boris d2cf021194 Revert "统一止盈退出后的目标权重重分配"
This reverts commit 3f14d9de54.
2026-09-07 03:29:08 +08:00
boris 0542a44afe 统一每日目标组合执行路径 2026-09-07 03:27:50 +08:00
boris 3f14d9de54 统一止盈退出后的目标权重重分配 2026-09-07 03:15:05 +08:00
boris b8e0d3bf4c 修复盘中收盘撮合盘口限制覆盖 2026-09-07 02:28:59 +08:00
boris f1a6a2695d 修复日线盘中调度按分钟行情撮合 2026-09-07 01:29:18 +08:00
boris 54ccfe7e0a fix: retain scheduled decision diagnostics 2026-09-07 01:18:58 +08:00
boris ce041e0d16 修复定时轮动提前在日线阶段执行 2026-09-06 23:18:58 +08:00
boris 81f6b7d1a5 修复显式股票权重被截断 2026-09-06 22:39:01 +08:00
boris dd376e4b32 Revert "perf: build calendar series boundaries in one pass"
This reverts commit f927ef8c0f.
2026-09-06 22:24:18 +08:00
boris f927ef8c0f perf: build calendar series boundaries in one pass 2026-09-06 22:18:54 +08:00
boris 0d831c4ca6 Revert "perf: build price series by symbol id"
This reverts commit 96d0cc6fc4.
2026-09-06 21:08:15 +08:00
boris 96d0cc6fc4 perf: build price series by symbol id 2026-09-06 20:55:18 +08:00
boris c7d213bf35 Revert "perf: skip unused explicit-action stock state"
This reverts commit b5934085da.
2026-09-06 20:02:33 +08:00
boris b5934085da perf: skip unused explicit-action stock state 2026-09-06 19:58:01 +08:00
boris 3657d83833 test: cover signal-date target persistence 2026-09-06 19:35:27 +08:00
boris 94a1422a35 fix: honor signal dates for explicit actions 2026-09-06 19:32:57 +08:00
boris 5bc9753766 fix: align risk-free dates with engine schedule 2026-09-06 18:45:11 +08:00
boris a838732e5a fix: reject missing daily execution prices per order 2026-09-06 18:26:55 +08:00
boris 4b88defdab docs: record date numeric VM acceptance 2026-09-06 18:07:34 +08:00
boris bc228980af perf: compile date comparisons into numeric VM 2026-09-06 17:58:57 +08:00
boris e0b4a3f56c docs: record expression scope performance evidence 2026-09-06 17:45:42 +08:00
boris 840473362d perf: reuse expression scope identifiers and dates 2026-09-06 17:37:12 +08:00
boris 630a7a16c9 fix: value unavailable execution days without fills 2026-09-06 14:36:35 +08:00
boris f5de3a2c29 Skip market buys below one lot 2026-09-06 13:35:52 +08:00
boris c5767ca272 revert: reject neutral symbol-id series storage 2026-09-06 06:29:51 +08:00
boris a7f96c030f docs: record symbol-id series acceptance 2026-09-06 06:26:45 +08:00
boris 5a7c49a454 perf: build market series by symbol id 2026-09-06 06:18:21 +08:00
boris cda249e9b4 docs: map open-source engine designs to FIDC 2026-09-06 05:39:14 +08:00
boris 52c7831bf6 revert: reject marginal numeric VM slot reuse 2026-09-06 05:37:20 +08:00
boris 5122c73aa8 perf: reuse numeric VM slots by generation 2026-09-06 05:31:23 +08:00
boris 5f08978827 Revert "perf: bind numeric VM identifiers at compile time"
This reverts commit 2135a5bd03.
2026-09-06 05:24:11 +08:00
boris 2135a5bd03 perf: bind numeric VM identifiers at compile time 2026-09-06 05:16:48 +08:00
boris 199f988b2e feat: add dated candidate universe contracts 2026-09-06 03:57:33 +08:00
boris 0f1d49bf63 feat: execute factor position target rules 2026-09-06 02:45:39 +08:00
boris c7f5188354 docs: correct performance evidence medians 2026-09-05 13:54:36 +08:00
boris 6a1c60b2e2 docs: record signal rolling rejection 2026-09-05 13:35:00 +08:00
boris 2aa330786a revert: reject allocation-free signal rolling scan 2026-09-05 13:26:23 +08:00
boris b65b3ed8f1 perf: scan signal rolling aggregates without allocations 2026-09-05 13:19:04 +08:00
boris f9ec86436a docs: record selection benchmark decisions 2026-09-05 13:09:44 +08:00
boris 42999ffa2c revert: reject selection band precheck 2026-09-05 13:01:58 +08:00
boris e060af380e perf: reject selection bands before state construction 2026-09-05 12:55:11 +08:00
boris b55ac0bf81 revert: reject rolling boundary reuse 2026-09-05 12:46:42 +08:00
boris 6b5d57675e perf: reuse rolling window boundaries 2026-09-05 12:40:40 +08:00
boris c225d8484f perf: freeze rank expression presence 2026-09-05 06:26:49 +08:00
boris 15c8f1f403 docs: record symbol id rank benchmark 2026-09-05 06:25:27 +08:00
boris 52f9ee9d92 perf: use lexical symbol ids for rank ties 2026-09-05 06:14:49 +08:00
boris d7e11be01f perf: rank transient candidates by symbol id 2026-09-05 06:07:23 +08:00
boris 866fe32a8c test: record transient selection arena acceptance 2026-09-05 06:00:23 +08:00
boris d89dd24f0a perf: isolate ordered selection hot path 2026-09-05 05:47:11 +08:00
boris 6a304e2fc2 Revert "revert: benchmark generic transient selection"
This reverts commit b2da70897a.
2026-09-05 05:46:05 +08:00
boris b2da70897a revert: benchmark generic transient selection 2026-09-05 05:39:57 +08:00
boris 12ad2b163a perf: isolate generic selection ranking 2026-09-05 05:34:24 +08:00
boris 1e8e738eda perf: sort transient states by arena index 2026-09-05 05:26:24 +08:00
boris 7f7fce1fc3 perf: keep ranked candidate states transient 2026-09-05 05:19:33 +08:00
boris a2d9e910ff docs: record transient selection state benchmark 2026-09-05 05:08:02 +08:00
boris 29faf7932e perf: avoid caching transient selection states 2026-09-05 04:59:50 +08:00
boris 0af4cd7f68 docs: record symbol id selection benchmark 2026-09-05 04:54:45 +08:00
boris 0e3c2028d0 perf: stream selection candidates by symbol id 2026-09-05 04:44:53 +08:00
boris c2e9c11a9a docs: record rejected vm scratch generations 2026-09-05 04:35:37 +08:00
boris 95503d418c docs: record rejected adjusted series compaction 2026-09-05 04:29:53 +08:00
boris 0c2681e699 Revert "perf: compact adjusted close series values"
This reverts commit ab87e18ba5.
2026-09-05 04:24:43 +08:00
boris ab87e18ba5 perf: compact adjusted close series values 2026-09-05 04:19:58 +08:00
boris 33808d9ba9 docs: record current minute-mode regression 2026-09-05 04:15:58 +08:00
boris 1f8a0fdc44 docs: record rejected lazy expression scope 2026-09-05 04:06:46 +08:00
boris 229ca8332d Revert "perf: build expression scope values lazily"
This reverts commit 1b449287fd.
2026-09-05 04:01:40 +08:00
boris 1b449287fd perf: build expression scope values lazily 2026-09-05 03:56:51 +08:00
boris 05085b961b docs: record noalloc instrument rule validation 2026-09-05 03:50:23 +08:00
boris cfb19b5783 perf: avoid board normalization allocations 2026-09-05 03:42:21 +08:00
boris 6f1e40754d docs: record specialized snapshot source validation 2026-09-05 03:40:35 +08:00
boris d79678d850 perf: specialize stock snapshot sources 2026-09-05 03:32:38 +08:00
boris 224adf70d4 docs: record rejected symbol board cache 2026-09-05 03:28:06 +08:00
boris f2105399c5 Revert "perf: cache symbol board classification"
This reverts commit eb8b14602a.
2026-09-05 03:19:31 +08:00
boris eb8b14602a perf: cache symbol board classification 2026-09-05 03:09:15 +08:00
boris df52f90d46 docs: record rejected rolling lookback freeze 2026-09-05 03:05:29 +08:00
boris 05e67c73df Revert "perf: freeze standard rolling lookbacks"
This reverts commit 32e4030442.
2026-09-05 03:01:18 +08:00
boris 32e4030442 perf: freeze standard rolling lookbacks 2026-09-05 02:54:53 +08:00
boris be2f624e3c docs: record typed current rolling validation 2026-09-05 02:52:24 +08:00
boris 75ab0c06c6 perf: type static current rolling helpers 2026-09-05 02:42:47 +08:00
boris 1410aa588a docs: record rejected current rolling batch 2026-09-05 02:38:32 +08:00
boris 43b15b2098 Revert "perf: batch current rolling means per stock"
This reverts commit 004a46cb41.
2026-09-05 02:33:34 +08:00
boris 004a46cb41 perf: batch current rolling means per stock 2026-09-05 02:29:06 +08:00
boris 76b1d6c38b docs: record calendar-major boundary validation 2026-09-05 02:24:47 +08:00
boris abe4fed452 perf: transpose rolling boundary index by date 2026-09-05 02:17:24 +08:00
boris a35137ed1c docs: record stock snapshot field projection 2026-09-05 02:13:00 +08:00
boris 7f17fa1fb4 perf: project stock snapshot fields by strategy 2026-09-05 02:02:01 +08:00
boris 5f7321da58 docs: record interned stock symbol validation 2026-09-05 01:52:49 +08:00
boris e5646ef80c perf: intern stock-state symbols 2026-09-05 01:41:00 +08:00
boris 71b3517003 docs: record compact stock-state cache key validation 2026-09-05 01:20:20 +08:00
boris 6f81e1940a perf: compact daily stock-state cache keys 2026-09-05 01:11:34 +08:00
boris 98a74f7bb4 docs: record rejected duplicate rolling optimization 2026-09-05 01:06:30 +08:00
boris 5b2a03d416 revert: remove unproven duplicate rolling reuse 2026-09-05 01:00:33 +08:00
boris e469b0ddf4 test: use valid adjusted series in rolling benchmark 2026-09-05 00:54:10 +08:00
boris 1f02e78b24 perf: reuse duplicate rolling lookbacks 2026-09-05 00:48:12 +08:00
boris a235f46b6d docs: record shared market-cap order benchmark 2026-09-05 00:46:00 +08:00
boris 05953f857a perf: share immutable market-cap order index 2026-09-05 00:37:39 +08:00
boris 6538742dfa docs: record rejected daily snapshot optimization 2026-09-05 00:30:42 +08:00
boris 1f10a6bb3d Revert "perf: reuse daily snapshot views in stock selection"
This reverts commit 1df0081479.
2026-09-05 00:24:05 +08:00
boris 1df0081479 perf: reuse daily snapshot views in stock selection 2026-09-05 00:14:57 +08:00
boris db155e7ad0 docs: record stock-state calendar index benchmark 2026-09-05 00:03:53 +08:00
boris 6d458dbbc6 perf: reuse stock-state calendar index 2026-09-04 23:49:33 +08:00
boris f7708331d3 docs: add second strategy rolling regression 2026-09-04 23:22:05 +08:00
boris 873cdb9d31 docs: record current rolling boundary benchmark 2026-09-04 23:19:15 +08:00
boris 52b07be19b perf: reuse current rolling series boundary 2026-09-04 23:06:52 +08:00
boris 914820cc07 perf: skip unused standard rolling lookup 2026-09-04 22:55:03 +08:00
boris 47d1520d72 docs: record instrument symbol-id benchmark 2026-09-04 22:53:11 +08:00
boris 8ed22897ea perf: index instruments by symbol id 2026-09-04 22:46:14 +08:00
boris 3963648f1a perf: retain faster stable universe sort 2026-09-04 22:31:30 +08:00
boris 2c711871f5 perf: avoid stable universe sort allocation 2026-09-04 22:20:26 +08:00
boris 836f56af41 style: format merged metrics exports 2026-09-04 22:19:49 +08:00
boris e27375a204 Merge remote-tracking branch 'origin/main' 2026-09-04 22:18:41 +08:00
boris 92322349d4 style: normalize fidc core formatting 2026-09-04 22:18:35 +08:00
boris 1a79dc483c 补齐权威下行风险指标 2026-09-03 15:02:44 +08:00
boris a3a077fa87 统一每日PIT无风险收益指标 2026-09-03 14:04:51 +08:00
boris b15b93eec1 修复目标权重映射预校验 2026-09-02 19:05:28 +08:00
boris 1215a04b7d 支持日期化仓位调整回放 2026-09-02 18:15:07 +08:00
boris d014bb2fbd fix: fail closed on missing factor values 2026-08-31 14:30:33 +08:00
boris dff791b51f perf: index factor lookups by symbol 2026-08-31 14:27:30 +08:00
boris ce46e42ff7 Merge remote-tracking branch 'origin/main' 2026-08-31 09:44:52 +08:00
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 f839b16dbd Merge remote-tracking branch 'refs/remotes/bundle/main' 2026-08-29 15:14:11 +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 ce5564408d 按策略引用投影额外因子字段 2026-08-29 08:16:58 +08:00
boris 41854fe5bd 按策略引用投影额外因子字段 2026-08-29 08:16:41 +08:00
boris d5265619f3 按有序市值流提前停止选股 2026-08-29 07:50:09 +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
124 changed files with 32328 additions and 1565 deletions
Generated
+1387 -11
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,6 +1,7 @@
[workspace]
members = [
"crates/fidc-core",
"crates/fidc-signal-client",
]
resolver = "2"
@@ -11,6 +12,8 @@ version = "0.1.0"
authors = ["OpenAI Codex"]
[workspace.dependencies]
rust_decimal = { version = "=1.39.0", features = ["serde-with-str"] }
sha2 = "=0.10.9"
ahash = "=0.8.12"
chrono = { version = "=0.4.44", features = ["serde"] }
indexmap = { version = "=2.11.4", features = ["serde"] }
@@ -18,5 +21,5 @@ reqwest = { version = "=0.12.24", default-features = false, features = ["json",
rayon = "=1.12.0"
rhai = { version = "=1.23.6", features = ["sync"] }
serde = { version = "=1.0.228", features = ["derive"] }
serde_json = "=1.0.145"
serde_json = { version = "=1.0.145", features = ["float_roundtrip"] }
thiserror = "=2.0.18"
+19 -1
View File
@@ -1,7 +1,19 @@
<p><img src="assets/brand/fiPandaWithTitle.png" alt="FiPanda" width="144" height="144"></p>
# fidc-backtest-engine
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
## Runtime position exposure schedule
`strategy_spec.runtimeExpressions.risk.positionExposureSchedule` accepts dated
`effectiveDate` plus `targetExposureBps` points. The platform expression strategy
uses the latest point whose date is not later than the current execution date and
otherwise keeps the strategy's normal `exposureExpr`. This contract is intended for
audited runtime controls replayed by paper/live shadow reconciliation; it is not a
market-data signal and does not change selection, pricing, fees, or execution-day
risk checks.
## 当前能力
- 日频和分钟执行价策略生命周期与确定性回放。
@@ -75,7 +87,7 @@
- `selection.market_cap_band(...)` 动态市值带。
- `filter.stock_expr(...)` 任意指标、因子和组合选股。
- `ordering.rank_by(...)``ordering.rank_expr(...)` 排序。
- `allocation.buy_scale(...)` 动态买入资金比例
- `allocation.buy_scale(...)` 相对等权槽位的个股资金倍率;显式权重可以大于 `1.0`,组合总仓位仍由 `risk.index_exposure(...)` 和严格资金预算控制
- `risk.stop_loss(...)``risk.take_profit(...)` 多条件止盈止损。
- `order.*``cancel.*``update_universe(...)``subscribe(...)` 显式交易动作。
@@ -83,8 +95,14 @@
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` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
`targetPortfolioDaily=true` 时,每只股票的默认目标金额固定为 `target_budget / selection_limit * buy_scale`,候选不足、缺行情或风控拒绝产生的剩余资金保留为现金,不得自动归一到满仓。止盈、止损或最大持有期触发后,标的从活动目标顺序移除;卖出未完成时继续占用仓位槽且不得反向补买,成功释放的槽位只能由同一决策时点已排序且通过策略条件的后续候选补充。只有显式设置 `redistributeTargetWeightsAfterExit=true` 才在可用目标间重新分配权重,只有显式设置 `reenterExitedTargets=true` 才保留退出标的供后续重新入场;两个开关互相独立,默认都为 `false`
## 内置微盘策略
`OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线:
+11
View File
@@ -0,0 +1,11 @@
# FiPanda 品牌资源
FIDC 系列共用用户提供的两张原始透明图片,不重绘、不裁切、不染色。
- `fiPandaWithTitle.png`:带标题版,登录页、项目首页、独立报告使用。
- `fipanda.png`:无标题版,顶部导航、小尺寸标识和系统图标使用。
- `favicon-32.png`:由无标题版等比生成,供浏览器标签页使用。
导航图标建议 28–42px;带标题版建议 96–160px。始终等比完整显示,保留透明背景,不为 Logo 增加大块容器或改变业务页面主题。项目名称、权限、交易合同与内部 FIDC 标识不因品牌图片变更而重命名。
原图保存在 `omniquant/assets/brand/source/``manifest.json` 记录原图和显示资源的 SHA-256。需要更新时,在完整工作区运行 `node omniquant/scripts/sync-fipanda-branding.mjs`,统一生成八个仓库及可下载策略技能包的资源,禁止分别维护不同版本。
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+38
View File
@@ -0,0 +1,38 @@
{
"schema": "fidc.fipanda-brand/v1",
"brand": "FiPanda",
"sourceRepository": "omniquant",
"sources": [
{
"file": "fipanda.png",
"sha256": "ff7dc0677b7d9c8dc6d008d3391f27fd583e3de35560dab021c1e2f8f2c464e5"
},
{
"file": "fiPandaWithTitle.png",
"sha256": "01bfb6acb36830d9b1ce6f7dc8c718977d43af44572ee6a26991d6c6aa2847d6"
}
],
"assets": [
{
"file": "fipanda.png",
"source": "fipanda.png",
"pixels": 192,
"usage": "navigation, compact icons, apple-touch-icon",
"sha256": "450aad3506ec3c67624b7705f4d8f851a9102fe950e7671a06fb1203dc1d9cf7"
},
{
"file": "fiPandaWithTitle.png",
"source": "fiPandaWithTitle.png",
"pixels": 384,
"usage": "login, project README, standalone reports",
"sha256": "d33846b3687ea69d43b4a1f17ceeb02662bd25f26fabc4c86c25b3475f20dede"
},
{
"file": "favicon-32.png",
"source": "fipanda.png",
"pixels": 32,
"usage": "browser tab icon",
"sha256": "ec3d7783457db284d46ae6e27833685203838637a7d68946ff85402725f8e420"
}
]
}
+3
View File
@@ -6,6 +6,7 @@ license.workspace = true
authors.workspace = true
[dependencies]
rust_decimal.workspace = true
ahash.workspace = true
chrono.workspace = true
indexmap.workspace = true
@@ -13,4 +14,6 @@ rayon.workspace = true
rhai.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
ta-lib = { git = "https://github.com/TA-Lib/ta-lib.git", rev = "dd5a90259a3f9e04e2da9f38bf0719a841b40108" }
@@ -0,0 +1,42 @@
use fidc_core::factor_events::{self, Expr, Frame};
use serde::Deserialize;
use serde_json::{Value, json};
use std::io::{self, Read};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
expressions: std::collections::BTreeMap<String, Expr>,
frame: Frame,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut input = String::new();
io::stdin().read_to_string(&mut input)?;
let output = if input.trim().is_empty() {
factor_events::catalog()
} else if serde_json::from_str::<Value>(&input)?.get("rank_history").is_some() {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Rank { dates:Vec<chrono::NaiveDate>, universe:Vec<String>, values:std::collections::BTreeMap<String,Vec<Option<f64>>> }
let value:Value=serde_json::from_str(&input)?;
let request:Rank=serde_json::from_value(value["rank_history"].clone())?;
json!({"result":fidc_core::factor_cross_section::rank_history(&request.dates,&request.universe,&request.values)?})
} else {
let request: Request = serde_json::from_str(&input)?;
let results = request
.expressions
.iter()
.map(|(id, expr)| {
let result = match factor_events::evaluate(expr, &request.frame) {
Ok(v) => json!({"result":v}),
Err(e) => json!({"error":e}),
};
(id.clone(), result)
})
.collect::<std::collections::BTreeMap<String, Value>>();
json!({"contract":factor_events::CONTRACT,"results":results,"read_only":true})
};
println!("{}", serde_json::to_string(&output)?);
Ok(())
}
@@ -0,0 +1,9 @@
use std::io::{self, Read};
fn main() {
let mut input=String::new();io::stdin().read_to_string(&mut input).unwrap();
let request=serde_json::from_str(&input).unwrap();
match fidc_core::market_event_context::aggregate(request) {
Ok(value)=>println!("{}",serde_json::to_string(&value).unwrap()),
Err(error)=>{eprintln!("{error}");std::process::exit(1);}
}
}
@@ -0,0 +1,26 @@
use std::io::Read;
fn main() {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).unwrap();
let value: serde_json::Value = serde_json::from_str(&input).unwrap();
let spec: fidc_core::daily_patterns::PatternSpec =
serde_json::from_value(value["spec"].clone()).unwrap();
let bars: Vec<fidc_core::session_events::MinuteBar> =
serde_json::from_value(value["bars"].clone()).unwrap();
let result = fidc_core::session_events::evaluate(
&spec.validate().unwrap(),
value["symbol"].as_str().unwrap(),
&bars,
serde_json::from_value(value["decision_at"].clone()).unwrap(),
);
match result {
Ok(row) => println!(
"{}",
serde_json::json!({"contract":fidc_core::session_events::CONTRACT,"row":row,"read_only":true,"source_evidence_verified":false})
),
Err(error) => {
eprintln!("{error}");
std::process::exit(1);
}
}
}
@@ -0,0 +1,15 @@
use std::io::{Read, Write};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut raw=Vec::new();
std::io::stdin().take(64*1024*1024+1).read_to_end(&mut raw)?;
if raw.len()>64*1024*1024 {return Err("signal_book_transport_limit".into());}
let book:fidc_core::signal_contract::SignalBook=serde_json::from_slice(&raw)?;
let version=book.content_sha256()?;
let validated=book.validate()?;
let result=serde_json::json!({"schema":fidc_core::signal_contract::SIGNAL_BOOK_SCHEMA,
"versionSha256":version,"symbols":validated.symbols(),
"onlineAllowed":validated.require_observed().is_ok()});
std::io::stdout().write_all(serde_json::to_string(&result)?.as_bytes())?;
Ok(())
}
File diff suppressed because it is too large Load Diff
+480
View File
@@ -0,0 +1,480 @@
//! Executes one frozen pool intent against real broker-simulator state.
use super::*;
use crate::holding_policy::HoldingLifecycleEvidence;
use crate::stock_pool_execution as pool;
use rust_decimal::{Decimal, prelude::ToPrimitive};
fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
if !value.is_finite() {
return Err(BacktestError::Execution(format!(
"stock_pool_nonfinite_{label}"
)));
}
value
.to_string()
.parse()
.map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}")))
}
fn pool_positions(
portfolio: &PortfolioState,
date: NaiveDate,
) -> Result<Vec<pool::Position>, BacktestError> {
portfolio
.positions()
.values()
.filter(|p| p.quantity > 0)
.map(|p| {
Ok(pool::Position {
symbol: p.symbol.clone(),
quantity: Decimal::from(p.quantity),
closable_quantity: Decimal::from(p.sellable_qty(date)),
average_cost: decimal(p.average_cost, "position_cost")?,
})
})
.collect()
}
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
fn pool_quote_inputs(
&self,
date: NaiveDate,
data: &DataSet,
symbols: &BTreeSet<String>,
execution_clock: Option<NaiveDateTime>,
) -> Result<Vec<pool::MarketSnapshot>, BacktestError> {
symbols
.iter()
.map(|symbol| {
let snapshot = data.market(date, symbol).ok_or_else(|| {
BacktestError::Execution(format!(
"stock_pool_execution_snapshot_missing:{symbol}:{date}"
))
})?;
let instrument = data.instruments().get(symbol).ok_or_else(|| {
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
})?;
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if self
.matching_type_uses_intraday_quotes()
{
let time = self
.runtime_intraday_start_time
.get()
.or(self.intraday_execution_start_time)
.ok_or_else(|| {
BacktestError::Execution(
"stock_pool_intraday_execution_clock_required".into(),
)
})?;
let clock = execution_clock
.unwrap_or(date.and_time(time))
.max(date.and_time(time));
let quote = data
.execution_quotes_on(date, symbol)
.iter()
.rev()
.find(|quote| quote.timestamp <= clock)
.ok_or_else(|| {
BacktestError::Execution(format!(
"stock_pool_execution_quote_missing:{symbol}:{clock}"
))
})?;
if !quote.last_price.is_finite() || quote.last_price <= 0.0 {
return Err(BacktestError::Execution(format!(
"stock_pool_execution_quote_invalid:{symbol}:{clock}"
)));
}
let raw_buy = self
.select_quote_reference_price(
snapshot,
quote,
OrderSide::Buy,
self.matching_type,
)
.ok_or_else(|| {
BacktestError::Execution(format!(
"stock_pool_buy_reference_missing:{symbol}:{clock}"
))
})?;
let raw_sell = self
.select_quote_reference_price(
snapshot,
quote,
OrderSide::Sell,
self.matching_type,
)
.ok_or_else(|| {
BacktestError::Execution(format!(
"stock_pool_sell_reference_missing:{symbol}:{clock}"
))
})?;
let calibration = self.slippage_calibration(data, snapshot)?;
let buy = self.quote_execution_price(
snapshot,
OrderSide::Buy,
raw_buy,
None,
calibration.as_ref(),
)?;
let sell = self.quote_execution_price(
snapshot,
OrderSide::Sell,
raw_sell,
None,
calibration.as_ref(),
)?;
(
quote.last_price,
snapshot.prev_close,
Some(quote.volume_delta as f64),
Some(quote.amount_delta),
Some(quote.bid1),
Some(quote.ask1),
buy,
sell,
)
} else {
let price = snapshot.price(self.effective_execution_price_field(date));
if !price.is_finite() || price <= 0.0 {
return Err(BacktestError::Execution(format!(
"stock_pool_execution_price_missing:{symbol}:{date}"
)));
}
// A daily open does not reveal the session's volume/turnover.
let completed = self.effective_execution_price_field(date) == PriceField::Close;
(
price,
snapshot.prev_close,
completed.then_some(snapshot.volume as f64),
None,
Some(price),
Some(price),
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?,
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, None)?,
)
};
Ok(pool::MarketSnapshot {
symbol: symbol.clone(),
last_price: decimal(price, "price")?,
prev_close: Some(decimal(prev, "prev_close")?),
volume: volume.map(|v| decimal(v, "volume")).transpose()?,
turnover: amount.map(|v| decimal(v, "amount")).transpose()?,
bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?,
ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?,
is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")),
instrument_rules: Some(pool::StockPoolInstrumentRules {
price_tick: decimal(snapshot.price_tick, "price_tick")?,
quantity_step: instrument.order_step_size().into(),
minimum_buy_quantity: instrument.minimum_order_quantity().into(),
}),
buy_sizing_price: Some(decimal(buy_price, "buy_price")?),
sell_sizing_price: Some(decimal(sell_price, "sell_price")?),
})
})
.collect()
}
pub(super) fn process_stock_pool_contract(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
contract: &pool::FrozenStockPoolIntent,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut IntradayExecutionLedger,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
if contract.signal_date > date
|| contract.frozen_equity < Decimal::ZERO
|| contract.generation.is_empty()
|| contract.pool_id.trim().is_empty()
{
return Err(BacktestError::Execution(
"stock_pool_frozen_intent_invalid".into(),
));
}
if self.matching_type == MatchingType::NextBarOpen && contract.signal_date >= date {
return Err(BacktestError::Execution(
"stock_pool_next_open_requires_prior_signal".into(),
));
}
let mut selection = contract.selection.clone();
let mut members = contract.members.clone();
for symbol in &contract.selection.requested_symbols {
let instrument = data.instruments().get(symbol).ok_or_else(|| {
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
})?;
if portfolio.position(symbol).is_none()
&& let Some(reason) = instrument.dated_market_absence_reason(date)
{
selection.requested_symbols.retain(|v| v != symbol);
selection.normal_trading_symbols.retain(|v| v != symbol);
selection.risk_eligible_symbols.retain(|v| v != symbol);
selection.final_symbols.retain(|v| v != symbol);
members.retain(|v| &v.symbol != symbol);
report.diagnostics.push(format!(
"stock_pool_market_absence symbol={symbol} date={date} reason={reason}"
));
}
}
let mut scope = selection
.requested_symbols
.iter()
.cloned()
.collect::<BTreeSet<_>>();
scope.extend(portfolio.positions().keys().cloned());
let official_dates = data.calendar().iter().collect::<Vec<_>>();
let initial_positions = pool_positions(portfolio, date)?;
let state = portfolio
.stock_pool_execution_state(&contract.pool_id)
.observe(
contract.signal_date,
date,
&official_dates,
&members,
&initial_positions,
)
.map_err(BacktestError::Execution)?;
portfolio
.set_stock_pool_execution_state(&contract.pool_id, state)
.map_err(BacktestError::Execution)?;
if self.has_open_orders() {
report
.diagnostics
.push("stock_pool_waiting_for_active_orders no_new_intent=true".into());
return Ok(());
}
let mut constraints = contract.constraints.clone();
constraints.execution_date = Some(date);
constraints.frozen_positions.clear();
let mut quote_scope = scope.clone();
for symbol in &scope {
let paused = data.market(date, symbol).is_some_and(|row| row.paused)
|| data
.candidate(date, symbol)
.is_some_and(|row| row.is_paused);
if !paused {
continue;
}
quote_scope.remove(symbol);
if let Some(position) = portfolio
.position(symbol)
.filter(|position| position.quantity > 0)
{
constraints.frozen_positions.insert(
symbol.clone(),
pool::FrozenStockPoolPosition {
trade_date: date,
reason: "paused".into(),
valuation_price: decimal(position.last_price, "paused_holding_valuation")?,
},
);
} else {
selection
.normal_trading_symbols
.retain(|item| item != symbol);
selection
.risk_eligible_symbols
.retain(|item| item != symbol);
selection.final_symbols.retain(|item| item != symbol);
selection
.exclusion_reasons
.entry(symbol.clone())
.or_default()
.push("paused".into());
}
}
let before_positions = portfolio
.positions()
.keys()
.cloned()
.collect::<BTreeSet<_>>();
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
let quotes =
self.pool_quote_inputs(date, data, &quote_scope, *global_execution_cursor)?;
let positions = pool_positions(portfolio, date)?;
let execution_state = portfolio
.stock_pool_execution_state(&contract.pool_id)
.observe(
contract.signal_date,
date,
&official_dates,
&members,
&positions,
)
.map_err(BacktestError::Execution)?;
constraints.pending_entry_symbols = execution_state.pending_symbols();
constraints.prior_target_weights = execution_state.last_target_weights.clone();
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
let account = pool::AccountSnapshot {
total_equity: contract.frozen_equity,
cash: decimal(portfolio.cash(), "cash")?,
frozen_cash: Decimal::ZERO,
};
constraints.automatic_permissions.clear();
if contract.rule.automatic_trade_protection.enabled() {
for symbol in &scope {
let position = portfolio.position(symbol).filter(|p| p.quantity > 0);
let sold = self
.same_day_sold_symbols
.borrow()
.iter()
.rev()
.find(|(day, symbols)| **day <= date && symbols.contains(symbol))
.map(|(day, _)| *day);
let evidence = HoldingLifecycleEvidence {
has_position: position.is_some(),
opened_date: position.and_then(|p| p.opened_date()),
last_buy_date: position.and_then(|p| p.last_buy_date()),
last_sell_date: sold,
};
let permission = contract
.rule
.automatic_trade_protection
.evaluate(symbol, date, &evidence, data.calendar())
.map_err(BacktestError::Execution)?;
constraints
.automatic_permissions
.insert(symbol.clone(), permission);
}
}
if self
.risk_config
.static_rules
.forbid_same_day_rebuy_after_sell
{
constraints.same_day_sold_symbols.extend(
self.same_day_sold_symbols
.borrow()
.get(&date)
.into_iter()
.flatten()
.cloned(),
);
}
constraints.same_day_sold_symbols.extend(
before_positions
.iter()
.filter(|symbol| portfolio.position(symbol).is_none_or(|p| p.quantity == 0))
.cloned(),
);
let fee =
|symbol: &str, side: pool::OrderSide, gross: Decimal| -> Result<Decimal, String> {
let amount = gross
.to_f64()
.ok_or("stock_pool_cost_amount_out_of_range")?;
decimal(
self.cost_model
.calculate_for_instrument(
date,
if side == pool::OrderSide::Buy {
OrderSide::Buy
} else {
OrderSide::Sell
},
amount,
data.instruments().get(symbol),
)
.total(),
"fee",
)
.map_err(|e| e.to_string())
};
let plan = pool::build_stock_pool_target_plan_with_fee_model(
&selection,
&members,
&contract.rule,
&account,
&positions,
&quotes,
contract.invest_ratio_bps,
contract.reserve_cash,
&contract.out_of_pool_policy,
"full_rebalance",
&constraints,
&contract.generation,
Decimal::ZERO,
Decimal::ZERO,
Decimal::ZERO,
Some(&fee),
)
.map_err(BacktestError::Execution)?;
let updated = execution_state
.record_plan(contract.signal_date, &contract.generation, &plan)
.map_err(BacktestError::Execution)?;
portfolio
.set_stock_pool_execution_state(&contract.pool_id, updated)
.map_err(BacktestError::Execution)?;
report.diagnostics.push(format!("stock_pool_plan phase={side:?} generation={} requested_bps={} effective_bps={} budget={}",contract.generation,plan.requested_invest_ratio_bps,plan.effective_invest_ratio_bps,plan.budget));
let max_positions = constraints
.target_holding_count
.unwrap_or(selection.final_symbols.len());
for row in plan.rows {
if side == pool::OrderSide::Buy && row.side.is_none() {
report.diagnostics.push(format!(
"stock_pool_decision symbol={} status={} current={} target={} reason={}",
row.symbol,
row.status,
row.current_quantity,
row.target_quantity,
row.reason
));
}
if row.side != Some(side) {
continue;
}
if side == pool::OrderSide::Buy
&& portfolio
.position(&row.symbol)
.is_none_or(|p| p.quantity == 0)
&& Self::positive_position_count(portfolio) >= max_positions
{
report.diagnostics.push(format!(
"stock_pool_buy_deferred symbol={} reason=occupied_position_slots",
row.symbol
));
continue;
}
let target = row.target_quantity.to_i32().ok_or_else(|| {
BacktestError::Execution("stock_pool_target_quantity_out_of_range".into())
})?;
let reason = row.source_intent.as_deref().unwrap_or("stock_pool_target");
if let Some(price) = row.limit_price {
self.process_limit_target_shares(
date,
portfolio,
data,
&row.symbol,
target,
price.to_f64().ok_or_else(|| {
BacktestError::Execution("stock_pool_limit_price_out_of_range".into())
})?,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)?;
} else {
self.process_target_shares(
date,
portfolio,
data,
&row.symbol,
target,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)?;
}
}
}
Ok(())
}
}
+52
View File
@@ -5,6 +5,7 @@ use chrono::NaiveDate;
use crate::events::OrderSide;
use crate::fixed_point::{FixedChinaAShareCostModel, FixedMoney, FixedTradingCost};
use crate::risk_control::TradingConstraintConfig;
use crate::Instrument;
#[derive(Debug, Clone, Copy)]
pub struct TradingCost {
@@ -35,6 +36,17 @@ impl TradingCost {
pub trait CostModel {
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost;
fn calculate_for_instrument(&self, date: NaiveDate, side: OrderSide, gross_amount: f64, _instrument: Option<&Instrument>) -> TradingCost {
self.calculate(date, side, gross_amount)
}
fn calculate_with_order_state_for_instrument(
&self, date: NaiveDate, side: OrderSide, gross_amount: f64,
order_id: Option<u64>, commission_state: &mut BTreeMap<u64,f64>, _instrument: Option<&Instrument>,
) -> TradingCost {
self.calculate_with_order_state(date, side, gross_amount, order_id, commission_state)
}
fn calculate_with_order_state(
&self,
date: NaiveDate,
@@ -215,6 +227,27 @@ impl ChinaAShareCostModel {
}
impl CostModel for ChinaAShareCostModel {
fn calculate_for_instrument(&self, date: NaiveDate, side: OrderSide, gross_amount: f64, instrument: Option<&Instrument>) -> TradingCost {
let mut cost = self.calculate(date, side, gross_amount);
if instrument.is_some_and(Instrument::is_exchange_traded_fund) {
cost.stamp_tax = 0.0;
cost.transfer_fee = 0.0;
}
cost
}
fn calculate_with_order_state_for_instrument(
&self, date: NaiveDate, side: OrderSide, gross_amount: f64,
order_id: Option<u64>, commission_state: &mut BTreeMap<u64,f64>, instrument: Option<&Instrument>,
) -> TradingCost {
let mut cost = self.calculate_with_order_state(date, side, gross_amount, order_id, commission_state);
if instrument.is_some_and(Instrument::is_exchange_traded_fund) {
cost.stamp_tax = 0.0;
cost.transfer_fee = 0.0;
}
cost
}
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost {
if gross_amount <= 0.0 {
return TradingCost {
@@ -273,6 +306,25 @@ impl CostModel for ChinaAShareCostModel {
mod tests {
use super::*;
#[test]
fn fund_fees_use_admitted_instrument_type_and_share_the_order_commission_budget() {
let day=NaiveDate::from_ymd_opt(2026,9,11).unwrap();
let model=ChinaAShareCostModel::from_trading_constraints(TradingConstraintConfig{commission_rate:0.0003,minimum_commission:5.,transfer_fee_rate:0.00001,..Default::default()});
let mut instrument=Instrument{symbol:"510300.SH".into(),name:"fixture".into(),board:"ETF".into(),round_lot:100,listed_at:Some(day),delisted_at:None,status:"active".into()};
for side in [OrderSide::Buy,OrderSide::Sell] {
let cost=model.calculate_for_instrument(day,side,10_000.,Some(&instrument));
assert_eq!(cost.commission,5.);assert_eq!(cost.stamp_tax,0.);assert_eq!(cost.transfer_fee,0.);
let mut state=BTreeMap::new();
let one=model.calculate_with_order_state_for_instrument(day,side,1_000.,Some(1),&mut state,Some(&instrument));
let two=model.calculate_with_order_state_for_instrument(day,side,9_000.,Some(1),&mut state,Some(&instrument));
assert_eq!(one.total()+two.total(),cost.total());
}
instrument.board="SH".into();
let stock=model.calculate_for_instrument(day,OrderSide::Sell,10_000.,Some(&instrument));
assert_eq!(stock.stamp_tax,5.);assert_eq!(stock.transfer_fee,0.1);
assert_eq!(stock.total(),model.calculate(day,OrderSide::Sell,10_000.).total());
}
#[test]
fn default_quantizes_fees_to_micro_yuan() {
let model = ChinaAShareCostModel::default();
File diff suppressed because it is too large Load Diff
+2614 -211
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
//! Causal volume budgets. Session totals may audit fills, never size earlier orders.
use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VolumeCapacityMode {
#[default]
ExecutionObservation,
CompletedBar,
SessionCapacityAudit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum CapacityError {
#[error("execution capacity ratio must be finite and in (0, 1]")]
InvalidRatio,
#[error("execution capacity decimal cannot be represented exactly")]
InvalidDecimal,
#[error("execution capacity observation has invalid time bounds")]
InvalidWindow,
#[error("execution capacity is not visible: available={available_at}, execution={execution_at}")]
NotVisible { available_at: NaiveDateTime, execution_at: NaiveDateTime },
#[error("execution capacity observation belongs to another session")]
WrongSession,
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
MissingObservation,
}
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
#[derive(Debug, Clone, Copy)]
pub struct ParticipationRate {
numerator: u128,
denominator: u128,
}
impl ParticipationRate {
pub fn new(rate: f64) -> Result<Self, CapacityError> {
if !rate.is_finite() || rate <= 0.0 || rate > 1.0 {
return Err(CapacityError::InvalidRatio);
}
if rate < 1e-20 {
// Even u64::MAX shares at this rate cannot admit a single share.
return Ok(Self { numerator: 0, denominator: 1 });
}
if rate == 1.0 {
return Ok(Self { numerator: 1, denominator: 1 });
}
let text = rate.to_string();
let digits = text.strip_prefix("0.").ok_or(CapacityError::InvalidDecimal)?;
let digits = digits.trim_end_matches('0');
let numerator = digits.parse::<u128>().map_err(|_| CapacityError::InvalidDecimal)?;
let denominator = 10_u128.checked_pow(digits.len() as u32).ok_or(CapacityError::InvalidDecimal)?;
if numerator > u128::MAX / u128::from(u64::MAX) {
return Err(CapacityError::InvalidDecimal);
}
Ok(Self { numerator, denominator })
}
pub fn total_shares(self, market_shares: u64) -> u64 {
let total = u128::from(market_shares) * self.numerator / self.denominator;
u64::try_from(total).expect("participation rate cannot exceed the market shares")
}
pub fn remaining(self, market_shares: u64, consumed_shares: u64, requested: u32) -> u32 {
self.total_shares(market_shares).saturating_sub(consumed_shares).min(u64::from(requested)) as u32
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VolumeObservationKind {
TradeIncrement,
CompletedBar,
CumulativeSession,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct VolumeObservation {
pub kind: VolumeObservationKind,
pub start: NaiveDateTime,
pub end: NaiveDateTime,
pub available_at: NaiveDateTime,
pub shares: u64,
}
impl VolumeObservation {
pub fn visible_shares(self, execution_at: NaiveDateTime) -> Result<u64, CapacityError> {
if self.start > self.end || self.available_at < self.end {
return Err(CapacityError::InvalidWindow);
}
if self.available_at > execution_at {
return Err(CapacityError::NotVisible { available_at: self.available_at, execution_at });
}
if self.start.date() != self.end.date() || self.end.date() != execution_at.date() {
return Err(CapacityError::WrongSession);
}
Ok(self.shares)
}
pub fn remaining(self, execution_at: NaiveDateTime, rate: ParticipationRate, consumed: u64, requested: u32) -> Result<u32, CapacityError> {
Ok(rate.remaining(self.visible_shares(execution_at)?, consumed, requested))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionCapacityAudit {
pub date: NaiveDate,
pub symbol: String,
pub filled_shares: u64,
pub session_shares: u64,
pub allowed_shares: u64,
pub passed: bool,
}
impl SessionCapacityAudit {
pub fn new(date: NaiveDate, symbol: String, filled_shares: u64, session_shares: u64, rate: ParticipationRate) -> Self {
let allowed_shares = rate.total_shares(session_shares);
Self { date, symbol, filled_shares, session_shares, allowed_shares, passed: filled_shares <= allowed_shares }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decimal_participation_never_rounds_a_fractional_share_up_or_overflows() {
assert_eq!(ParticipationRate::new(0.58).unwrap().total_shares(50), 29);
assert_eq!(ParticipationRate::new(0.25).unwrap().total_shares(3), 0);
assert_eq!(ParticipationRate::new(0.5).unwrap().total_shares(3), 1);
assert_eq!(ParticipationRate::new(1.).unwrap().total_shares(u64::MAX), u64::MAX);
assert_eq!(ParticipationRate::new(0.25).unwrap().remaining(u64::MAX, 0, u32::MAX), u32::MAX);
assert_eq!(ParticipationRate::new(f64::MIN_POSITIVE).unwrap().total_shares(u64::MAX), 0);
for rate in [0., -1., f64::NAN, f64::INFINITY, 1.001] {
assert!(ParticipationRate::new(rate).is_err());
}
}
#[test]
fn completed_volume_cannot_be_used_for_an_earlier_open() {
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
let opening = day.and_hms_opt(9,30,0).unwrap();
let closing = day.and_hms_opt(15,0,0).unwrap();
let observation = VolumeObservation { kind:VolumeObservationKind::CompletedBar, start:opening, end:closing, available_at:closing, shares:10000 };
assert!(matches!(observation.visible_shares(opening), Err(CapacityError::NotVisible { .. })));
assert_eq!(observation.remaining(closing, ParticipationRate::new(0.25).unwrap(), 1000, 5000).unwrap(), 1500);
assert!(matches!(observation.visible_shares(closing+chrono::Duration::days(1)), Err(CapacityError::WrongSession)));
}
#[test]
fn delayed_publication_and_invalid_bounds_are_not_treated_as_zero_volume() {
let at = NaiveDate::from_ymd_opt(2025,1,2).unwrap().and_hms_opt(10,18,0).unwrap();
let observation = VolumeObservation { kind:VolumeObservationKind::TradeIncrement, start:at, end:at, available_at:at+chrono::Duration::seconds(1), shares:0 };
assert!(matches!(observation.visible_shares(at), Err(CapacityError::NotVisible { .. })));
assert_eq!(VolumeObservation { available_at:at-chrono::Duration::seconds(1), ..observation }.visible_shares(at), Err(CapacityError::InvalidWindow));
assert_eq!(VolumeObservation { available_at:at, ..observation }.visible_shares(at).unwrap(), 0);
}
#[test]
fn session_audit_changes_verdict_not_executed_quantity() {
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
let rate = ParticipationRate::new(0.25).unwrap();
let a = SessionCapacityAudit::new(day,"TEST".into(),1000,3000,rate);
let b = SessionCapacityAudit::new(day,"TEST".into(),1000,5000,rate);
assert!(!a.passed); assert!(b.passed);
assert_eq!(a.filled_shares,b.filled_shares);
}
}
@@ -0,0 +1,61 @@
use chrono::NaiveDateTime;
use crate::engine::BacktestError;
/// Cumulative TWAP entitlement depends on the frozen clock, not future quotes.
pub(crate) struct TwapSchedule {
start: NaiveDateTime,
end: NaiveDateTime,
quantity: u32,
}
impl TwapSchedule {
pub(crate) fn new(
start: Option<NaiveDateTime>,
end: Option<NaiveDateTime>,
quantity: u32,
) -> Result<Self, BacktestError> {
let (Some(start), Some(end)) = (start, end) else {
return Err(BacktestError::Execution("TWAP requires an explicit start and end clock".into()));
};
if end < start || start.date() != end.date() {
return Err(BacktestError::Execution("TWAP requires an ordered same-session clock window".into()));
}
Ok(Self { start, end, quantity })
}
pub(crate) fn due_quantity(&self, at: NaiveDateTime, filled: u32) -> u32 {
if at < self.start {
return 0;
}
let entitlement = if at >= self.end {
self.quantity
} else {
let elapsed = (at - self.start).num_microseconds().expect("same-day interval") as u128;
let duration = (self.end - self.start).num_microseconds().expect("same-day interval") as u128;
(u128::from(self.quantity) * elapsed / duration) as u32
};
entitlement.saturating_sub(filled)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, NaiveDate};
#[test]
fn clock_entitlements_are_exact_and_allow_backlog_without_future_quote_counts() {
let start = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(10, 0, 0).unwrap();
let end = start + Duration::minutes(10);
let schedule = TwapSchedule::new(Some(start), Some(end), 1_000).unwrap();
assert_eq!(schedule.due_quantity(start, 0), 0);
assert_eq!(schedule.due_quantity(start + Duration::minutes(2), 0), 200);
assert_eq!(schedule.due_quantity(start + Duration::minutes(5), 100), 400);
assert_eq!(schedule.due_quantity(end, 100), 900);
assert_eq!(schedule.due_quantity(end, 1_000), 0);
assert!(TwapSchedule::new(Some(start), None, 1_000).is_err());
assert!(TwapSchedule::new(Some(end), Some(start), 1_000).is_err());
assert_eq!(TwapSchedule::new(Some(start), Some(start), 1_000).unwrap().due_quantity(start, 0), 1_000);
}
}
@@ -0,0 +1,234 @@
//! Cross-sectional operators require an explicit complete universe, never a UI page.
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const OPERATORS: &[&str] = &[
"RANK",
"PERCENTILE",
"TOP",
"BOTTOM",
"TOP_PERCENT",
"BOTTOM_PERCENT",
"WINSORIZE",
"INDUSTRY_NEUTRALIZE",
"SIZE_NEUTRALIZE",
];
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Observation {
pub symbol: String,
pub value: f64,
pub industry: Option<String>,
pub market_cap: Option<f64>,
}
#[derive(Debug, Serialize)]
pub struct Output {
pub symbol: String,
pub value: f64,
}
/// Every date ranks the same frozen research universe; unknown inputs invalidate the whole date.
pub fn rank_history(
dates: &[chrono::NaiveDate], universe: &[String], values: &BTreeMap<String, Vec<Option<f64>>>,
) -> Result<serde_json::Value, String> {
use serde_json::json;
if dates.is_empty() || dates.windows(2).any(|w| w[0] >= w[1]) || universe.len() < 2
|| universe.len() > 20_000 || dates.len().saturating_mul(universe.len()) > 2_000_000
|| universe.iter().collect::<BTreeSet<_>>().len() != universe.len()
|| values.keys().collect::<BTreeSet<_>>() != universe.iter().collect::<BTreeSet<_>>()
|| values.values().any(|v| v.len() != dates.len() || v.iter().flatten().any(|v| !v.is_finite())) {
return Err("research_rank_history_incomplete_or_invalid_universe".into());
}
let mut rank = universe.iter().map(|s|(s.clone(),vec![None;dates.len()])).collect::<BTreeMap<_,_>>();
let mut percentile = rank.clone();
let mut unknown_dates = Vec::new();
for (i, date) in dates.iter().enumerate() {
let missing = universe.iter().filter(|s|values[*s][i].is_none()).collect::<Vec<_>>();
if !missing.is_empty() {
unknown_dates.push(json!({"date":date,"missing_count":missing.len(),"missing_symbol_sample":missing.iter().take(20).collect::<Vec<_>>(),"sample_limit":20}));
continue;
}
let observations = universe.iter().map(|s|Observation{symbol:s.clone(),value:values[s][i].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>();
for item in evaluate("RANK", universe, &observations, 0.0)? {rank.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
for item in evaluate("PERCENTILE", universe, &observations, 0.0)? {percentile.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
}
Ok(json!({"rank":rank,"percentile":percentile,"unknown_dates":unknown_dates,
"universe":universe,"dates":dates,"tie_policy":"average_rank_descending",
"membership_policy":"fixed_research_scope_not_historical_index_membership"}))
}
fn mean(values: &[f64]) -> f64 {
let base = values[0];
base + values
.iter()
.skip(1)
.map(|v| (v - base) / values.len() as f64)
.sum::<f64>()
}
fn quantile(sorted: &[f64], p: f64) -> f64 {
let x = p * (sorted.len() - 1) as f64;
let l = x.floor() as usize;
let r = x.ceil() as usize;
sorted[l] + (sorted[r] - sorted[l]) * (x - l as f64)
}
pub fn evaluate(
name: &str,
universe: &[String],
rows: &[Observation],
threshold: f64,
) -> Result<Vec<Output>, String> {
let expected = universe.iter().collect::<BTreeSet<_>>();
if rows.is_empty()
|| rows.len() > 20_000
|| expected.len() != universe.len()
|| rows.len() != universe.len()
|| rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != expected
|| rows.iter().any(|r| !r.value.is_finite())
{
return Err("cross_section_incomplete_or_invalid_universe".into());
}
if !OPERATORS.contains(&name) || !threshold.is_finite() {
return Err("cross_section_operator_invalid".into());
}
if matches!(name, "TOP" | "BOTTOM") && (threshold < 1.0 || threshold.fract() != 0.0)
|| matches!(name, "TOP_PERCENT" | "BOTTOM_PERCENT") && !(0.0..=1.0).contains(&threshold)
|| name == "WINSORIZE" && !(0.0..0.5).contains(&threshold)
{
return Err("cross_section_threshold_invalid".into());
}
let mut sorted = rows.iter().map(|r| r.value).collect::<Vec<_>>();
sorted.sort_by(f64::total_cmp);
let mut industry_values: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
if name == "INDUSTRY_NEUTRALIZE" {
for row in rows {
let industry = row
.industry
.as_deref()
.filter(|v| !v.trim().is_empty())
.ok_or("cross_section_pit_industry_missing")?;
industry_values.entry(industry).or_default().push(row.value);
}
}
let size = if name == "SIZE_NEUTRALIZE" {
let x = rows
.iter()
.map(|r| {
r.market_cap
.filter(|v| v.is_finite() && *v > 0.0)
.map(f64::ln)
.ok_or("cross_section_market_cap_missing")
})
.collect::<Result<Vec<_>, _>>()?;
let xm = mean(&x);
let ym = mean(&sorted);
let variance = x.iter().map(|v| (v - xm).powi(2)).sum::<f64>();
if variance == 0.0 || rows.len() < 3 {
return Err("cross_section_size_regression_unidentified".into());
}
let beta = x
.iter()
.zip(rows)
.map(|(x, y)| (x - xm) * (y.value - ym))
.sum::<f64>()
/ variance;
Some((x, xm, ym, beta))
} else {
None
};
rows.iter()
.enumerate()
.map(|(index, row)| {
let low = sorted.partition_point(|v| *v < row.value);
let high = sorted.partition_point(|v| *v <= row.value);
let rank = (low + 1 + high) as f64 / 2.0;
let descending = (rows.len() + 1) as f64 - rank;
let percentile = if rows.len() == 1 {
0.5
} else {
(rank - 1.0) / (rows.len() - 1) as f64
};
let value = match name {
"RANK" => descending,
"PERCENTILE" => percentile,
"TOP" => f64::from(descending <= threshold),
"BOTTOM" => f64::from(rank <= threshold),
"TOP_PERCENT" => f64::from(descending <= threshold * rows.len() as f64),
"BOTTOM_PERCENT" => f64::from(rank <= threshold * rows.len() as f64),
"WINSORIZE" => row.value.clamp(
quantile(&sorted, threshold),
quantile(&sorted, 1.0 - threshold),
),
"INDUSTRY_NEUTRALIZE" => {
row.value - mean(&industry_values[row.industry.as_deref().unwrap()])
}
"SIZE_NEUTRALIZE" => {
let (x, xm, ym, beta) = size.as_ref().unwrap();
row.value - (ym + beta * (x[index] - xm))
}
_ => unreachable!(),
};
if !value.is_finite() {
return Err("cross_section_result_nonfinite".into());
}
Ok(Output {
symbol: row.symbol.clone(),
value,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn historical_ranks_keep_ties_and_unknown_full_cross_sections() {
let dates=["2026-09-07","2026-09-08","2026-09-09"].map(|d|d.parse().unwrap());
let universe=vec!["A".into(),"B".into(),"C".into()];
let values=BTreeMap::from([("A".into(),vec![None,Some(10.0),Some(20.0)]),("B".into(),vec![Some(10.0),Some(10.0),Some(10.0)]),("C".into(),vec![Some(20.0),Some(5.0),Some(15.0)])]);
let out=rank_history(&dates,&universe,&values).unwrap();
assert_eq!(out["rank"]["A"],serde_json::json!([null,1.5,1.0]));
assert_eq!(out["rank"]["C"],serde_json::json!([null,3.0,2.0]));
assert_eq!(out["unknown_dates"][0]["missing_count"],1);
let earlier=values.iter().map(|(s,v)|(s.clone(),v[..2].to_vec())).collect();
let first=rank_history(&dates[..2],&universe,&earlier).unwrap();
assert_eq!(&out["rank"]["A"].as_array().unwrap()[..2],first["rank"]["A"].as_array().unwrap());
assert!(rank_history(&dates,&universe[..2],&values).is_err());
}
fn rows() -> Vec<Observation> {
[1.0, 3.0, 3.0, 4.0]
.iter()
.enumerate()
.map(|(i, &value)| Observation {
symbol: format!("S{i}"),
value,
industry: Some(if i < 2 { "A" } else { "B" }.into()),
market_cap: Some(10.0 + i as f64),
})
.collect()
}
#[test]
fn ties_keep_equal_rank_and_missing_universe_rejects() {
let r = rows();
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
let out = evaluate("RANK", &u, &r, 0.0).unwrap();
assert_eq!(
out.iter().map(|r| r.value).collect::<Vec<_>>(),
vec![4.0, 2.5, 2.5, 1.0]
);
assert!(evaluate("RANK", &u, &r[..3], 0.0).is_err());
}
#[test]
fn neutralization_preserves_input_order() {
let r = rows();
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
let out = evaluate("INDUSTRY_NEUTRALIZE", &u, &r, 0.0).unwrap();
assert_eq!(
out.iter().map(|r| r.value).collect::<Vec<_>>(),
vec![-1.0, 1.0, -0.5, 0.5]
);
assert!(evaluate("TOP_PERCENT", &u, &r, 20.0).is_err());
}
}
@@ -0,0 +1,52 @@
//! Indicator metadata is versioned independently from the numerical kernel.
use crate::factor_events::{CONTRACT, OPERATORS, TA_REV};
use serde_json::{Value, json};
use ta_lib::abstract_api::{self, OptInputType};
pub fn catalog() -> Value {
use sha2::{Digest, Sha256};
let mut implementation = Sha256::new();
for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(),
include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(),
include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);}
let implementation_sha256=format!("{:x}",implementation.finalize());
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":parameter_domain(p.kind)})).collect::<Vec<_>>(),
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
})).collect();
json!({"contract":CONTRACT,"parameter_domain_contract":"fidc.indicator-parameter-domain/v1","expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
"execution_context_contract":crate::pattern_context::CONTRACT,
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
"market_event_context_contract":crate::market_event_context::CONTRACT,
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
}
pub(crate) fn parameter_domain(kind: OptInputType) -> Value {
match kind {
OptInputType::IntegerRange { min, max, default, .. } => json!({
"value_type":"integer", "minimum":min, "maximum":max, "default":default,
}),
OptInputType::RealRange { min, max, default, precision, .. } => json!({
"value_type":"number", "minimum":min, "maximum":max,
"default":default, "display_precision":precision,
}),
OptInputType::IntegerList { values, default } => json!({
"value_type":"integer", "default":default,
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
}),
OptInputType::RealList { values, default } => json!({
"value_type":"number", "default":default,
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
}),
}
}
+936
View File
@@ -0,0 +1,936 @@
//! Causal, typed indicator/event expressions shared by research and trading.
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use ta_lib::{
Core,
abstract_api::{self, InputType, OptInputType, OutputType},
};
pub const CONTRACT: &str = "fidc_factor_event_expression_v1";
pub const TA_REV: &str = "dd5a90259a3f9e04e2da9f38bf0719a841b40108";
pub fn field_dependencies(expr: &Expr) -> std::collections::BTreeSet<String> {
let mut fields = std::collections::BTreeSet::new();
match expr {
Expr::Field { name } => {
fields.insert(name.clone());
}
Expr::Indicator { inputs, .. } => {
for e in inputs {
fields.extend(field_dependencies(e));
}
}
Expr::Operator { args, .. } => {
for e in args {
fields.extend(field_dependencies(e));
}
}
_ => {}
}
fields
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Expr {
Number {
value: f64,
},
Field {
name: String,
},
Indicator {
name: String,
#[serde(default)]
inputs: Vec<Expr>,
#[serde(default)]
parameters: BTreeMap<String, Value>,
#[serde(default)]
output: usize,
},
Operator {
name: String,
args: Vec<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
window: Option<usize>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Frame {
pub symbol: String,
pub frequency: String,
pub decision_at: DateTime<FixedOffset>,
pub timestamps: Vec<DateTime<FixedOffset>>,
pub available_at: Vec<DateTime<FixedOffset>>,
pub fields: BTreeMap<String, Vec<Option<f64>>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ValueType {
Number,
Boolean,
}
#[derive(Debug, Clone, Serialize)]
pub struct Series {
pub value_type: ValueType,
pub values: Vec<Option<f64>>,
}
pub(crate) const OPERATORS: &[&str] = &[
"GT",
"GTE",
"LT",
"LTE",
"EQ",
"NEQ",
"BETWEEN",
"OUTSIDE",
"CROSS_ABOVE",
"CROSS_BELOW",
"BREAK_ABOVE",
"BREAK_BELOW",
"BREAK_HIGH",
"BREAK_LOW",
"CHANGE",
"DIFF",
"DELTA",
"PCT_CHANGE",
"LOG_RETURN",
"RISING",
"FALLING",
"NON_DECREASING",
"NON_INCREASING",
"TURN_UP",
"TURN_DOWN",
"BOTTOM_REVERSAL",
"TOP_REVERSAL",
"SLOPE",
"SLOPE_CHANGE",
"ACCELERATION",
"HHV",
"LLV",
"ARGMAX",
"ARGMIN",
"DISTANCE_TO_HIGH",
"DISTANCE_TO_LOW",
"NEW_HIGH",
"NEW_LOW",
"NEAR_HIGH",
"NEAR_LOW",
"BULLISH_DIVERGENCE",
"BEARISH_DIVERGENCE",
"ZSCORE",
"MINMAX",
"STANDARDIZE",
"NORMALIZE",
"COUNT",
"COUNT_TRUE",
"CONSECUTIVE",
"BARS_SINCE",
"DURATION",
"DAYS_SINCE",
"TIME_SINCE",
"REF",
"LAG",
"PREV",
"SHIFT",
"ROLLING_MEAN",
"ROLLING_SUM",
"ROLLING_STD",
"ROLLING_MAX",
"ROLLING_MIN",
"ROLLING_MEDIAN",
"ROLLING_CORR",
"ROLLING_COV",
"AND",
"OR",
"NOT",
"XOR",
"ADD",
"SUB",
"MUL",
"DIV",
"ABS",
"MAX",
"MIN",
"LOG",
"SQRT",
"POWER",
"CUMMAX",
"CUMMIN",
"SIGN",
"IF",
];
pub use crate::factor_event_catalog::catalog;
impl Frame {
pub fn validate(&self) -> Result<(), String> {
let n = self.timestamps.len();
if self.symbol.is_empty()
|| n == 0
|| n > 200_000
|| self.available_at.len() != n
|| self.fields.len() > 100
|| n.saturating_mul(self.fields.len()) > 1_000_000
{
return Err("factor_frame_invalid: identity/shape/limit".into());
}
if !["1d", "1w", "1m", "5m", "15m", "30m", "60m"].contains(&self.frequency.as_str()) {
return Err("factor_frame_invalid: unsupported_frequency".into());
}
for i in 0..n {
if (i > 0 && self.timestamps[i] <= self.timestamps[i - 1])
|| self.available_at[i] < self.timestamps[i]
|| self.available_at[i] > self.decision_at
{
return Err(format!(
"factor_input_not_visible: {} index={i}",
self.symbol
));
}
}
for (field, values) in &self.fields {
if values.len() != n || values.iter().flatten().any(|v| !v.is_finite()) {
return Err(format!("factor_field_invalid: {} {field}", self.symbol));
}
}
Ok(())
}
}
pub fn evaluate(expr: &Expr, frame: &Frame) -> Result<Series, String> {
frame.validate()?;
fn cost(expr: &Expr, depth: usize, nodes: &mut usize) -> Result<usize, String> {
*nodes += 1;
if depth > 24 || *nodes > 256 {
return Err("factor_expression_size_exceeded".into());
}
let (children, own) = match expr {
Expr::Indicator {
inputs, parameters, ..
} => (
inputs.as_slice(),
parameters
.values()
.filter_map(Value::as_u64)
.max()
.unwrap_or(30)
.min(1_000_000) as usize,
),
Expr::Operator { args, window, .. } => (args.as_slice(), window.unwrap_or(1)),
_ => (&[][..], 1),
};
children.iter().try_fold(own, |total, child| {
Ok(total.saturating_add(cost(child, depth + 1, nodes)?))
})
}
if frame
.timestamps
.len()
.saturating_mul(cost(expr, 0, &mut 0)?)
> 20_000_000
{
return Err("factor_expression_compute_budget_exceeded".into());
}
evaluate_inner(expr, frame, 0)
}
fn evaluate_inner(expr: &Expr, frame: &Frame, depth: usize) -> Result<Series, String> {
if depth > 24 {
return Err("factor_expression_too_deep".into());
}
match expr {
Expr::Number { value } if value.is_finite() => Ok(Series {
value_type: ValueType::Number,
values: vec![Some(*value); frame.timestamps.len()],
}),
Expr::Number { .. } => Err("factor_constant_nonfinite".into()),
Expr::Field { name } => Ok(Series {
value_type: ValueType::Number,
values: frame
.fields
.get(name)
.ok_or_else(|| format!("factor_source_field_missing: {} {name}", frame.symbol))?
.clone(),
}),
Expr::Indicator {
name,
inputs,
parameters,
output,
} => indicator(name, inputs, parameters, *output, frame, depth),
Expr::Operator { name, args, window } => {
if args.len() > 16 {
return Err("factor_operator_arity_exceeded".into());
}
let args = args
.iter()
.map(|a| evaluate_inner(a, frame, depth + 1))
.collect::<Result<Vec<_>, _>>()?;
operator(name, &args, *window, frame)
}
}
}
fn indicator(
name: &str,
inputs: &[Expr],
parameters: &BTreeMap<String, Value>,
output: usize,
frame: &Frame,
depth: usize,
) -> Result<Series, String> {
let id =
abstract_api::get_func_handle(name).ok_or_else(|| format!("indicator_unknown: {name}"))?;
let info = id.info();
if output >= info.outputs.len() {
return Err("indicator_output_invalid".into());
}
let real_count = info
.inputs
.iter()
.filter(|i| i.kind == InputType::Real)
.count();
if inputs.len() != real_count || info.inputs.iter().any(|i| i.kind == InputType::Integer) {
return Err(format!(
"indicator_inputs_invalid: {name} expects {real_count} real series"
));
}
let mut data = inputs
.iter()
.map(|a| evaluate_inner(a, frame, depth + 1))
.collect::<Result<Vec<_>, _>>()?;
if data.iter().any(|s| s.value_type != ValueType::Number) {
return Err("indicator_requires_numeric_input".into());
}
let price_names = ["open", "high", "low", "close", "volume", "open_interest"];
let flags = info
.inputs
.iter()
.filter(|i| i.kind == InputType::Price)
.fold(0, |v, i| v | i.flags.0);
let mut price_indices = [None; 6];
for (i, field) in price_names.iter().enumerate() {
if flags & (1 << i) != 0 {
price_indices[i] = Some(data.len());
data.push(evaluate_inner(
&Expr::Field {
name: (*field).into(),
},
frame,
depth + 1,
)?);
}
}
let core = Core::new();
let mut validation = id.new_call(&core);
for (key, v) in parameters {
let slot = info
.opt_inputs
.iter()
.position(|p| p.param_name == key)
.ok_or_else(|| format!("indicator_parameter_unknown: {name}.{key}"))?;
match info.opt_inputs[slot].kind {
OptInputType::IntegerRange { .. } | OptInputType::IntegerList { .. } => {
let v = v
.as_i64()
.and_then(|v| i32::try_from(v).ok())
.ok_or("indicator_parameter_requires_integer")?;
validation.set_opt(slot, v).map_err(|e| format!("{e:?}"))?;
}
_ => {
validation
.set_opt(
slot,
v.as_f64()
.filter(|v| v.is_finite())
.ok_or("indicator_parameter_requires_finite_number")?,
)
.map_err(|e| format!("{e:?}"))?;
}
}
}
let lookback = validation
.lookback()
.map_err(|e| format!("indicator_parameter_invalid: {name} {e:?}"))?;
let n = frame.timestamps.len();
let mut result = vec![None; n];
let mut start = 0;
// Never bridge missing source observations. Recursive indicators rewarm after a gap.
while start < n {
if data.iter().any(|s| s.values[start].is_none()) {
start += 1;
continue;
}
let mut end = start + 1;
while end < n && data.iter().all(|s| s.values[end].is_some()) {
end += 1;
}
if end - start <= lookback {
start = end;
continue;
}
let arrays = data
.iter()
.map(|s| {
s.values[start..end]
.iter()
.map(|v| v.unwrap())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let mut float_out = (0..info.outputs.len())
.map(|_| vec![0.0; end - start])
.collect::<Vec<_>>();
let mut int_out = (0..info.outputs.len())
.map(|_| vec![0i32; end - start])
.collect::<Vec<_>>();
let mut call = id.new_call(&core);
for (key, v) in parameters {
let slot = info
.opt_inputs
.iter()
.position(|p| p.param_name == key)
.unwrap();
match info.opt_inputs[slot].kind {
OptInputType::IntegerRange { .. } | OptInputType::IntegerList { .. } => {
call.set_opt(slot, v.as_i64().unwrap() as i32)
.map_err(|e| format!("{e:?}"))?;
}
_ => {
call.set_opt(slot, v.as_f64().unwrap())
.map_err(|e| format!("{e:?}"))?;
}
}
}
let mut real_slot = 0;
for (slot, i) in info.inputs.iter().enumerate() {
if i.kind == InputType::Real {
call.set_input(slot, &arrays[real_slot])
.map_err(|e| format!("{e:?}"))?;
real_slot += 1;
} else {
let p = price_indices.map(|i| i.map(|i| arrays[i].as_slice()));
call.set_price_input(slot, p[0], p[1], p[2], p[3], p[4], p[5])
.map_err(|e| format!("{e:?}"))?;
}
}
for (slot, (floats, ints)) in float_out.iter_mut().zip(int_out.iter_mut()).enumerate() {
if info.outputs[slot].kind == OutputType::Real {
call.set_output(slot, floats)
.map_err(|e| format!("{e:?}"))?;
} else {
call.set_int_output(slot, ints)
.map_err(|e| format!("{e:?}"))?;
}
}
let range = call
.call(0, end - start - 1)
.map_err(|e| format!("indicator_failed: {name} {e:?}"))?;
drop(call);
for j in 0..range.count {
let value = if info.outputs[output].kind == OutputType::Real {
float_out[output][j]
} else {
int_out[output][j] as f64
};
if !value.is_finite() {
return Err(format!(
"indicator_nonfinite: {name} index={}",
start + range.beg_idx + j
));
}
result[start + range.beg_idx + j] = Some(value);
}
start = end;
}
Ok(Series {
value_type: ValueType::Number,
values: result,
})
}
fn average(v: &[f64]) -> f64 {
v[0] + v
.iter()
.skip(1)
.map(|x| (x - v[0]) / v.len() as f64)
.sum::<f64>()
}
fn slope(v: &[f64]) -> f64 {
let x = (v.len() - 1) as f64 / 2.0;
let y = average(v);
let num = v
.iter()
.enumerate()
.map(|(i, v)| (i as f64 - x) * (v - y))
.sum::<f64>();
let den = (0..v.len()).map(|i| (i as f64 - x).powi(2)).sum::<f64>();
num / den
}
fn boolean(v: bool) -> Option<f64> {
Some(if v { 1.0 } else { 0.0 })
}
fn operator(
name: &str,
args: &[Series],
window: Option<usize>,
frame: &Frame,
) -> Result<Series, String> {
if !OPERATORS.contains(&name) {
return Err(format!("operator_not_registered: {name}"));
}
let bool_input = matches!(
name,
"AND"
| "OR"
| "NOT"
| "XOR"
| "COUNT"
| "COUNT_TRUE"
| "CONSECUTIVE"
| "BARS_SINCE"
| "DURATION"
| "DAYS_SINCE"
| "TIME_SINCE"
);
let lag = matches!(name, "REF" | "LAG" | "PREV" | "SHIFT");
if args.is_empty()
|| (name == "IF"
&& (args.len() != 3
|| args[0].value_type != ValueType::Boolean
|| args[1].value_type != args[2].value_type))
|| (!lag
&& name != "IF"
&& args
.iter()
.any(|a| (a.value_type == ValueType::Boolean) != bool_input))
{
return Err(format!("operator_input_type_invalid: {name}"));
}
let arity = match name {
"BETWEEN" | "OUTSIDE" | "IF" => 3,
"GT" | "GTE" | "LT" | "LTE" | "EQ" | "NEQ" | "CROSS_ABOVE" | "CROSS_BELOW"
| "BREAK_ABOVE" | "BREAK_BELOW" | "ADD" | "SUB" | "MUL" | "DIV" | "MAX" | "MIN"
| "POWER" | "XOR" | "ROLLING_CORR" | "ROLLING_COV" | "NEAR_HIGH" | "NEAR_LOW"
| "BULLISH_DIVERGENCE" | "BEARISH_DIVERGENCE" => 2,
"AND" | "OR" => args.len(),
_ => 1,
};
if args.len() != arity {
return Err(format!("operator_arity_invalid: {name}"));
}
let windowed = matches!(
name,
"BREAK_HIGH"
| "BREAK_LOW"
| "RISING"
| "FALLING"
| "NON_DECREASING"
| "NON_INCREASING"
| "SLOPE"
| "SLOPE_CHANGE"
| "HHV"
| "LLV"
| "ARGMAX"
| "ARGMIN"
| "DISTANCE_TO_HIGH"
| "DISTANCE_TO_LOW"
| "NEW_HIGH"
| "NEW_LOW"
| "NEAR_HIGH"
| "NEAR_LOW"
| "BULLISH_DIVERGENCE"
| "BEARISH_DIVERGENCE"
| "ZSCORE"
| "STANDARDIZE"
| "MINMAX"
| "NORMALIZE"
| "COUNT"
| "COUNT_TRUE"
) || name.starts_with("ROLLING_");
let n = window.unwrap_or(1);
if n == 0
|| n > 10_000
|| (windowed && window.is_none())
|| (matches!(
name,
"SLOPE"
| "SLOPE_CHANGE"
| "ZSCORE"
| "STANDARDIZE"
| "ROLLING_STD"
| "ROLLING_CORR"
| "ROLLING_COV"
) && n < 2)
{
return Err(format!("operator_window_invalid: {name}"));
}
let returns_bool = matches!(
name,
"GT" | "GTE"
| "LT"
| "LTE"
| "EQ"
| "NEQ"
| "BETWEEN"
| "OUTSIDE"
| "CROSS_ABOVE"
| "CROSS_BELOW"
| "BREAK_ABOVE"
| "BREAK_BELOW"
| "BREAK_HIGH"
| "BREAK_LOW"
| "RISING"
| "FALLING"
| "NON_DECREASING"
| "NON_INCREASING"
| "TURN_UP"
| "TURN_DOWN"
| "BOTTOM_REVERSAL"
| "TOP_REVERSAL"
| "NEW_HIGH"
| "NEW_LOW"
| "NEAR_HIGH"
| "NEAR_LOW"
| "BULLISH_DIVERGENCE"
| "BEARISH_DIVERGENCE"
| "AND"
| "OR"
| "NOT"
| "XOR"
);
let len = frame.timestamps.len();
let mut out = vec![None; len];
let mut last_true = None;
let mut consecutive = Some(0usize);
let mut extreme: Option<f64> = None;
let mut cumulative_complete = true;
for i in 0..len {
let a = args[0].values[i];
let b = args.get(1).and_then(|a| a.values[i]);
let at = |j: usize| args[0].values.get(j).copied().flatten();
let history = |end: usize, count: usize| -> Option<Vec<f64>> {
if end < count {
None
} else {
args[0].values[end - count..end].iter().copied().collect()
}
};
out[i] = match name {
"IF" => a.and_then(|a| {
if a == 1.0 {
args[1].values[i]
} else {
args[2].values[i]
}
}),
"SIGN" => a.map(|v| {
if v == 0.0 {
0.0
} else if v > 0.0 {
1.0
} else {
-1.0
}
}),
"CUMMAX" | "CUMMIN" => {
cumulative_complete &= a.is_some();
extreme = a.filter(|_| cumulative_complete).map(|v| {
extreme.map_or(v, |p| if name == "CUMMAX" { p.max(v) } else { p.min(v) })
});
extreme
}
"AND" => {
if args.iter().any(|a| a.values[i] == Some(0.0)) {
Some(0.0)
} else if args.iter().any(|a| a.values[i].is_none()) {
None
} else {
Some(1.0)
}
}
"OR" => {
if args.iter().any(|a| a.values[i] == Some(1.0)) {
Some(1.0)
} else if args.iter().any(|a| a.values[i].is_none()) {
None
} else {
Some(0.0)
}
}
"NOT" => a.map(|v| 1.0 - v),
"XOR" => a.zip(b).and_then(|(a, b)| boolean(a != b)),
"GT" | "GTE" | "LT" | "LTE" | "EQ" | "NEQ" => a.zip(b).and_then(|(a, b)| {
boolean(match name {
"GT" => a > b,
"GTE" => a >= b,
"LT" => a < b,
"LTE" => a <= b,
"EQ" => a == b,
_ => a != b,
})
}),
"BETWEEN" | "OUTSIDE" => a.zip(b).zip(args[2].values[i]).and_then(|((a, b), c)| {
if b > c {
None
} else {
boolean((a >= b && a <= c) == (name == "BETWEEN"))
}
}),
"CROSS_ABOVE" | "CROSS_BELOW" | "BREAK_ABOVE" | "BREAK_BELOW" => {
if i == 0 {
None
} else {
a.zip(b).zip(at(i - 1).zip(args[1].values[i - 1])).and_then(
|((a, b), (p, q))| {
boolean(if name.ends_with("ABOVE") {
p <= q && a > b
} else {
p >= q && a < b
})
},
)
}
}
"REF" | "LAG" | "PREV" | "SHIFT" => i.checked_sub(n).and_then(at),
"CHANGE" | "DIFF" | "DELTA" | "PCT_CHANGE" | "LOG_RETURN" => a
.zip(i.checked_sub(n).and_then(at))
.and_then(|(a, p)| match name {
"PCT_CHANGE" => {
if p == 0.0 {
None
} else {
Some(a / p - 1.0)
}
}
"LOG_RETURN" => {
if a <= 0.0 || p <= 0.0 {
None
} else {
Some((a / p).ln())
}
}
_ => Some(a - p),
}),
"ACCELERATION" => a
.zip(i.checked_sub(n).and_then(at))
.zip(i.checked_sub(n * 2).and_then(at))
.map(|((a, p), q)| a - 2.0 * p + q),
"BULLISH_DIVERGENCE" | "BEARISH_DIVERGENCE" => {
if i < n || n < 4 {
None
} else {
let price: Option<Vec<f64>> =
args[0].values[i - n..=i].iter().copied().collect();
let indicator: Option<Vec<f64>> =
args[1].values[i - n..=i].iter().copied().collect();
price.zip(indicator).and_then(|(price, indicator)| {
let low = name == "BULLISH_DIVERGENCE";
let pivots = (1..n)
.filter(|&j| {
if low {
price[j] < price[j - 1] && price[j] < price[j + 1]
} else {
price[j] > price[j - 1] && price[j] > price[j + 1]
}
})
.collect::<Vec<_>>();
if pivots.last() != Some(&(n - 1)) || pivots.len() < 2 {
return boolean(false);
}
let a = pivots[pivots.len() - 2];
let b = n - 1;
boolean(if low {
price[b] < price[a] && indicator[b] > indicator[a]
} else {
price[b] > price[a] && indicator[b] < indicator[a]
})
})
}
}
"TURN_UP" | "TURN_DOWN" | "BOTTOM_REVERSAL" | "TOP_REVERSAL" => {
if i < 2 {
None
} else {
a.zip(at(i - 1)).zip(at(i - 2)).and_then(|((a, p), q)| {
if name == "ACCELERATION" {
Some(a - 2.0 * p + q)
} else {
boolean(if matches!(name, "TURN_UP" | "BOTTOM_REVERSAL") {
p < q && a > p
} else {
p > q && a < p
})
}
})
}
}
"ABS" => a.map(f64::abs),
"LOG" => a.filter(|v| *v > 0.0).map(f64::ln),
"SQRT" => a.filter(|v| *v >= 0.0).map(f64::sqrt),
"ADD" => a.zip(b).map(|(a, b)| a + b),
"SUB" => a.zip(b).map(|(a, b)| a - b),
"MUL" => a.zip(b).map(|(a, b)| a * b),
"DIV" => a.zip(b).filter(|(_, b)| *b != 0.0).map(|(a, b)| a / b),
"MAX" => a.zip(b).map(|(a, b)| a.max(b)),
"MIN" => a.zip(b).map(|(a, b)| a.min(b)),
"POWER" => a.zip(b).map(|(a, b)| a.powf(b)),
"BARS_SINCE" | "DAYS_SINCE" | "TIME_SINCE" => {
if a == Some(1.0) {
last_true = Some(i);
}
if a.is_none() {
last_true = None;
}
last_true.map(|t| {
if name == "BARS_SINCE" {
(i - t) as f64
} else {
let secs = (frame.timestamps[i] - frame.timestamps[t]).num_seconds() as f64;
if name == "DAYS_SINCE" {
secs / 86400.0
} else {
secs
}
}
})
}
"CONSECUTIVE" | "DURATION" => {
consecutive = match a {
Some(1.0) => consecutive.map(|v| v + 1),
Some(_) => Some(0),
None => None,
};
consecutive.map(|v| v as f64)
}
"BREAK_HIGH" | "NEW_HIGH" | "BREAK_LOW" | "NEW_LOW" => {
a.zip(history(i, n)).and_then(|(a, v)| {
boolean(if matches!(name, "BREAK_HIGH" | "NEW_HIGH") {
a > v.into_iter().fold(f64::NEG_INFINITY, f64::max)
} else {
a < v.into_iter().fold(f64::INFINITY, f64::min)
})
})
}
"RISING" | "FALLING" | "NON_DECREASING" | "NON_INCREASING" => history(i + 1, n + 1)
.and_then(|v| {
boolean(v.windows(2).all(|p| match name {
"RISING" => p[1] > p[0],
"FALLING" => p[1] < p[0],
"NON_DECREASING" => p[1] >= p[0],
_ => p[1] <= p[0],
}))
}),
"SLOPE_CHANGE" => history(i + 1, n)
.zip(history(i, n))
.map(|(a, b)| slope(&a) - slope(&b)),
_ => history(i + 1, n).and_then(|mut v| {
let mean = average(&v);
let lo = v.iter().copied().fold(f64::INFINITY, f64::min);
let hi = v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let variance = v.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
match name {
"HHV" | "ROLLING_MAX" => Some(hi),
"LLV" | "ROLLING_MIN" => Some(lo),
"ARGMAX" => v.iter().rposition(|x| *x == hi).map(|p| (n - 1 - p) as f64),
"ARGMIN" => v.iter().rposition(|x| *x == lo).map(|p| (n - 1 - p) as f64),
"DISTANCE_TO_HIGH" => {
if hi == 0.0 {
None
} else {
Some(v[n - 1] / hi - 1.0)
}
}
"DISTANCE_TO_LOW" => {
if lo == 0.0 {
None
} else {
Some(v[n - 1] / lo - 1.0)
}
}
"NEAR_HIGH" | "NEAR_LOW" => b.filter(|b| *b >= 0.0).and_then(|b| {
let base = if name == "NEAR_HIGH" { hi } else { lo };
if base == 0.0 {
None
} else {
boolean((v[n - 1] / base - 1.0).abs() <= b)
}
}),
"ZSCORE" | "STANDARDIZE" => {
if variance == 0.0 {
None
} else {
Some((v[n - 1] - mean) / variance.sqrt())
}
}
"MINMAX" | "NORMALIZE" => {
if hi == lo {
None
} else {
Some((v[n - 1] - lo) / (hi - lo))
}
}
"ROLLING_MEAN" => Some(mean),
"ROLLING_SUM" | "COUNT" | "COUNT_TRUE" => Some(v.iter().sum()),
"ROLLING_STD" => Some(variance.sqrt()),
"ROLLING_MEDIAN" => {
v.sort_by(f64::total_cmp);
Some(if n % 2 == 1 {
v[n / 2]
} else {
(v[n / 2 - 1] + v[n / 2]) / 2.0
})
}
"SLOPE" => Some(slope(&v)),
"ROLLING_CORR" | "ROLLING_COV" => {
let b: Option<Vec<f64>> =
args[1].values[i + 1 - n..=i].iter().copied().collect();
b.and_then(|b| {
let bm = average(&b);
let cov = v
.iter()
.zip(&b)
.map(|(a, b)| (a - mean) * (b - bm))
.sum::<f64>()
/ n as f64;
if name == "ROLLING_COV" {
Some(cov)
} else {
let bv = b.iter().map(|b| (b - bm).powi(2)).sum::<f64>() / n as f64;
let d = (variance * bv).sqrt();
if d == 0.0 { None } else { Some(cov / d) }
}
})
}
_ => None,
}
}),
}
.filter(|v| v.is_finite());
}
Ok(Series {
value_type: if name == "IF" {
args[1].value_type
} else if lag {
args[0].value_type
} else if returns_bool {
ValueType::Boolean
} else {
ValueType::Number
},
values: out,
})
}
#[cfg(test)]
#[path = "factor_events_tests.rs"]
mod tests;
+161
View File
@@ -0,0 +1,161 @@
use super::*;
use serde_json::json;
use crate::factor_event_catalog::parameter_domain;
#[test]
fn every_parameter_domain_is_structured_and_matches_native_defaults() {
for function in abstract_api::funcs() {
let handle = abstract_api::get_func_handle(function.name).unwrap();
let core = Core::new();
let mut call = handle.new_call(&core);
for (index, parameter) in function.opt_inputs.iter().enumerate() {
let domain = parameter_domain(parameter.kind);
let default = domain["default"].as_f64().unwrap();
assert!(default.is_finite(), "{} {}", function.name, parameter.param_name);
if let Some(choices) = domain.get("choices") {
assert!(choices.as_array().unwrap().iter().any(|v| v["value"].as_f64() == Some(default)));
} else {
assert!(default >= domain["minimum"].as_f64().unwrap());
assert!(default <= domain["maximum"].as_f64().unwrap());
}
if domain["value_type"] == "integer" {
assert_eq!(default.fract(), 0.0);
call.set_opt(index, default as i32).unwrap();
} else {
call.set_opt(index, default).unwrap();
}
}
assert!(call.lookback().is_ok(), "{}", function.name);
}
}
#[test]
fn parameter_domains_keep_enumeration_labels_without_debug_string_parsing() {
let catalog = catalog();
assert_eq!(catalog["parameter_domain_contract"], "fidc.indicator-parameter-domain/v1");
let indicators = catalog["indicators"].as_array().unwrap();
let rsi = indicators.iter().find(|v| v["name"] == "RSI").unwrap();
assert_eq!(rsi["parameters"][0]["domain"]["minimum"], 2);
let stoch = indicators.iter().find(|v| v["name"] == "STOCH").unwrap();
let ma_type = stoch["parameters"].as_array().unwrap().iter().find(|p| p["name"] == "optInSlowK_MAType").unwrap();
assert!(ma_type["domain"]["choices"].as_array().unwrap().iter().any(|v| v["label"] == "EMA" && v["value"] == 1));
}
fn frame(values: Vec<Option<f64>>) -> Frame {
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
let times = (0..values.len())
.map(|i| start + chrono::Duration::days(i as i64))
.collect::<Vec<_>>();
Frame {
symbol: "TEST".into(),
frequency: "1d".into(),
decision_at: *times.last().unwrap(),
available_at: times.clone(),
timestamps: times,
fields: BTreeMap::from([("close".into(), values)]),
}
}
fn expr(v: Value) -> Expr {
serde_json::from_value(v).unwrap()
}
#[test]
fn ta_sma_real_values_and_parameter_validation() {
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
);
assert_eq!(
evaluate(&e, &frame).unwrap().values,
vec![None, None, Some(2.0), Some(3.0)]
);
let bad = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
);
assert!(
evaluate(&bad, &frame)
.unwrap_err()
.contains("parameter_unknown")
);
}
#[test]
fn cross_is_event_not_state_and_never_uses_future() {
let f = frame(vec![
Some(9.0),
Some(10.0),
Some(11.0),
Some(12.0),
Some(8.0),
]);
let e = expr(
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
);
let mut invalid = f.clone();
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
assert!(evaluate(&e, &invalid).is_err());
}
#[test]
fn missing_is_not_zero_and_breakout_excludes_current() {
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
let e = expr(
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, None, Some(1.0), None, None]
);
let zero = expr(
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
);
assert!(
evaluate(&zero, &f)
.unwrap()
.values
.iter()
.all(Option::is_none)
);
}
#[test]
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(1.0), None, None, Some(2.0)]
);
let e = expr(
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
}
#[test]
fn no_event_has_no_bars_since_and_type_errors_reject() {
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
assert!(
evaluate(
&expr(
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
),
&f
)
.is_err()
);
}
#[test]
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
assert!(
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
.is_err()
);
let c = catalog();
assert!(c["indicators"].as_array().unwrap().len() > 190);
assert_eq!(c["live_routing"], false);
}
+369
View File
@@ -0,0 +1,369 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::TradingCalendar;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TradingActionOrigin {
Strategy,
Manual,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomaticTradeProtection {
#[serde(default, deserialize_with = "optional_days")]
pub buy_protection_days: u32,
#[serde(default, deserialize_with = "optional_days")]
pub sell_cooldown_days: u32,
#[serde(default, deserialize_with = "optional_days")]
pub max_holding_days: u32,
#[serde(default, deserialize_with = "optional_locks")]
pub locks: Vec<AutomaticTradeLock>,
}
pub fn deserialize_optional_policy<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<AutomaticTradeProtection, D::Error> {
Ok(Option::<AutomaticTradeProtection>::deserialize(deserializer)?.unwrap_or_default())
}
fn optional_days<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
let raw = serde_json::Value::deserialize(deserializer)?;
if raw.is_null() {
return Ok(0);
}
raw.as_f64()
.filter(|value| {
value.is_finite() && value.fract() == 0.0 && *value >= 0.0 && *value <= 3650.0
})
.map(|value| value as u32)
.ok_or_else(|| serde::de::Error::custom("protection days must be integers in 0..3650"))
}
fn optional_locks<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<AutomaticTradeLock>, D::Error> {
Ok(Option::<Vec<AutomaticTradeLock>>::deserialize(deserializer)?.unwrap_or_default())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomaticTradeLock {
pub symbol: String,
pub start_date: NaiveDate,
pub end_date: Option<NaiveDate>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HoldingLifecycleEvidence {
pub has_position: bool,
pub opened_date: Option<NaiveDate>,
pub last_buy_date: Option<NaiveDate>,
pub last_sell_date: Option<NaiveDate>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AutomaticTradePermission {
pub buy_denial: Option<&'static str>,
pub sell_denial: Option<&'static str>,
pub max_holding_exit: bool,
}
impl AutomaticTradeProtection {
pub fn enabled(&self) -> bool {
self.buy_protection_days > 0
|| self.sell_cooldown_days > 0
|| self.max_holding_days > 0
|| !self.locks.is_empty()
}
pub fn validate(&self) -> Result<(), String> {
if [
self.buy_protection_days,
self.sell_cooldown_days,
self.max_holding_days,
]
.into_iter()
.any(|days| days > 3650)
{
return Err("automatic_trade_holding_days_out_of_range: expected 0..3650".into());
}
if self.locks.len() > 2000 {
return Err("automatic_trade_locks_limit: maximum 2000 intervals".into());
}
for lock in &self.locks {
let valid_symbol = lock.symbol.split_once('.').is_some_and(|(code, venue)| {
code.len() == 6
&& code.bytes().all(|ch| ch.is_ascii_digit())
&& matches!(venue, "SH" | "SZ" | "BJ")
});
if !valid_symbol {
return Err(format!(
"automatic_trade_lock_invalid_symbol: {}",
lock.symbol
));
}
if lock.end_date.is_some_and(|end| end < lock.start_date) {
return Err(format!(
"automatic_trade_lock_invalid_interval: {}",
lock.symbol
));
}
}
Ok(())
}
pub fn evaluate(
&self,
symbol: &str,
execution_date: NaiveDate,
evidence: &HoldingLifecycleEvidence,
calendar: &TradingCalendar,
) -> Result<AutomaticTradePermission, String> {
self.validate()?;
if self.locks.iter().any(|lock| {
lock.symbol == symbol
&& lock.start_date <= execution_date
&& lock.end_date.is_none_or(|end| execution_date <= end)
}) {
return Ok(AutomaticTradePermission {
buy_denial: Some("automatic_trade_locked"),
sell_denial: Some("automatic_trade_locked"),
max_holding_exit: false,
});
}
let elapsed = |date: NaiveDate| -> Result<usize, String> {
let start = calendar.index_of(date).ok_or_else(|| {
format!(
"automatic_trade_holding_calendar_missing: symbol={symbol} fact_date={date}"
)
})?;
let end = calendar.index_of(execution_date).ok_or_else(|| format!("automatic_trade_holding_calendar_missing: symbol={symbol} execution_date={execution_date}"))?;
end.checked_sub(start).ok_or_else(|| format!("automatic_trade_holding_future_fact: symbol={symbol} fact_date={date} execution_date={execution_date}"))
};
let mut decision = AutomaticTradePermission::default();
if self.buy_protection_days > 0
&& evidence.has_position
&& let Some(date) = evidence.last_buy_date
&& elapsed(date)? <= self.buy_protection_days as usize
{
decision.sell_denial = Some("buy_fill_protection");
}
if self.sell_cooldown_days > 0
&& let Some(date) = evidence.last_sell_date
&& elapsed(date)? <= self.sell_cooldown_days as usize
{
decision.buy_denial = Some("sell_fill_cooldown");
}
if self.max_holding_days > 0 && evidence.has_position {
let opened = evidence.opened_date.ok_or_else(|| format!("automatic_trade_opened_date_missing: symbol={symbol}; require confirmed position lifecycle evidence"))?;
decision.max_holding_exit = elapsed(opened)? >= self.max_holding_days as usize
&& decision.sell_denial.is_none();
if decision.max_holding_exit {
decision.buy_denial = Some("maximum_holding_exit");
}
}
Ok(decision)
}
/// The caller supplies origin from its authenticated execution path, never
/// from an untrusted order-body flag. Broker and ordinary risk checks remain.
pub fn evaluate_for_origin(
&self,
origin: TradingActionOrigin,
symbol: &str,
execution_date: NaiveDate,
evidence: &HoldingLifecycleEvidence,
calendar: &TradingCalendar,
) -> Result<AutomaticTradePermission, String> {
self.validate()?;
match origin {
TradingActionOrigin::Strategy => {
self.evaluate(symbol, execution_date, evidence, calendar)
}
TradingActionOrigin::Manual => Ok(AutomaticTradePermission::default()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(value: &str) -> NaiveDate {
NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap()
}
fn calendar() -> TradingCalendar {
TradingCalendar::new(
[
"2026-09-11",
"2026-09-14",
"2026-09-15",
"2026-09-16",
"2026-09-17",
]
.into_iter()
.map(d)
.collect(),
)
}
#[test]
fn three_complete_sessions_protect_through_wednesday_not_72_hours() {
let policy = AutomaticTradeProtection {
buy_protection_days: 3,
sell_cooldown_days: 3,
..Default::default()
};
let evidence = HoldingLifecycleEvidence {
has_position: true,
last_buy_date: Some(d("2026-09-11")),
last_sell_date: Some(d("2026-09-11")),
..Default::default()
};
for day in ["2026-09-11", "2026-09-14", "2026-09-15", "2026-09-16"] {
let decision = policy
.evaluate("000001.SZ", d(day), &evidence, &calendar())
.unwrap();
assert_eq!(decision.sell_denial, Some("buy_fill_protection"));
assert_eq!(decision.buy_denial, Some("sell_fill_cooldown"));
}
assert_eq!(
policy
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
.unwrap(),
AutomaticTradePermission::default()
);
}
#[test]
fn absolute_locks_are_inclusive_and_override_timed_exit_without_changing_other_symbols() {
let policy = AutomaticTradeProtection {
max_holding_days: 1,
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d("2026-09-11"),
end_date: Some(d("2026-09-16")),
}],
..Default::default()
};
let evidence = HoldingLifecycleEvidence {
has_position: true,
opened_date: Some(d("2026-09-11")),
..Default::default()
};
let locked = policy
.evaluate("000001.SZ", d("2026-09-16"), &evidence, &calendar())
.unwrap();
assert_eq!(locked.sell_denial, Some("automatic_trade_locked"));
assert!(!locked.max_holding_exit);
assert!(
policy
.evaluate("600000.SH", d("2026-09-16"), &evidence, &calendar())
.unwrap()
.max_holding_exit
);
assert!(
policy
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
.unwrap()
.max_holding_exit
);
}
#[test]
fn zero_disabled_and_missing_calendar_or_opened_date_are_not_inferred() {
let evidence = HoldingLifecycleEvidence {
has_position: true,
..Default::default()
};
assert_eq!(
AutomaticTradeProtection::default()
.evaluate(
"000001.SZ",
d("2026-09-17"),
&evidence,
&TradingCalendar::new(vec![])
)
.unwrap(),
AutomaticTradePermission::default()
);
let policy = AutomaticTradeProtection {
max_holding_days: 1,
..Default::default()
};
assert!(
policy
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
.unwrap_err()
.contains("opened_date_missing")
);
let evidence = HoldingLifecycleEvidence {
opened_date: Some(d("2026-09-10")),
..evidence
};
assert!(
policy
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
.unwrap_err()
.contains("calendar_missing")
);
}
#[test]
fn manual_origin_only_bypasses_automatic_policy_not_an_order_or_broker_permission() {
let policy = AutomaticTradeProtection {
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d("2026-09-11"),
end_date: None,
}],
..Default::default()
};
assert_eq!(
policy
.evaluate_for_origin(
TradingActionOrigin::Manual,
"000001.SZ",
d("2026-09-14"),
&HoldingLifecycleEvidence::default(),
&calendar()
)
.unwrap(),
AutomaticTradePermission::default()
);
assert_eq!(
policy
.evaluate_for_origin(
TradingActionOrigin::Strategy,
"000001.SZ",
d("2026-09-14"),
&HoldingLifecycleEvidence::default(),
&calendar()
)
.unwrap()
.buy_denial,
Some("automatic_trade_locked")
);
}
#[test]
fn opening_date_follows_fills_not_partial_sales_or_corporate_conversions() {
let mut portfolio = crate::PortfolioState::new(100_000.0);
let position = portfolio.position_mut("000001.SZ");
position.buy(d("2026-09-11"), 100, 10.0);
position.buy(d("2026-09-14"), 200, 10.0);
position.sell(100, 10.0).unwrap();
assert_eq!(position.opened_date(), Some(d("2026-09-11")));
portfolio
.apply_successor_conversion("000001.SZ", "000002.SZ", 2.0, 0.0)
.unwrap();
let successor = portfolio.position_mut("000002.SZ");
assert_eq!(successor.opened_date(), Some(d("2026-09-11")));
assert_eq!(successor.last_buy_date(), Some(d("2026-09-14")));
successor.sell(400, 5.0).unwrap();
assert_eq!(successor.opened_date(), None);
successor.buy(d("2026-09-17"), 100, 5.0);
assert_eq!(successor.opened_date(), Some(d("2026-09-17")));
}
}
+110 -9
View File
@@ -1,6 +1,17 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
pub fn listed_sector_is_kcb(value: &str) -> Option<bool> {
match value.trim().to_ascii_uppercase().as_str() {
"科创板" | "KSH" | "STAR" | "STAR_MARKET" => Some(true),
"主板" | "沪市主板" | "深市主板" | "中小板" | "中小企业板" | "创业板"
| "北交所" | "北证" | "新三板" | "基础层" | "创新层" | "精选层"
| "MAIN" | "MAIN_BOARD" | "CHINEXT" | "GEM" | "BJ" | "BJS" | "BJSE"
| "BSE" => Some(false),
_ => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Instrument {
pub symbol: String,
@@ -16,22 +27,40 @@ pub struct Instrument {
}
impl Instrument {
/// Classification from the admitted security master, never a code prefix
/// or a name substring. This does not grant T+0 settlement eligibility.
pub fn is_exchange_traded_fund(&self) -> bool {
matches!(self.board.trim().to_ascii_uppercase().as_str(), "ETF" | "EXCHANGE_TRADED_FUND")
}
pub fn effective_round_lot(&self) -> u32 {
self.round_lot.max(1)
}
pub fn minimum_order_quantity(&self) -> u32 {
match self.board.trim().to_ascii_uppercase().as_str() {
"KSH" => 200,
"BJS" | "BJ" | "BJSE" => 100,
_ => self.effective_round_lot(),
let board = self.board.trim();
if board.eq_ignore_ascii_case("KSH") {
200
} else if board.eq_ignore_ascii_case("BJS")
|| board.eq_ignore_ascii_case("BJ")
|| board.eq_ignore_ascii_case("BJSE")
{
100
} else {
self.effective_round_lot()
}
}
pub fn order_step_size(&self) -> u32 {
match self.board.trim().to_ascii_uppercase().as_str() {
"KSH" | "BJS" | "BJ" | "BJSE" => 1,
_ => self.effective_round_lot(),
let board = self.board.trim();
if board.eq_ignore_ascii_case("KSH")
|| board.eq_ignore_ascii_case("BJS")
|| board.eq_ignore_ascii_case("BJ")
|| board.eq_ignore_ascii_case("BJSE")
{
1
} else {
self.effective_round_lot()
}
}
@@ -47,8 +76,17 @@ impl Instrument {
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.delisted_at.is_none())
&& !self.is_delisted_on_or_before(date)
}
pub fn dated_market_absence_reason(&self, date: NaiveDate) -> Option<&'static str> {
if self.listed_at.is_some_and(|listed| date < listed) {
Some("not_yet_listed")
} else if self.is_delisted_on_or_before(date) {
Some("delisted")
} else {
None
}
}
}
@@ -56,6 +94,69 @@ fn default_status() -> String {
"active".to_string()
}
#[cfg(test)]
mod tests {
use super::{Instrument, listed_sector_is_kcb};
#[test]
fn listing_sector_is_explicit_and_unknown_stays_unknown() {
assert_eq!(listed_sector_is_kcb("科创板"), Some(true));
assert_eq!(listed_sector_is_kcb(" star "), Some(true));
assert_eq!(listed_sector_is_kcb("主板"), Some(false));
assert_eq!(listed_sector_is_kcb("创业板"), Some(false));
assert_eq!(listed_sector_is_kcb("北证"), Some(false));
for value in ["", "-", "SH", "688001.SH", "半导体"] {
assert_eq!(listed_sector_is_kcb(value), None);
}
}
fn instrument(board: &str, round_lot: u32) -> Instrument {
Instrument {
symbol: "000001.SZ".to_string(),
name: "test".to_string(),
board: board.to_string(),
round_lot,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}
}
#[test]
fn lifecycle_is_dated_and_latest_undated_terminal_status_is_not_historical_evidence() {
let mut item = instrument("BJS", 100);
let listing = chrono::NaiveDate::from_ymd_opt(2026, 8, 5).unwrap();
let removal = chrono::NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
item.listed_at = Some(listing);
item.delisted_at = Some(removal);
assert_eq!(item.dated_market_absence_reason(listing.pred_opt().unwrap()), Some("not_yet_listed"));
assert!(item.is_active_on(listing));
assert!(!item.is_active_on(removal));
assert_eq!(item.dated_market_absence_reason(removal), Some("delisted"));
item.delisted_at = None;
for status in ["delisting", "delisted", "inactive", "terminated"] {
item.status = status.into();
assert!(item.is_active_on(listing));
assert_eq!(item.dated_market_absence_reason(listing), None);
}
}
#[test]
fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() {
let kcb = instrument(" kSh ", 100);
assert_eq!(kcb.minimum_order_quantity(), 200);
assert_eq!(kcb.order_step_size(), 1);
let bjse = instrument("bjse", 100);
assert_eq!(bjse.minimum_order_quantity(), 100);
assert_eq!(bjse.order_step_size(), 1);
let main_board = instrument("SZSE", 50);
assert_eq!(main_board.minimum_order_quantity(), 50);
assert_eq!(main_board.order_step_size(), 50);
}
}
mod optional_date_format {
use chrono::NaiveDate;
use serde::{self, Deserialize, Deserializer, Serializer};
+31 -11
View File
@@ -2,6 +2,15 @@ pub mod broker;
pub mod calendar;
pub mod cost;
pub mod data;
pub mod daily_patterns;
pub mod pattern_context;
pub mod session_events;
pub mod factor_events;
pub mod execution_capacity;
mod execution_schedule;
mod factor_event_catalog;
pub mod factor_cross_section;
pub mod market_event_context;
pub mod engine;
pub mod event_bus;
pub mod events;
@@ -14,10 +23,18 @@ pub mod platform_expr_strategy;
pub mod platform_runtime_schema;
pub mod platform_strategy_spec;
pub mod portfolio;
pub mod portfolio_loss;
pub mod risk_control;
pub mod rules;
pub mod scheduler;
pub mod strategy;
pub mod holding_policy;
pub mod stock_pool_candidates;
pub mod stock_pool_indicators;
pub mod stock_pool_execution;
pub mod stock_pool_index_policy;
pub mod stock_pool_state;
pub mod signal_contract;
pub mod strategy_ai;
pub mod universe;
@@ -31,15 +48,15 @@ pub use data::{
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote,
IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar, PriceField, SecuritiesMarginRecord,
SplitRecord, YieldCurvePoint,
IntradayMarketSnapshotOverlay, IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar,
PriceField, SecuritiesMarginRecord, SplitRecord, YieldCurvePoint,
};
pub use engine::{
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
ProcessEventRetention,
ProcessEventRetention, backtest_execution_dates,
};
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
pub use events::{
@@ -56,13 +73,15 @@ pub use futures::{
FuturesTradingParameter, FuturesTransactionCostModel,
};
pub use instrument::Instrument;
pub use metrics::{BacktestMetrics, compute_backtest_metrics};
pub use metrics::{
BacktestMetrics, RiskFreeRateContract, RiskFreeRateObservation, compute_backtest_metrics,
};
pub use platform_expr_strategy::{
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
PlatformUniverseActionKind,
PlatformPortfolioDrawdownControlConfig, PlatformPositionTargetRule, PlatformRebalanceSchedule,
PlatformScheduleFrequency, PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode,
PlatformTradeAction, PlatformUniverseActionKind,
};
pub use platform_runtime_schema::{
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
@@ -75,12 +94,13 @@ pub use platform_strategy_spec::{
StrategyExpressionActionConfig, StrategyExpressionAllocationConfig,
StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig,
StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig,
StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig,
StrategyRebalanceSpec, StrategyRiskPolicySpec, StrategyRuntimeEnvironment,
StrategyRuntimeExpressions, StrategyRuntimeSpec, StrategyUniverseSpec,
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 portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossDecision, PortfolioLossError, PortfolioLossState};
pub use risk_control::{
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
StaticRiskRuleConfig, TradingConstraintConfig,
@@ -0,0 +1,257 @@
//! Complete published daily cross sections, independent of trading candidates and accounts.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const CONTRACT: &str = "fidc_market_event_context_v1";
pub fn implementation_sha256() -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(include_bytes!("market_event_context.rs")))
}
pub const COMMON_FIELDS: &[&str] = &[
"market_breadth", "market_return", "market_limit_up_count", "market_limit_down_count",
"market_limit_up_rate", "market_broken_limit_rate", "market_high_board", "market_profit_effect",
];
pub const INDUSTRY_FIELDS: &[&str] = &[
"industry_close", "industry_return_20", "industry_breadth", "industry_rank", "industry_size",
];
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Observation {
pub symbol: String,
pub industry: Option<String>,
pub close: Option<f64>,
pub high: Option<f64>,
pub previous_close: Option<f64>,
pub upper_limit: Option<f64>,
pub lower_limit: Option<f64>,
pub no_limit: Option<bool>,
pub paused: Option<bool>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Day {
pub date: NaiveDate,
pub universe: Vec<String>,
pub rows: Vec<Observation>,
}
#[derive(Default, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct State {
pub last_date: Option<NaiveDate>,
pub streaks: BTreeMap<String, Option<u32>>,
pub limit_ups: BTreeSet<String>,
pub industry_history: BTreeMap<String, Vec<f64>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub days: Vec<Day>,
#[serde(default)]
pub previous: State,
}
#[derive(Serialize)]
pub struct OutputDay {
pub date: NaiveDate,
pub common: BTreeMap<String, Option<f64>>,
pub industries: BTreeMap<String, BTreeMap<String, Option<f64>>>,
pub members: BTreeMap<String, Option<String>>,
pub securities: usize,
pub active: usize,
pub paused: usize,
pub no_limit: usize,
pub profit_effect_members: Vec<String>,
pub profit_effect_missing: Vec<String>,
pub industry_missing: Vec<String>,
}
#[derive(Serialize)]
pub struct Output {
pub contract: &'static str,
pub days: Vec<OutputDay>,
pub state: State,
}
fn positive(value: Option<f64>, symbol: &str, field: &str) -> Result<f64, String> {
value.filter(|v| v.is_finite() && *v > 0.0)
.ok_or_else(|| format!("market_event_input_invalid: {symbol} {field}"))
}
fn average(values: impl Iterator<Item = f64>, n: usize) -> f64 {
values.map(|v| v / n as f64).sum()
}
pub fn aggregate(request: Request) -> Result<Output, String> {
let mut state = request.previous;
if request.days.is_empty() || request.days.len() > 30
|| request.days.iter().map(|d| d.rows.len()).sum::<usize>() > 60_000
|| state.streaks.len() > 20_000 || state.limit_ups.len() > 20_000
|| state.industry_history.len() > 2000
|| state.industry_history.values().any(|v| v.is_empty() || v.len() > 21
|| v.iter().any(|x| !x.is_finite() || *x <= 0.0))
|| state.last_date.is_none() && (!state.streaks.is_empty() || !state.limit_ups.is_empty() || !state.industry_history.is_empty()) {
return Err("market_event_history_budget_or_state_invalid".into());
}
let mut output = Vec::new();
for day in request.days {
if state.last_date.is_some_and(|d| d >= day.date)
|| day.universe.is_empty() || day.universe.len() > 20_000
|| day.universe.iter().collect::<BTreeSet<_>>().len() != day.universe.len()
|| day.rows.len() != day.universe.len()
|| day.rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != day.universe.iter().collect::<BTreeSet<_>>() {
return Err(format!("market_event_incomplete_cross_section: {}", day.date));
}
let mut returns = BTreeMap::new();
let mut groups: BTreeMap<String, Vec<f64>> = BTreeMap::new();
let mut members = BTreeMap::new();
let mut streaks = BTreeMap::new();
let mut ups = BTreeSet::new();
let mut downs = 0; let mut touched = 0; let mut broken = 0; let mut paused = 0; let mut unlimited = 0;
for row in &day.rows {
let industry = row.industry.clone().filter(|s| !s.trim().is_empty());
members.insert(row.symbol.clone(), industry.clone());
match row.paused {
Some(true) => {
paused += 1;
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten());
continue;
},
Some(false) => {},
None => return Err(format!("market_event_pause_state_missing: {} {}", day.date, row.symbol)),
}
let c = positive(row.close, &row.symbol, "close")?;
let h = positive(row.high, &row.symbol, "high")?;
let p = positive(row.previous_close, &row.symbol, "previous_close")?;
if h + 1e-8 < c { return Err(format!("market_event_high_below_close: {}", row.symbol)); }
let change = c / p - 1.0;
returns.insert(row.symbol.clone(), change);
if let Some(industry) = industry { groups.entry(industry).or_default().push(change); }
let is_up = match row.no_limit {
Some(true) => { unlimited += 1; false },
Some(false) => {
let upper = positive(row.upper_limit, &row.symbol, "upper_limit")?;
let lower = positive(row.lower_limit, &row.symbol, "lower_limit")?;
if lower >= upper || c > upper + 1e-8 || c < lower - 1e-8 {
return Err(format!("market_event_limit_bounds_invalid: {} {}", day.date, row.symbol));
}
let at_up = (c - upper).abs() <= 1e-8;
if (c - lower).abs() <= 1e-8 { downs += 1; }
if h >= upper - 1e-8 { touched += 1; if !at_up { broken += 1; } }
at_up
},
None => return Err(format!("market_event_limit_policy_missing: {}", row.symbol)),
};
if is_up {
ups.insert(row.symbol.clone());
// The first observed limit-up may already be a continuing streak.
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten().map(|v| v + 1));
} else { streaks.insert(row.symbol.clone(), Some(0)); }
}
let active = returns.len();
if active == 0 { return Err(format!("market_event_no_active_market: {}", day.date)); }
let previous_ups = state.limit_ups.iter().cloned().collect::<Vec<_>>();
let profit_missing = previous_ups.iter().filter(|s| !returns.contains_key(*s)).cloned().collect::<Vec<_>>();
let profit = if previous_ups.is_empty() || !profit_missing.is_empty() { None }
else { Some(average(previous_ups.iter().map(|s| returns[s]), previous_ups.len())) };
let board = if ups.iter().any(|s| streaks[s].is_none()) { None }
else { Some(ups.iter().map(|s| streaks[s].unwrap()).max().unwrap_or(0) as f64) };
let common = BTreeMap::from([
("market_breadth".into(), Some(returns.values().filter(|v| **v > 0.0).count() as f64 / active as f64)),
("market_return".into(), Some(average(returns.values().copied(), active))),
("market_limit_up_count".into(), Some(ups.len() as f64)),
("market_limit_down_count".into(), Some(downs as f64)),
("market_limit_up_rate".into(), (touched > 0).then(|| ups.len() as f64 / touched as f64)),
("market_broken_limit_rate".into(), (touched > 0).then(|| broken as f64 / touched as f64)),
("market_high_board".into(), board),
("market_profit_effect".into(), profit),
]);
let mut industries = BTreeMap::new();
// A disappeared group breaks its continuous history; no stale NAV is carried forward.
state.industry_history.retain(|key, _| groups.contains_key(key));
for (industry, values) in groups {
let history = state.industry_history.entry(industry.clone()).or_default();
let nav = history.last().copied().unwrap_or(1.0) * (1.0 + average(values.iter().copied(), values.len()));
history.push(nav);
if history.len() > 21 { history.remove(0); }
let momentum = (history.len() == 21).then(|| nav / history[0] - 1.0);
industries.insert(industry, BTreeMap::from([
("industry_close".into(), Some(nav)), ("industry_return_20".into(), momentum),
("industry_breadth".into(), Some(values.iter().filter(|v| **v > 0.0).count() as f64 / values.len() as f64)),
]));
}
let universe = industries.keys().cloned().collect::<Vec<_>>();
let known = industries.values().all(|g| g["industry_return_20"].is_some());
let ranks = if known && !universe.is_empty() {
crate::factor_cross_section::evaluate("RANK", &universe, &industries.iter().map(|(s,g)|
crate::factor_cross_section::Observation {symbol:s.clone(), value:g["industry_return_20"].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>(),0.0)?
.into_iter().map(|r|(r.symbol,r.value)).collect::<BTreeMap<_,_>>()
} else { BTreeMap::new() };
for (name, fields) in &mut industries {
fields.insert("industry_rank".into(), ranks.get(name).copied());
fields.insert("industry_size".into(), Some(universe.len() as f64));
}
let industry_missing=members.iter().filter(|(_,group)|group.is_none()).map(|(s,_)|s.clone()).collect::<Vec<_>>();
if !industry_missing.is_empty() {
// An unclassified member may belong to any group; never silently shrink a group.
state.industry_history.clear();
for fields in industries.values_mut() { for value in fields.values_mut() { *value=None; } }
}
output.push(OutputDay { date:day.date, common, industries, members, securities:day.rows.len(), active, paused,
no_limit:unlimited, profit_effect_members:previous_ups, profit_effect_missing:profit_missing, industry_missing });
state.last_date = Some(day.date); state.streaks = streaks; state.limit_ups = ups;
}
Ok(Output {contract:CONTRACT, days:output, state})
}
#[cfg(test)]
mod tests {
use super::*;
fn day(n: u32, up: bool) -> Day {
Day {date:NaiveDate::from_ymd_opt(2026,9,n).unwrap(), universe:vec!["A".into(),"B".into()], rows:vec![
Observation{symbol:"A".into(),industry:Some("I".into()),close:Some(if up {11.0}else{10.0}),high:Some(11.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)},
Observation{symbol:"B".into(),industry:Some("J".into()),close:Some(9.0),high:Some(10.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)}]}
}
#[test]
fn formulas_use_real_limits_and_full_denominators() {
let r=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
let d=&r.days[1];
assert_eq!(d.common["market_breadth"],Some(0.5));
assert_eq!(d.common["market_limit_down_count"],Some(1.0));
assert_eq!(d.common["market_limit_up_rate"],Some(1.0));
assert_eq!(r.days[0].common["market_limit_up_rate"],Some(0.0));
assert_eq!(r.days[0].common["market_broken_limit_rate"],Some(1.0));
assert_eq!(r.days[2].common["market_high_board"],Some(2.0));
assert!((r.days[2].common["market_profit_effect"].unwrap()-0.1).abs()<1e-12);
assert_eq!(r.days[0].common["market_profit_effect"],None);
}
#[test]
fn missing_duplicate_and_unproven_limit_states_fail() {
let mut d=day(1,true);d.rows.pop();assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
let mut d=day(1,true);d.rows[0].upper_limit=None;assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
let mut d=day(1,true);d.rows[0].no_limit=Some(true);d.rows[0].upper_limit=None;
assert_eq!(aggregate(Request{days:vec![d],previous:State::default()}).unwrap().days[0].no_limit,1);
}
#[test]
fn chunking_and_future_append_preserve_history() {
let first=aggregate(Request{days:vec![day(1,false),day(2,true)],previous:State::default()}).unwrap();
let next=aggregate(Request{days:vec![day(3,true)],previous:first.state}).unwrap();
let full=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
assert_eq!(serde_json::to_value(&first.days).unwrap(),serde_json::to_value(&full.days[..2]).unwrap());
assert_eq!(serde_json::to_value(&next.days).unwrap(),serde_json::to_value(&full.days[2..]).unwrap());
let unknown=aggregate(Request{days:vec![day(1,true)],previous:State::default()}).unwrap();
assert_eq!(unknown.days[0].common["market_high_board"],None);
}
#[test]
fn missing_industry_does_not_invent_groups_or_disable_independent_market_facts() {
let mut missing=day(2,true);missing.rows[0].industry=None;
let r=aggregate(Request{days:vec![day(1,false),missing,day(3,true)],previous:State::default()}).unwrap();
assert_eq!(r.days[1].common["market_breadth"],Some(0.5));
assert_eq!(r.days[1].industry_missing,vec!["A"]);
assert!(r.days[1].industries.values().flat_map(|g|g.values()).all(Option::is_none));
assert_eq!(r.days[2].industries["I"]["industry_return_20"],None);
}
}
+328 -49
View File
@@ -9,7 +9,28 @@ use crate::portfolio::HoldingSummary;
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
const MONTHS_PER_YEAR: f64 = 12.0;
const DEFAULT_RISK_FREE_RATE: f64 = 0.022;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RiskFreeRateObservation {
pub date: NaiveDate,
pub source_date: NaiveDate,
pub annual_rate: f64,
pub daily_rate: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RiskFreeRateContract {
pub version: String,
pub source: String,
pub tenor: String,
pub periods_per_year: f64,
pub max_staleness_days: usize,
pub observed_max_staleness_days: usize,
pub sha256: String,
pub observations: Vec<RiskFreeRateObservation>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BacktestMetrics {
@@ -29,6 +50,7 @@ pub struct BacktestMetrics {
pub max_drawdown_duration_days: usize,
pub total_trade_days: usize,
pub sortino: f64,
pub downside_risk: f64,
pub information_ratio: f64,
pub tracking_error: f64,
pub volatility: f64,
@@ -55,6 +77,13 @@ pub struct BacktestMetrics {
pub excess_win_rate: f64,
pub monthly_sharpe: f64,
pub monthly_volatility: f64,
pub risk_free_rate_contract_version: String,
pub risk_free_rate_source: String,
pub risk_free_rate_tenor: String,
pub risk_free_rate_observation_count: usize,
pub risk_free_rate_max_staleness_days: usize,
pub risk_free_rate_observed_max_staleness_days: usize,
pub risk_free_rate_sha256: String,
}
pub fn compute_backtest_metrics(
@@ -63,30 +92,23 @@ pub fn compute_backtest_metrics(
daily_holdings: &[HoldingSummary],
account_events: &[AccountEvent],
initial_cash: f64,
) -> BacktestMetrics {
risk_free_contract: Option<&RiskFreeRateContract>,
) -> Result<BacktestMetrics, String> {
let Some(first_point) = equity_curve.first() else {
return BacktestMetrics {
risk_free_rate: DEFAULT_RISK_FREE_RATE,
return Ok(BacktestMetrics {
initial_cash,
..BacktestMetrics::default()
};
});
};
let Some(last_point) = equity_curve.last() else {
return BacktestMetrics {
risk_free_rate: DEFAULT_RISK_FREE_RATE,
return Ok(BacktestMetrics {
initial_cash,
..BacktestMetrics::default()
};
});
};
let trade_days = equity_curve.len();
let benchmark_start = if first_point.benchmark_prev_close.is_finite()
&& first_point.benchmark_prev_close > f64::EPSILON
{
first_point.benchmark_prev_close
} else {
first_point.benchmark_close
};
let benchmark_start = first_point.benchmark_reference_close();
let explicit_unit_nav = equity_curve.iter().any(|point| {
point.external_cash_flow.abs() > f64::EPSILON
|| (point.unit_nav.is_finite()
@@ -122,6 +144,7 @@ pub fn compute_backtest_metrics(
.zip(benchmark_returns.iter())
.map(|(lhs, rhs)| lhs - rhs)
.collect::<Vec<_>>();
let zero_risk_free_rates = vec![0.0; excess_returns.len()];
let benchmark_net_value = if benchmark_start.abs() < f64::EPSILON {
1.0
@@ -140,16 +163,28 @@ pub fn compute_backtest_metrics(
let annual_return = annualize_return(total_return, trade_days);
let excess_annual_return = annualize_return(excess_cumulative_return, trade_days);
let risk_free_rate = DEFAULT_RISK_FREE_RATE;
let daily_rf = risk_free_rate / TRADING_DAYS_PER_YEAR;
let sharpe = annualized_sharpe(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
let sortino = annualized_sortino(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
let information_ratio = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
let (daily_risk_free_rates, risk_free_metadata) =
aligned_daily_risk_free_rates(equity_curve, risk_free_contract)?;
let risk_free_rate =
effective_annual_risk_free_rate(&daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
let sharpe = annualized_sharpe(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
let sortino = annualized_sortino(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
let downside_risk =
annualized_downside_risk(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
let information_ratio = annualized_sharpe(
&excess_returns,
&zero_risk_free_rates,
TRADING_DAYS_PER_YEAR,
);
let tracking_error = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
let volatility = annualized_std(&returns, TRADING_DAYS_PER_YEAR);
let excess_volatility = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
let excess_sharpe = annualized_sharpe(
&excess_returns,
&zero_risk_free_rates,
TRADING_DAYS_PER_YEAR,
);
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, &daily_risk_free_rates);
let equity_nav = portfolio_nav;
let benchmark_nav_series = equity_curve
@@ -178,6 +213,8 @@ pub fn compute_backtest_metrics(
.zip(monthly_benchmark_returns.iter())
.map(|(lhs, rhs)| lhs - rhs)
.collect::<Vec<_>>();
let monthly_risk_free_returns =
group_monthly_risk_free_returns(equity_curve, &daily_risk_free_rates);
let monthly_excess_win_rate = ratio(
monthly_excess_returns
.iter()
@@ -187,7 +224,7 @@ pub fn compute_backtest_metrics(
);
let monthly_sharpe = annualized_sharpe(
&monthly_portfolio_returns,
risk_free_rate / MONTHS_PER_YEAR,
&monthly_risk_free_returns,
MONTHS_PER_YEAR,
);
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
@@ -239,7 +276,7 @@ pub fn compute_backtest_metrics(
let total_trade_days = equity_by_date.len();
BacktestMetrics {
Ok(BacktestMetrics {
total_return,
annual_return,
sharpe,
@@ -256,6 +293,7 @@ pub fn compute_backtest_metrics(
max_drawdown_duration_days,
total_trade_days,
sortino,
downside_risk,
information_ratio,
tracking_error,
volatility,
@@ -285,7 +323,14 @@ pub fn compute_backtest_metrics(
excess_win_rate,
monthly_sharpe,
monthly_volatility,
}
risk_free_rate_contract_version: risk_free_metadata.version,
risk_free_rate_source: risk_free_metadata.source,
risk_free_rate_tenor: risk_free_metadata.tenor,
risk_free_rate_observation_count: daily_risk_free_rates.len(),
risk_free_rate_max_staleness_days: risk_free_metadata.max_staleness_days,
risk_free_rate_observed_max_staleness_days: risk_free_metadata.observed_max_staleness_days,
risk_free_rate_sha256: risk_free_metadata.sha256,
})
}
fn point_nav(point: &DailyEquityPoint, initial_cash: f64) -> f64 {
@@ -316,13 +361,106 @@ fn annualize_return(total_return: f64, periods: usize) -> f64 {
base.powf(TRADING_DAYS_PER_YEAR / periods) - 1.0
}
fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
if returns.len() < 2 {
fn aligned_daily_risk_free_rates(
equity_curve: &[DailyEquityPoint],
contract: Option<&RiskFreeRateContract>,
) -> Result<(Vec<f64>, RiskFreeRateContract), String> {
let Some(contract) = contract else {
return Ok((
vec![0.0; equity_curve.len()],
RiskFreeRateContract {
version: "not-configured".to_string(),
source: "not-configured".to_string(),
tenor: "NONE".to_string(),
periods_per_year: TRADING_DAYS_PER_YEAR,
..RiskFreeRateContract::default()
},
));
};
if contract.version.trim().is_empty()
|| contract.source.trim().is_empty()
|| contract.tenor.trim().is_empty()
|| contract.sha256.len() != 64
{
return Err("risk-free rate contract metadata is incomplete".to_string());
}
if contract.observations.len() != equity_curve.len() {
return Err(format!(
"risk-free rate observation count mismatch: expected={} actual={}",
equity_curve.len(),
contract.observations.len()
));
}
let mut rates = Vec::with_capacity(equity_curve.len());
for (point, observation) in equity_curve.iter().zip(&contract.observations) {
if observation.date != point.date {
return Err(format!(
"risk-free rate date mismatch: expected={} actual={}",
point.date, observation.date
));
}
if observation.source_date > observation.date {
return Err(format!(
"risk-free rate uses future observation: date={} source_date={}",
observation.date, observation.source_date
));
}
let staleness = observation
.date
.signed_duration_since(observation.source_date)
.num_days();
if staleness < 0 || staleness as usize > contract.max_staleness_days {
return Err(format!(
"risk-free rate observation is stale: date={} source_date={} days={}",
observation.date, observation.source_date, staleness
));
}
if !observation.annual_rate.is_finite()
|| observation.annual_rate <= -1.0
|| observation.annual_rate >= 1.0
|| !observation.daily_rate.is_finite()
|| observation.daily_rate <= -1.0
{
return Err(format!(
"risk-free rate observation is invalid: date={}",
observation.date
));
}
let periods_per_year =
if contract.periods_per_year.is_finite() && contract.periods_per_year > 0.0 {
contract.periods_per_year
} else {
TRADING_DAYS_PER_YEAR
};
let expected_daily = (1.0 + observation.annual_rate).powf(1.0 / periods_per_year) - 1.0;
if (expected_daily - observation.daily_rate).abs() > 1e-12 {
return Err(format!(
"risk-free daily conversion mismatch: date={} expected={} actual={}",
observation.date, expected_daily, observation.daily_rate
));
}
rates.push(observation.daily_rate);
}
Ok((rates, contract.clone()))
}
fn effective_annual_risk_free_rate(daily_rates: &[f64], periods_per_year: f64) -> f64 {
if daily_rates.is_empty() {
return 0.0;
}
let mean_log =
daily_rates.iter().map(|rate| rate.ln_1p()).sum::<f64>() / daily_rates.len() as f64;
(mean_log * periods_per_year).exp_m1()
}
fn annualized_sharpe(returns: &[f64], daily_risk_free_rates: &[f64], periods_per_year: f64) -> f64 {
if returns.len() < 2 || returns.len() != daily_risk_free_rates.len() {
return 0.0;
}
let adjusted = returns
.iter()
.map(|value| value - daily_rf)
.zip(daily_risk_free_rates)
.map(|(value, risk_free)| value - risk_free)
.collect::<Vec<_>>();
let mean_ret = mean(&adjusted);
let std = std_dev(&adjusted);
@@ -333,23 +471,24 @@ fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f
}
}
fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
if returns.is_empty() {
fn annualized_sortino(
returns: &[f64],
daily_risk_free_rates: &[f64],
periods_per_year: f64,
) -> f64 {
if returns.is_empty() || returns.len() != daily_risk_free_rates.len() {
return 0.0;
}
let adjusted = returns
.iter()
.map(|value| value - daily_rf)
.zip(daily_risk_free_rates)
.map(|(value, risk_free)| value - risk_free)
.collect::<Vec<_>>();
let downside = adjusted
.iter()
.filter(|value| **value < 0.0)
.map(|value| value.powi(2))
.collect::<Vec<_>>();
if downside.is_empty() {
return 0.0;
}
let downside_dev = (downside.iter().sum::<f64>() / downside.len() as f64).sqrt();
.map(|value| value.min(0.0).powi(2))
.sum::<f64>();
let downside_dev = (downside / adjusted.len() as f64).sqrt();
if downside_dev <= f64::EPSILON {
0.0
} else {
@@ -357,32 +496,60 @@ fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) ->
}
}
fn annualized_downside_risk(
returns: &[f64],
daily_risk_free_rates: &[f64],
periods_per_year: f64,
) -> f64 {
if returns.is_empty() || returns.len() != daily_risk_free_rates.len() {
return 0.0;
}
let downside_mean_square = returns
.iter()
.zip(daily_risk_free_rates)
.map(|(value, risk_free)| (value - risk_free).min(0.0).powi(2))
.sum::<f64>()
/ returns.len() as f64;
downside_mean_square.sqrt() * periods_per_year.sqrt()
}
fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 {
std_dev(values) * periods_per_year.sqrt()
}
fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64, f64) {
if returns.len() < 2 || returns.len() != benchmark_returns.len() {
fn alpha_beta(
returns: &[f64],
benchmark_returns: &[f64],
daily_risk_free_rates: &[f64],
) -> (f64, f64) {
if returns.len() < 2
|| returns.len() != benchmark_returns.len()
|| returns.len() != daily_risk_free_rates.len()
{
return (0.0, 0.0);
}
let strategy_excess = returns
.iter()
.map(|value| value - daily_rf)
.zip(daily_risk_free_rates)
.map(|(value, risk_free)| value - risk_free)
.collect::<Vec<_>>();
let benchmark_excess = benchmark_returns
.iter()
.map(|value| value - daily_rf)
.zip(daily_risk_free_rates)
.map(|(value, risk_free)| value - risk_free)
.collect::<Vec<_>>();
let mean_strategy = mean(&strategy_excess);
let mean_benchmark = mean(&benchmark_excess);
let variance_benchmark = variance(&benchmark_excess);
let mean_raw_strategy = mean(returns);
let mean_raw_benchmark = mean(benchmark_returns);
let variance_benchmark = variance(benchmark_returns);
if variance_benchmark <= f64::EPSILON {
return (0.0, 0.0);
}
let covariance = strategy_excess
let covariance = returns
.iter()
.zip(benchmark_excess.iter())
.map(|(lhs, rhs)| (lhs - mean_strategy) * (rhs - mean_benchmark))
.zip(benchmark_returns.iter())
.map(|(lhs, rhs)| (lhs - mean_raw_strategy) * (rhs - mean_raw_benchmark))
.sum::<f64>()
/ (strategy_excess.len() - 1) as f64;
let beta = covariance / variance_benchmark;
@@ -391,7 +558,9 @@ fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64
}
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
let mut peak = 0.0_f64;
// NAV is measured against the pre-period capital. The first real loss
// must not become a new zero-drawdown baseline.
let mut peak = 1.0_f64;
let mut max_drawdown = 0.0_f64;
let mut duration = 0_usize;
let mut max_duration = 0_usize;
@@ -522,6 +691,26 @@ where
.collect()
}
fn group_monthly_risk_free_returns(
equity_curve: &[DailyEquityPoint],
daily_risk_free_rates: &[f64],
) -> Vec<f64> {
if equity_curve.len() != daily_risk_free_rates.len() {
return Vec::new();
}
let mut monthly_growth = BTreeMap::<(i32, u32), f64>::new();
for (point, daily_rate) in equity_curve.iter().zip(daily_risk_free_rates) {
let growth = monthly_growth
.entry((point.date.year(), point.date.month()))
.or_insert(1.0);
*growth *= 1.0 + daily_rate;
}
monthly_growth
.into_values()
.map(|growth| growth - 1.0)
.collect()
}
fn mean(values: &[f64]) -> f64 {
if values.is_empty() {
0.0
@@ -580,6 +769,28 @@ fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
mod tests {
use super::*;
#[test]
fn drawdown_includes_initial_nav_without_adding_a_trading_day() {
let (drawdown, duration) = drawdown_stats(&[0.9, 0.99]);
assert!((drawdown + 0.1).abs() < 1e-12);
assert_eq!(duration, 2);
assert_eq!(drawdown_stats(&[1.0, 1.1, 1.1]), (0.0, 0));
assert_eq!(drawdown_stats(&[0.0]), (-1.0, 1));
assert_eq!(drawdown_stats(&[]), (0.0, 0));
}
#[test]
fn first_day_loss_is_preserved_in_shared_backtest_metrics() {
let curve = vec![
equity_point("2025-01-03", 99.16648349337, 98.81608059815, 100.0),
equity_point("2025-01-06", 99.68551588547, 98.65392198168, 98.81608059815),
];
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
assert!((metrics.max_drawdown + 0.0083351650663).abs() < 1e-12);
assert_eq!(metrics.total_trade_days, 2);
assert_eq!(metrics.max_drawdown_duration_days, 2);
}
fn equity_point(
date: &str,
total_equity: f64,
@@ -587,6 +798,7 @@ mod tests {
benchmark_prev_close: f64,
) -> DailyEquityPoint {
DailyEquityPoint {
signal_baseline: false,
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
cash: total_equity,
market_value: 0.0,
@@ -606,16 +818,26 @@ 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, None).unwrap();
let expected = 7595.285 / 5957.717 - 1.0;
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
}
#[test]
fn signal_baseline_uses_same_close_for_strategy_and_benchmark() {
let mut baseline=equity_point("2026-09-04",100.0,4548.0499,4552.5784);
baseline.signal_baseline=true;
let curve=vec![baseline,equity_point("2026-09-08",104.0,4558.7371,4575.0245)];
let metrics=compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap();
assert!((metrics.benchmark_cumulative_return-(4558.7371/4548.0499-1.0)).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 {
signal_baseline: false,
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
cash: 220.0,
market_value: 0.0,
@@ -635,9 +857,66 @@ mod tests {
total_equity: 200.0,
note: "deposit_withdraw amount=100.00 reason=test".to_string(),
}];
let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0);
let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0, None).unwrap();
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);
}
#[test]
fn risk_adjusted_metrics_use_daily_pit_rates_and_all_period_downside() {
let curve = vec![
equity_point("2026-01-02", 101.0, 100.0, 100.0),
equity_point("2026-01-05", 98.98, 100.0, 100.0),
equity_point("2026-01-06", 100.4647, 100.0, 100.0),
equity_point("2026-01-07", 99.9623765, 100.0, 100.0),
];
let annual_rates = [0.012, 0.012, 0.013, 0.013];
let observations = curve
.iter()
.zip(annual_rates)
.map(|(point, annual_rate)| RiskFreeRateObservation {
date: point.date,
source_date: point.date,
annual_rate,
daily_rate: (1.0 + annual_rate).powf(1.0 / TRADING_DAYS_PER_YEAR) - 1.0,
})
.collect();
let contract = RiskFreeRateContract {
version: "cn-government-bond-3m-pit-daily/v1".to_string(),
source: "test".to_string(),
tenor: "3M".to_string(),
periods_per_year: TRADING_DAYS_PER_YEAR,
max_staleness_days: 15,
observed_max_staleness_days: 0,
sha256: "a".repeat(64),
observations,
};
let metrics =
compute_backtest_metrics(&curve, &[], &[], &[], 100.0, Some(&contract)).unwrap();
let returns = [0.01, -0.02, 0.015, -0.005];
let daily_rates = annual_rates
.map(|annual_rate| (1.0 + annual_rate).powf(1.0 / TRADING_DAYS_PER_YEAR) - 1.0);
let adjusted = returns
.iter()
.zip(daily_rates)
.map(|(value, risk_free)| value - risk_free)
.collect::<Vec<_>>();
let expected_sharpe = mean(&adjusted) / std_dev(&adjusted) * TRADING_DAYS_PER_YEAR.sqrt();
let downside = (adjusted
.iter()
.map(|value| value.min(0.0).powi(2))
.sum::<f64>()
/ adjusted.len() as f64)
.sqrt();
let expected_sortino = mean(&adjusted) / downside * TRADING_DAYS_PER_YEAR.sqrt();
assert!((metrics.sharpe - expected_sharpe).abs() < 1e-12);
assert!((metrics.sortino - expected_sortino).abs() < 1e-12);
assert!((metrics.downside_risk - downside * TRADING_DAYS_PER_YEAR.sqrt()).abs() < 1e-12);
assert_eq!(metrics.risk_free_rate_source, "test");
assert_eq!(metrics.risk_free_rate_tenor, "3M");
assert_eq!(metrics.risk_free_rate_observation_count, 4);
assert_ne!(metrics.risk_free_rate, 0.022);
}
}
+291 -45
View File
@@ -11,6 +11,7 @@ pub(crate) enum ValueType {
pub(crate) enum Value {
Number(f64),
Boolean(bool),
Missing(ValueType),
}
impl Value {
@@ -18,20 +19,28 @@ impl Value {
match self {
Self::Number(_) => ValueType::Number,
Self::Boolean(_) => ValueType::Boolean,
Self::Missing(value_type) => value_type,
}
}
pub(crate) fn as_number(self) -> Option<f64> {
match self {
Self::Number(value) => Some(value),
Self::Boolean(_) => None,
Self::Boolean(_) | Self::Missing(_) => None,
}
}
pub(crate) fn as_bool(self) -> Option<bool> {
match self {
Self::Boolean(value) => Some(value),
Self::Number(_) => None,
Self::Number(_) | Self::Missing(_) => None,
}
}
fn normalized(self) -> Self {
match self {
Self::Number(value) if !value.is_finite() => Self::Missing(ValueType::Number),
value => value,
}
}
}
@@ -103,6 +112,8 @@ enum BinaryOp {
LessEqual,
Greater,
GreaterEqual,
And,
Or,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -134,7 +145,7 @@ enum Instruction {
Binary(BinaryOp),
Call { builtin: Builtin, argc: u8 },
JumpIfFalse(usize),
JumpIfTrue(usize),
ShortCircuit { on: bool, target: usize },
Jump(usize),
Return,
}
@@ -165,7 +176,7 @@ impl Program {
let mut pc = 0usize;
while let Some(instruction) = self.instructions.get(pc) {
match *instruction {
Instruction::Push(value) => scratch.stack.push(value),
Instruction::Push(value) => scratch.stack.push(value.normalized()),
Instruction::LoadVariable(index) => {
let index = usize::from(index);
let cached = scratch.variables[index];
@@ -173,7 +184,8 @@ impl Program {
Some(value) => value,
None => {
let expected_type = self.variable_types[index];
let value = resolve(index, &self.variables[index], expected_type)?;
let value =
resolve(index, &self.variables[index], expected_type)?.normalized();
if value.value_type() != expected_type {
return Err(EvalError::new(format!(
"variable {} expected {:?}, got {:?}",
@@ -219,15 +231,23 @@ impl Program {
scratch.stack.push(value);
}
Instruction::JumpIfFalse(target) => {
let condition = pop_bool(&mut scratch.stack)?;
// Like CASE WHEN, only a known true condition takes this branch.
let condition = match pop(&mut scratch.stack)? {
Value::Boolean(value) => value,
Value::Missing(ValueType::Boolean) => false,
_ => return Err(EvalError::new("boolean operand required")),
};
if !condition {
pc = target;
continue;
}
}
Instruction::JumpIfTrue(target) => {
let condition = pop_bool(&mut scratch.stack)?;
if condition {
Instruction::ShortCircuit { on, target } => {
let condition =
scratch.stack.last().copied().ok_or_else(|| {
EvalError::new("stack underflow during short circuit")
})?;
if condition.as_bool() == Some(on) {
pc = target;
continue;
}
@@ -284,43 +304,140 @@ fn pop(stack: &mut Vec<Value>) -> Result<Value, EvalError> {
stack.pop().ok_or_else(|| EvalError::new("stack underflow"))
}
fn pop_bool(stack: &mut Vec<Value>) -> Result<bool, EvalError> {
pop(stack)?
.as_bool()
.ok_or_else(|| EvalError::new("boolean operand required"))
}
fn number(value: Value) -> Result<f64, EvalError> {
if value == Value::Missing(ValueType::Number) {
return Ok(f64::NAN);
}
value
.as_number()
.ok_or_else(|| EvalError::new("numeric operand required"))
}
fn eval_unary(operator: UnaryOp, value: Value) -> Result<Value, EvalError> {
match operator {
if matches!(value, Value::Missing(_)) {
return Ok(value);
}
let result: Result<Value, EvalError> = match operator {
UnaryOp::Negate => Ok(Value::Number(-number(value)?)),
UnaryOp::Not => {
Ok(Value::Boolean(!value.as_bool().ok_or_else(|| {
EvalError::new("boolean operand required for !")
})?))
}
}
};
Ok(result?.normalized())
}
fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, EvalError> {
match operator {
if matches!(operator, BinaryOp::And | BinaryOp::Or) {
let (lhs, rhs) = (lhs.as_bool(), rhs.as_bool());
let result = match operator {
BinaryOp::And if lhs == Some(false) || rhs == Some(false) => Some(false),
BinaryOp::And if lhs == Some(true) && rhs == Some(true) => Some(true),
BinaryOp::Or if lhs == Some(true) || rhs == Some(true) => Some(true),
BinaryOp::Or if lhs == Some(false) && rhs == Some(false) => Some(false),
_ => None,
};
return Ok(result
.map(Value::Boolean)
.unwrap_or(Value::Missing(ValueType::Boolean)));
}
if matches!(lhs, Value::Missing(_)) || matches!(rhs, Value::Missing(_)) {
let value_type = match operator {
BinaryOp::Add
| BinaryOp::Subtract
| BinaryOp::Multiply
| BinaryOp::Divide
| BinaryOp::Remainder => ValueType::Number,
_ => ValueType::Boolean,
};
return Ok(Value::Missing(value_type));
}
let result: Result<Value, EvalError> = match operator {
BinaryOp::Add => Ok(Value::Number(number(lhs)? + number(rhs)?)),
BinaryOp::Subtract => Ok(Value::Number(number(lhs)? - number(rhs)?)),
BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)),
BinaryOp::Divide => Ok(Value::Number(number(lhs)? / number(rhs)?)),
BinaryOp::Remainder => Ok(Value::Number(number(lhs)? % number(rhs)?)),
BinaryOp::Equal => Ok(Value::Boolean(lhs == rhs)),
BinaryOp::NotEqual => Ok(Value::Boolean(lhs != rhs)),
BinaryOp::Less => Ok(Value::Boolean(number(lhs)? < number(rhs)?)),
BinaryOp::LessEqual => Ok(Value::Boolean(number(lhs)? <= number(rhs)?)),
BinaryOp::Greater => Ok(Value::Boolean(number(lhs)? > number(rhs)?)),
BinaryOp::GreaterEqual => Ok(Value::Boolean(number(lhs)? >= number(rhs)?)),
BinaryOp::Equal => Ok(Value::Boolean(match (lhs, rhs) {
(Value::Number(lhs), Value::Number(rhs)) => float_equal(lhs, rhs),
(Value::Boolean(lhs), Value::Boolean(rhs)) => lhs == rhs,
_ => {
return Err(EvalError::new(
"comparison operands must have the same type",
));
}
})),
BinaryOp::NotEqual => Ok(Value::Boolean(match (lhs, rhs) {
(Value::Number(lhs), Value::Number(rhs)) => float_not_equal(lhs, rhs),
(Value::Boolean(lhs), Value::Boolean(rhs)) => lhs != rhs,
_ => {
return Err(EvalError::new(
"comparison operands must have the same type",
));
}
})),
BinaryOp::Less => {
let (lhs, rhs) = (number(lhs)?, number(rhs)?);
Ok(Value::Boolean(
(rhs - lhs) / float_comparison_scale(lhs, rhs) > f64::EPSILON,
))
}
BinaryOp::LessEqual => {
let (lhs, rhs) = (number(lhs)?, number(rhs)?);
Ok(Value::Boolean(
(rhs - lhs) / float_comparison_scale(lhs, rhs) > -f64::EPSILON,
))
}
BinaryOp::Greater => {
let (lhs, rhs) = (number(lhs)?, number(rhs)?);
Ok(Value::Boolean(
(lhs - rhs) / float_comparison_scale(lhs, rhs) > f64::EPSILON,
))
}
BinaryOp::GreaterEqual => {
let (lhs, rhs) = (number(lhs)?, number(rhs)?);
Ok(Value::Boolean(
(lhs - rhs) / float_comparison_scale(lhs, rhs) > -f64::EPSILON,
))
}
BinaryOp::And | BinaryOp::Or => unreachable!(),
};
Ok(result?.normalized())
}
pub(crate) fn finite_comparison(operator: &str, lhs: f64, rhs: f64) -> Option<bool> {
if !lhs.is_finite() || !rhs.is_finite() {
return None;
}
let operator = match operator {
"==" => BinaryOp::Equal,
"!=" => BinaryOp::NotEqual,
"<" => BinaryOp::Less,
"<=" => BinaryOp::LessEqual,
">" => BinaryOp::Greater,
">=" => BinaryOp::GreaterEqual,
_ => return None,
};
eval_binary(operator, Value::Number(lhs), Value::Number(rhs))
.ok()?
.as_bool()
}
fn float_comparison_scale(lhs: f64, rhs: f64) -> f64 {
if lhs * rhs == 0.0 {
1.0
} else {
lhs.abs().max(rhs.abs())
}
}
fn float_equal(lhs: f64, rhs: f64) -> bool {
(lhs - rhs).abs() / float_comparison_scale(lhs, rhs) <= f64::EPSILON
}
fn float_not_equal(lhs: f64, rhs: f64) -> bool {
(lhs - rhs).abs() / float_comparison_scale(lhs, rhs) > f64::EPSILON
}
fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
@@ -330,7 +447,16 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
.ok_or_else(|| EvalError::new("missing builtin argument"))
.and_then(number)
};
Ok(match builtin {
if !matches!(builtin, Builtin::Nz | Builtin::SafeDiv | Builtin::Iff)
&& args.iter().any(|value| matches!(value, Value::Missing(_)))
{
return Ok(Value::Missing(if builtin == Builtin::Between {
ValueType::Boolean
} else {
ValueType::Number
}));
}
let result = match builtin {
Builtin::Round => Value::Number(numeric(0)?.round()),
Builtin::Floor => Value::Number(numeric(0)?.floor()),
Builtin::Ceil => Value::Number(numeric(0)?.ceil()),
@@ -341,7 +467,13 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)),
Builtin::Log => Value::Number(numeric(0)?.ln()),
Builtin::Exp => Value::Number(numeric(0)?.exp()),
Builtin::Clamp => Value::Number(numeric(0)?.clamp(numeric(1)?, numeric(2)?)),
Builtin::Clamp => {
let (value, low, high) = (numeric(0)?, numeric(1)?, numeric(2)?);
if low > high {
return Err(EvalError::new("clamp lower bound exceeds upper bound"));
}
Value::Number(value.clamp(low, high))
}
Builtin::Between => {
let value = numeric(0)?;
Value::Boolean(value >= numeric(1)? && value <= numeric(2)?)
@@ -365,13 +497,15 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
})
}
Builtin::Iff => {
let condition = args
.first()
.and_then(|value| value.as_bool())
.ok_or_else(|| EvalError::new("iff condition must be boolean"))?;
let condition = match args.first().copied() {
Some(Value::Boolean(value)) => value,
Some(Value::Missing(ValueType::Boolean)) => false,
_ => return Err(EvalError::new("iff condition must be boolean")),
};
if condition { args[1] } else { args[2] }
}
})
};
Ok(result.normalized())
}
#[derive(Debug, Clone, PartialEq)]
@@ -1067,24 +1201,20 @@ where
let lhs_type = self.expression(lhs)?;
require_type(lhs_type, ValueType::Boolean, position)?;
let branch = self.instructions.len();
self.instructions.push(match operator {
ParsedBinaryOp::And => Instruction::JumpIfFalse(usize::MAX),
ParsedBinaryOp::Or => Instruction::JumpIfTrue(usize::MAX),
_ => unreachable!(),
self.instructions.push(Instruction::ShortCircuit {
on: operator == ParsedBinaryOp::Or,
target: usize::MAX,
});
let rhs_type = self.expression(rhs)?;
require_type(rhs_type, ValueType::Boolean, rhs.position())?;
let end_jump = self.instructions.len();
self.instructions.push(Instruction::Jump(usize::MAX));
let short_target = self.instructions.len();
self.instructions
.push(Instruction::Push(Value::Boolean(matches!(
operator,
ParsedBinaryOp::Or
))));
.push(Instruction::Binary(if operator == ParsedBinaryOp::And {
BinaryOp::And
} else {
BinaryOp::Or
}));
let end_target = self.instructions.len();
patch_jump(&mut self.instructions, branch, short_target)?;
patch_jump(&mut self.instructions, end_jump, end_target)?;
patch_jump(&mut self.instructions, branch, end_target)?;
return Ok(ValueType::Boolean);
}
@@ -1233,7 +1363,7 @@ fn patch_jump(
};
match instruction {
Instruction::JumpIfFalse(value)
| Instruction::JumpIfTrue(value)
| Instruction::ShortCircuit { target: value, .. }
| Instruction::Jump(value) => {
*value = target;
Ok(())
@@ -1313,6 +1443,122 @@ mod tests {
);
}
#[test]
fn floating_comparisons_match_rhai_epsilon_semantics() {
let adjacent = 11.699999999999998_f64;
assert_eq!(
evaluate("value == 11.7", &[("value", Value::Number(adjacent))]),
Value::Boolean(true)
);
assert_eq!(
evaluate("value != 11.7", &[("value", Value::Number(adjacent))]),
Value::Boolean(false)
);
assert_eq!(
evaluate("value <= 11.7", &[("value", Value::Number(adjacent))]),
Value::Boolean(true)
);
assert_eq!(
evaluate("value >= 11.7", &[("value", Value::Number(adjacent))]),
Value::Boolean(true)
);
assert_eq!(
evaluate("value < 11.7", &[("value", Value::Number(adjacent))]),
Value::Boolean(false)
);
assert_eq!(
evaluate("value > 11.7", &[("value", Value::Number(adjacent))]),
Value::Boolean(false)
);
}
#[test]
fn nullable_boolean_truth_table_preserves_unknown_under_negation() {
let unknown = Value::Missing(ValueType::Boolean);
let states = [Value::Boolean(false), Value::Boolean(true), unknown];
let and = [
[states[0], states[0], states[0]],
[states[0], states[1], unknown],
[states[0], unknown, unknown],
];
let or = [
[states[0], states[1], unknown],
[states[1], states[1], states[1]],
[unknown, states[1], unknown],
];
for (i, lhs) in states.iter().enumerate() {
for (j, rhs) in states.iter().enumerate() {
let values = [("lhs", *lhs), ("rhs", *rhs)];
assert_eq!(evaluate("lhs && rhs", &values), and[i][j]);
assert_eq!(evaluate("lhs || rhs", &values), or[i][j]);
assert_eq!(evaluate("!!(lhs && rhs)", &values), and[i][j]);
assert_eq!(evaluate("!!(lhs || rhs)", &values), or[i][j]);
}
}
assert_eq!(evaluate("!value", &[("value", unknown)]), unknown);
}
#[test]
fn missing_numeric_operands_do_not_become_boolean_false_or_zero() {
let unknown = Value::Missing(ValueType::Boolean);
for missing in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
for operator in ["==", "!=", "<", "<=", ">", ">="] {
let values = [("value", Value::Number(missing))];
assert_eq!(evaluate(&format!("value {operator} 0.0"), &values), unknown);
assert_eq!(
evaluate(&format!("!(0.0 {operator} value)"), &values),
unknown
);
assert_eq!(
evaluate(&format!("!((value + 1.0) {operator} 0.0)"), &values),
unknown
);
}
}
let missing = [("value", Value::Number(f64::NAN))];
assert_eq!(evaluate("!(min(value, 1.0) > 0.0)", &missing), unknown);
assert_eq!(evaluate("!between(value, 0.0, 1.0)", &missing), unknown);
assert_eq!(evaluate("!(1.0 / 0.0 > 0.0)", &[]), unknown);
assert_eq!(evaluate("!(sqrt(-1.0) > 0.0)", &[]), unknown);
assert_eq!(evaluate("nz(value, 7.0)", &missing), Value::Number(7.0));
assert_eq!(
evaluate("nz(value, 0.0) == 0.0", &missing),
Value::Boolean(true)
);
}
#[test]
fn nullable_short_circuit_and_explicit_conditional_branches_are_lazy() {
for source in [
"false && missing",
"true || missing",
"if true { true } else { missing }",
] {
let program = compile(source, |_| Some(ValueType::Boolean)).unwrap();
program
.evaluate(&mut Scratch::default(), |_, _, _| {
Err(EvalError::new("unused input must not be resolved"))
})
.unwrap();
}
let unknown = Value::Missing(ValueType::Boolean);
assert_eq!(
evaluate("if value { 1.0 } else { 2.0 }", &[("value", unknown)]),
Value::Number(2.0)
);
assert_eq!(
evaluate("iff(value, 1.0, 2.0)", &[("value", unknown)]),
Value::Number(2.0)
);
}
#[test]
fn invalid_clamp_returns_error_without_panicking() {
let program = compile("clamp(1.0, 2.0, 0.0)", |_| None).unwrap();
let result = program.evaluate(&mut Scratch::default(), |_, _, _| unreachable!());
assert!(result.unwrap_err().to_string().contains("lower bound"));
}
#[test]
fn short_circuit_does_not_resolve_unused_variable() {
let program = compile("false && missing", |name| {
+427
View File
@@ -0,0 +1,427 @@
//! Explicit reference identities and frozen rank universes shared by all daily runtimes.
use crate::{
daily_patterns::{dataset_series, evaluate_with_context, PatternSpec, ResearchContext},
factor_events::{field_dependencies, Expr},
DataSet,
};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
pub const CONTRACT: &str = "fidc_pattern_execution_context_v1";
pub const CONTEXT_FIELDS: &[&str] = &[
"index_open",
"index_high",
"index_low",
"index_close",
"scope_rank",
"scope_percentile",
"scope_size",
];
const STOCK_FIELDS: &[&str] = &[
"open",
"high",
"low",
"close",
"volume",
"raw_open",
"raw_high",
"raw_low",
"raw_close",
"prev_close",
"amount",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecutionContext {
pub contract: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub benchmark: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rank_expression: Option<Expr>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rank_universe: Vec<String>,
}
fn valid_symbol(s: &str) -> bool {
let Some((code, market)) = s.split_once('.') else {
return false;
};
code.len() == 6
&& code.bytes().all(|c| c.is_ascii_digit())
&& matches!(market, "SH" | "SZ" | "BJ" | "CSI")
}
impl ExecutionContext {
pub fn fields(&self, expression: &Expr) -> BTreeSet<String> {
let mut fields = field_dependencies(expression);
if let Some(rank) = &self.rank_expression {
fields.extend(field_dependencies(rank));
}
fields
}
pub fn validate(&self, expression: &Expr) -> Result<(), String> {
if self.contract != CONTRACT {
return Err("pattern_context_contract_invalid".into());
}
let needed = field_dependencies(expression);
let ranked = needed.iter().any(|f| f.starts_with("scope_"));
if ranked != self.rank_expression.is_some() || !ranked && !self.rank_universe.is_empty() {
return Err("pattern_rank_expression_and_universe_required".into());
}
if ranked
&& (self.rank_universe.len() < 2
|| self.rank_universe.len() > 20_000
|| self.rank_universe.iter().any(|s| !valid_symbol(s))
|| self.rank_universe.iter().collect::<BTreeSet<_>>().len()
!= self.rank_universe.len())
{
return Err("pattern_rank_universe_invalid".into());
}
if let Some(rank) = &self.rank_expression {
let fields = field_dependencies(rank);
if fields
.iter()
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !f.starts_with("index_"))
{
return Err("pattern_rank_expression_invalid_or_recursive".into());
}
}
let fields = self.fields(expression);
if fields
.iter()
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !CONTEXT_FIELDS.contains(&f.as_str()))
{
return Err("pattern_context_unmapped_field".into());
}
let index = fields.iter().any(|f| f.starts_with("index_"));
if index != self.benchmark.is_some()
|| self
.benchmark
.as_ref()
.is_some_and(|s| !valid_symbol(s) || s.ends_with(".BJ"))
{
return Err("pattern_reference_index_required".into());
}
if !index && !ranked {
return Err("pattern_unused_context".into());
}
Ok(())
}
}
pub fn build_dataset_context(
spec: &PatternSpec,
data: &DataSet,
date: NaiveDate,
) -> Result<ResearchContext, String> {
let Some(config) = &spec.execution_context else {
return Ok(ResearchContext::default());
};
config.validate(
spec.expression
.as_ref()
.ok_or("pattern_context_requires_expression")?,
)?;
let days = data.calendar().trailing_days(date, spec.history_len());
if days.len() != spec.history_len() || days.last() != Some(&date) {
return Err("pattern_context_calendar_incomplete".into());
}
let needed = config.fields(spec.expression.as_ref().unwrap());
let mut context = ResearchContext::default();
if let Some(symbol) = &config.benchmark {
for name in needed.iter().filter(|f| f.starts_with("index_")) {
let values = days
.iter()
.map(|d| {
let value = if let Some(b) = data.market(*d, symbol) {
match name.as_str() {
"index_open" => Some(b.open),
"index_high" => Some(b.high),
"index_low" => Some(b.low),
"index_close" => Some(b.close),
_ => None,
}
} else if let Some(b) = data.benchmark(*d).filter(|b| &b.benchmark == symbol) {
match name.as_str() {
"index_open" => Some(b.open),
"index_close" => Some(b.close),
_ => None,
}
} else {
None
};
value
.filter(|v| v.is_finite() && *v > 0.0)
.map(Some)
.ok_or_else(|| format!("pattern_reference_missing: {symbol} {d} {name}"))
})
.collect::<Result<Vec<_>, _>>()?;
context.common.insert(name.clone(), values);
}
}
if let Some(expression) = &config.rank_expression {
let mut input = spec.clone();
input.execution_context = None;
input.expression = Some(expression.clone());
let mut values = BTreeMap::new();
for symbol in &config.rank_universe {
let row = evaluate_with_context(
&input,
&days,
&dataset_series(data, &days, symbol),
&context.common,
true,
)?;
if let Some(reason) = row.exclusion {
return Err(format!("pattern_rank_member_incomplete: {symbol} {reason}"));
}
values.insert(
symbol.clone(),
serde_json::from_value::<Vec<Option<f64>>>(
row.values["expression"]["values"].clone(),
)
.map_err(|e| e.to_string())?,
);
}
let ranks =
crate::factor_cross_section::rank_history(&days, &config.rank_universe, &values)?;
for symbol in &config.rank_universe {
let decode = |value: &Value| {
serde_json::from_value::<Vec<Option<f64>>>(value.clone()).map_err(|e| e.to_string())
};
context.by_symbol.insert(
symbol.clone(),
BTreeMap::from([
("scope_rank".into(), decode(&ranks["rank"][symbol])?),
(
"scope_percentile".into(),
decode(&ranks["percentile"][symbol])?,
),
(
"scope_size".into(),
vec![Some(config.rank_universe.len() as f64); days.len()],
),
]),
);
}
}
Ok(context)
}
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
let mut specs = Vec::new();
match value {
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
Value::Array(items) => {
for v in items {
specs.extend(specs_in_value(v)?);
}
}
Value::Object(items) => {
for v in items.values() {
specs.extend(specs_in_value(v)?);
}
}
_ => {}
}
Ok(specs)
}
pub fn required_symbols(value: &Value) -> Result<(BTreeSet<String>, BTreeSet<String>), String> {
let (mut indices, mut stocks) = (BTreeSet::new(), BTreeSet::new());
for spec in specs_in_value(value)? {
if let Some(context) = spec.execution_context {
if let Some(index) = context.benchmark {
indices.insert(index);
}
stocks.extend(context.rank_universe);
}
}
Ok((indices, stocks))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
use serde_json::json;
#[test]
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
assert!(serde_json::to_value(expression).unwrap().get("window").is_none());
}
fn data(future: bool, reference: bool) -> DataSet {
let mut days = vec![
NaiveDate::from_ymd_opt(2026, 9, 4).unwrap(),
NaiveDate::from_ymd_opt(2026, 9, 7).unwrap(),
NaiveDate::from_ymd_opt(2026, 9, 8).unwrap(),
];
if future {
days.push(NaiveDate::from_ymd_opt(2026, 9, 9).unwrap());
}
let symbols = vec!["000001.SZ", "000002.SZ", "000003.SZ"];
let mut instruments = symbols
.iter()
.map(|s| Instrument {
symbol: s.to_string(),
name: s.to_string(),
board: "SZ_MAIN".into(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".into(),
})
.collect::<Vec<_>>();
if reference {
instruments.push(Instrument {
symbol: "399006.SZ".into(),
name: "reference".into(),
board: "INDEX".into(),
round_lot: 1,
listed_at: None,
delisted_at: None,
status: "active".into(),
});
}
let mut market = vec![];
let mut factors = vec![];
let mut benchmark = vec![];
for (i, d) in days.iter().enumerate() {
for (n, s) in symbols.iter().enumerate() {
let c = [
[10., 12., 11., 1000.],
[10., 11., 12., 1.],
[10., 10., 13., 1.],
][n][i];
market.push(DailyMarketSnapshot {
date: *d,
symbol: s.to_string(),
timestamp: None,
day_open: c,
open: c,
high: c,
low: c,
close: c,
last_price: c,
bid1: c,
ask1: c,
prev_close: 10.,
volume: 100000,
minute_volume: 0,
bid1_volume: 10000,
ask1_volume: 10000,
trading_phase: None,
paused: false,
upper_limit: c * 2.,
lower_limit: c / 2.,
price_tick: 0.01,
});
factors.push(DailyFactorSnapshot {
date: *d,
symbol: s.to_string(),
market_cap_bn: 1.,
free_float_cap_bn: 1.,
pe_ttm: 10.,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.),
extra_factors: Default::default(),
});
}
if reference {
let mut row = market.last().unwrap().clone();
row.symbol = "399006.SZ".into();
row.open = 30.;
row.high = 30.;
row.low = 30.;
row.close = 30.;
market.push(row);
}
benchmark.push(BenchmarkSnapshot {
date: *d,
benchmark: "000300.SH".into(),
open: 4000.,
close: 4000.,
prev_close: 4000.,
volume: 1000,
});
}
DataSet::from_components(instruments, market, factors, vec![], benchmark).unwrap()
}
fn spec(rank: bool) -> PatternSpec {
let expression = if rank {
json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"scope_rank"},{"kind":"number","value":2}]})
} else {
json!({"kind":"operator","name":"LT","args":[{"kind":"field","name":"index_close"},{"kind":"number","value":100}]})
};
let context = if rank {
json!({"contract":CONTRACT,"rank_expression":{"kind":"operator","name":"PCT_CHANGE","window":1,"args":[{"kind":"field","name":"close"}]},"rank_universe":["000001.SZ","000002.SZ","000003.SZ"]})
} else {
json!({"contract":CONTRACT,"benchmark":"399006.SZ"})
};
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression,"execution_context":context})).unwrap().validate().unwrap()
}
#[test]
fn dataset_rank_is_full_scope_causal_and_equal_to_pure_cross_section() {
let spec = spec(true);
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
let original = build_dataset_context(&spec, &data(false, true), date).unwrap();
let future = build_dataset_context(&spec, &data(true, true), date).unwrap();
assert_eq!(original.by_symbol, future.by_symbol);
assert_eq!(original.by_symbol["000001.SZ"]["scope_rank"][2], Some(3.));
assert_eq!(original.by_symbol["000002.SZ"]["scope_rank"][2], Some(2.));
assert_eq!(original.by_symbol["000003.SZ"]["scope_rank"][2], Some(1.));
assert!(
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
.unwrap()
.matched
);
let mut incomplete = data(false, true).snapshot_components();
incomplete.market.retain(|r| r.symbol != "000003.SZ");
let broken = DataSet::from_components(
incomplete.instruments,
incomplete.market,
incomplete.factors,
incomplete.candidates,
incomplete.benchmarks,
)
.unwrap();
assert!(build_dataset_context(&spec, &broken, date).is_err());
}
#[test]
fn reference_index_never_defaults_to_performance_benchmark() {
let spec = spec(false);
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
assert!(
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
.unwrap()
.matched
);
assert!(build_dataset_context(&spec, &data(false, false), date)
.unwrap_err()
.contains("399006.SZ"));
}
#[test]
fn runtime_contract_rejects_missing_range_and_recursive_ranks() {
let mut missing = spec(true);
missing
.execution_context
.as_mut()
.unwrap()
.rank_universe
.clear();
assert!(missing.validate().is_err());
let mut recursive = spec(true);
recursive
.execution_context
.as_mut()
.unwrap()
.rank_expression = Some(Expr::Field {
name: "scope_rank".into(),
});
assert!(recursive.validate().is_err());
}
}
File diff suppressed because it is too large Load Diff
@@ -227,6 +227,8 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
"factor",
"day_factor",
"rolling_mean",
"pattern_signal",
"pattern_score",
"rolling_mean_current",
"rolling_max_current",
"rolling_return_stddev_current",
+236
View File
@@ -0,0 +1,236 @@
//! Stock pools emit one frozen framework intent, not a lossy code-strategy translation.
use super::*;
use crate::stock_pool_execution as pool;
use rust_decimal::Decimal;
impl PlatformExprStrategy {
pub(super) fn stock_pool_decision(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, BacktestError> {
let program = self
.config
.stock_pool
.as_ref()
.ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))?
.clone();
let mut constraints = pool::stock_pool_constraints_from_configuration(
&program.allocation_policy,
&program.stop_take_policy,
)
.map_err(BacktestError::Execution)?;
if let Some(policy) = constraints
.market_timing_policy
.as_ref()
.filter(|policy| policy.enabled)
{
let before_close = !ctx.is_lagged_execution()
&& ctx
.active_datetime
.is_some_and(|at| at.time() < NaiveTime::from_hms_opt(15, 0, 0).unwrap());
let as_of = if before_close {
ctx.data
.previous_trading_date(ctx.decision_date, 1)
.ok_or_else(|| {
BacktestError::Execution(
"market_timing_previous_completed_session_missing".into(),
)
})?
} else {
ctx.decision_date
};
let required = policy
.required_history()
.map_err(BacktestError::Execution)?;
let mut dates = ctx
.data
.calendar()
.iter()
.filter(|date| *date <= as_of)
.collect::<Vec<_>>();
if dates.len() < required {
return Err(BacktestError::Execution(format!(
"market_timing_official_calendar_incomplete:required={required}:available={}",
dates.len()
)));
}
dates = dates.split_off(dates.len() - required);
let index = policy.index_code.as_ref().expect("validated index policy");
let closes = dates
.iter()
.map(|date| {
let row = ctx.data.market(*date, index).ok_or_else(|| {
BacktestError::Execution(format!(
"market_timing_completed_index_row_missing:{index}:{date}"
))
})?;
Ok(crate::stock_pool_index_policy::IndexClose {
date: *date,
close: row.close,
})
})
.collect::<Result<Vec<_>, BacktestError>>()?;
constraints.market_timing_input =
Some(crate::stock_pool_index_policy::MarketTimingInput {
index_code: index.clone(),
as_of_date: as_of,
official_dates: dates,
closes,
});
}
let rule = pool::normalize_stock_pool_execution_rule(
Some(&program.timing_policy),
!self.config.buy_filter_expr.trim().is_empty(),
!self.config.stop_loss_expr.trim().is_empty()
|| !self.config.take_profit_expr.trim().is_empty()
|| !self.config.position_target_rules.is_empty(),
)
.map_err(BacktestError::Execution)?;
if self.config.in_skip_window(ctx.decision_date) {
return Ok(StrategyDecision::default());
}
let day = self.day_state(ctx, ctx.decision_date)?;
let (market_date, universe_date, factor_date) = self.selection_dates(ctx);
let (low, high) = self.market_cap_band(ctx, &day)?;
let (ranked, mut diagnostics, risk_decisions) = self.select_symbols(
ctx,
market_date,
universe_date,
factor_date,
&day,
low,
high,
usize::MAX,
)?;
let held = ctx
.portfolio
.positions()
.values()
.filter(|p| p.quantity > 0)
.map(|p| p.symbol.clone())
.collect::<BTreeSet<_>>();
if !self.config.buy_filter_expr.trim().is_empty() {
for symbol in &ranked {
let stock =
self.stock_state_with_factor_date(ctx, market_date, factor_date, symbol)?;
if !self.eval_bool(ctx, &self.config.buy_filter_expr, &day, Some(&stock), None)? {
constraints
.buy_denials
.insert(symbol.clone(), vec!["frozen_buy_condition_not_met".into()]);
}
}
}
let native_exits = self.current_stop_take_exit_symbols(ctx, ctx.decision_date, &day)?;
for symbol in native_exits {
constraints.position_target_bps.insert(symbol, 0);
}
for (symbol, (bps, _)) in
self.current_position_target_rules(ctx, ctx.decision_date, factor_date, &day)?
{
constraints
.position_target_bps
.entry(symbol)
.and_modify(|old| *old = (*old).min(bps))
.or_insert(bps);
}
let limit = constraints.target_holding_count.unwrap_or(ranked.len());
let final_symbols = ranked
.iter()
.filter(|symbol| !constraints.position_target_bps.contains_key(*symbol))
.take(limit)
.cloned()
.collect();
let generation = format!(
"stock-pool:{}:{}:{}",
program.pool_id,
program.version_id,
ctx.active_datetime
.map(|date| date.to_string())
.unwrap_or_else(|| ctx.decision_date.to_string())
);
let selection = pool::StockPoolSelection {
trade_date: ctx.decision_date,
requested_symbols: ranked.clone(),
normal_trading_symbols: ranked.clone(),
risk_eligible_symbols: ranked.clone(),
final_symbols,
exclusion_reasons: BTreeMap::new(),
inherited_from_generation: None,
explicit_empty: program.members.is_empty()
&& self.config.candidate_symbols_by_date.is_empty(),
generation: Some(generation.clone()),
};
let by_symbol = program
.members
.iter()
.map(|member| (member.symbol.as_str(), member))
.collect::<BTreeMap<_, _>>();
let mut scope = ranked.clone();
// Kept rules are execution metadata, not additional selection candidates.
for symbol in held {
if by_symbol.contains_key(symbol.as_str()) && !scope.contains(&symbol) {
scope.push(symbol)
}
}
let members = scope
.iter()
.enumerate()
.map(|(index, symbol)| {
let mut member = by_symbol
.get(symbol.as_str())
.map(|member| (*member).clone())
.unwrap_or_else(|| pool::StockPoolMemberSpec {
symbol: symbol.clone(),
requested_order: index as i32,
recommendation_reason: String::new(),
target_weight_bps: None,
stop_loss: constraints.default_stop_loss,
take_profit: constraints.default_take_profit,
});
member.requested_order = index as i32;
member
})
.collect();
let (base_ratio, reserve_cash) =
pool::stock_pool_funding_from_configuration(&program.allocation_policy)
.map_err(BacktestError::Execution)?;
let ratio = self
.config
.position_exposure_schedule
.range(..=ctx.decision_date)
.next_back()
.map(|(_, value)| (*value * 10000.).round() as i64)
.unwrap_or(i64::from(base_ratio));
let invest_ratio_bps = i32::try_from(ratio)
.ok()
.filter(|ratio| (0..=10000).contains(ratio))
.ok_or_else(|| BacktestError::Execution("stock_pool_invest_ratio_invalid".into()))?;
let signal_equity =
self.signal_visible_total_value(ctx, ctx.decision_date, ctx.is_lagged_execution());
let frozen_equity = signal_equity
.to_string()
.parse::<Decimal>()
.map_err(|_| BacktestError::Execution("stock_pool_signal_equity_invalid".into()))?;
diagnostics.push(format!("stock_pool_signal_frozen generation={generation} candidate_count={} frozen_equity={frozen_equity}",ranked.len()));
Ok(StrategyDecision {
order_intents: vec![OrderIntent::StockPool {
contract: Box::new(pool::FrozenStockPoolIntent {
pool_id:program.pool_id.clone(),
signal_date: ctx.decision_date,
frozen_equity,
selection,
members,
rule,
constraints,
invest_ratio_bps,
reserve_cash,
out_of_pool_policy: program.out_of_pool_policy,
generation,
}),
}],
diagnostics,
risk_decisions,
..Default::default()
})
}
}
File diff suppressed because it is too large Load Diff
+144 -15
View File
@@ -60,6 +60,10 @@ pub struct PositionLot {
pub struct Position {
pub symbol: String,
pub quantity: u32,
opened_date: Option<NaiveDate>,
last_buy_date: Option<NaiveDate>,
// ALV-compatible moving average execution price; partial sells do not rebase it.
pub average_price: f64,
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
pub average_cost: f64,
pub last_price: f64,
@@ -86,6 +90,9 @@ impl Position {
Self {
symbol: symbol.into(),
quantity: 0,
opened_date: None,
last_buy_date: None,
average_price: 0.0,
average_cost: 0.0,
last_price: 0.0,
realized_pnl: FixedMoney::ZERO,
@@ -111,6 +118,12 @@ impl Position {
self.quantity == 0
}
pub fn opened_date(&self) -> Option<NaiveDate> {
self.opened_date
}
pub fn last_buy_date(&self) -> Option<NaiveDate> { self.last_buy_date }
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
self.buy_with_mark_price(date, quantity, price, price);
}
@@ -127,6 +140,11 @@ impl Position {
}
let previous_quantity = self.quantity;
self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date)));
if previous_quantity == 0 {
self.opened_date = Some(date);
}
let previous_average_price = self.average_price;
let previous_average_cost = self.average_cost;
let gross_amount = fixed_money_or_panic(
execution_price * quantity as f64,
@@ -146,6 +164,18 @@ impl Position {
.day_buy_value
.checked_add(gross_amount)
.expect("fixed-point day buy value overflow");
if previous_quantity > 0
&& previous_average_price.is_finite()
&& previous_average_price > 0.0
&& execution_price.is_finite()
&& execution_price > 0.0
{
self.average_price = (previous_average_price * previous_quantity as f64
+ execution_price * quantity as f64)
/ self.quantity as f64;
} else {
self.average_price = execution_price;
}
if previous_quantity > 0
&& previous_average_cost.is_finite()
&& previous_average_cost > 0.0
@@ -186,6 +216,7 @@ impl Position {
let mut remaining_proceeds = total_proceeds;
let mut realized = FixedMoney::ZERO;
let mut realized_entry = FixedMoney::ZERO;
let average_price_before_sell = self.average_price;
let average_cost_before_sell = self.average_cost;
while remaining > 0 {
@@ -250,11 +281,20 @@ impl Position {
.checked_add(total_proceeds)
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
if self.quantity == 0 {
self.opened_date = None;
self.average_price = 0.0;
self.recalculate_average_cost();
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
self.average_cost = average_cost_before_sell;
} else {
self.recalculate_average_cost();
if average_price_before_sell.is_finite() && average_price_before_sell > 0.0 {
self.average_price = average_price_before_sell;
} else {
self.average_price = self.average_entry_price().unwrap_or(0.0);
}
if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
self.average_cost = average_cost_before_sell;
} else {
self.recalculate_average_cost();
}
}
self.refresh_day_pnl();
Ok(realized.to_f64())
@@ -298,6 +338,13 @@ impl Position {
.to_f64()
}
pub fn unrealized_average_price_pnl(&self) -> f64 {
if self.quantity == 0 || !self.average_price.is_finite() || self.average_price <= 0.0 {
return 0.0;
}
(self.last_price - self.average_price) * self.quantity as f64
}
pub fn pnl(&self) -> f64 {
self.realized_pnl.to_f64() + self.unrealized_pnl()
}
@@ -421,9 +468,12 @@ impl Position {
}
pub fn holding_return(&self, price: f64) -> Option<f64> {
let Some(avg_price) = self.average_entry_price() else {
return None;
};
let avg_price = self
.average_price
.is_finite()
.then_some(self.average_price)
.filter(|value| *value > 0.0)
.or_else(|| self.average_entry_price())?;
if avg_price <= 0.0 {
None
} else {
@@ -503,6 +553,7 @@ impl Position {
if adjust_cost_basis {
self.average_cost -= dividend_per_share;
}
self.average_price -= dividend_per_share;
self.last_price -= dividend_per_share;
self.day_dividend_cash = self
.day_dividend_cash
@@ -545,6 +596,11 @@ impl Position {
self.lots = scaled_lots;
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
self.last_price /= ratio;
if self.average_price.is_finite() && self.average_price > 0.0 {
self.average_price /= ratio;
} else {
self.average_price = self.average_entry_price().unwrap_or(0.0);
}
if self.average_cost.is_finite() && self.average_cost > 0.0 {
self.average_cost /= ratio;
} else {
@@ -620,6 +676,7 @@ pub struct PortfolioState {
cash_receivables: Vec<CashReceivable>,
pending_cash_flows: Vec<PendingCashFlow>,
day_sold_symbols: BTreeSet<String>,
stock_pool_states: std::collections::BTreeMap<String,crate::stock_pool_state::StockPoolExecutionState>,
}
#[derive(Debug, Clone)]
@@ -656,6 +713,7 @@ impl PortfolioState {
cash_receivables: Vec::new(),
pending_cash_flows: Vec::new(),
day_sold_symbols: BTreeSet::new(),
stock_pool_states: std::collections::BTreeMap::new(),
}
}
@@ -665,6 +723,15 @@ impl PortfolioState {
self.initial_cash.to_f64()
}
pub(crate) fn stock_pool_execution_state(&self,pool_id:&str)->crate::stock_pool_state::StockPoolExecutionState{
self.stock_pool_states.get(pool_id).cloned().unwrap_or_default()
}
pub(crate) fn set_stock_pool_execution_state(&mut self,pool_id:&str,state:crate::stock_pool_state::StockPoolExecutionState)->Result<(),String>{
if pool_id.trim().is_empty(){return Err("stock_pool_state_identity_missing".into())}
state.validate()?;self.stock_pool_states.insert(pool_id.into(),state);Ok(())
}
pub fn initial_cash(&self) -> f64 {
self.initial_cash.to_f64()
}
@@ -1006,8 +1073,6 @@ impl PortfolioState {
let unresolved_delisting = current_market_missing
&& data.instrument(&position.symbol).is_some_and(|instrument| {
instrument.is_delisted_on_or_before(date)
|| (instrument.status.eq_ignore_ascii_case("delisted")
&& instrument.delisted_at.is_none())
});
if unresolved_delisting {
position.last_price = 0.0;
@@ -1027,11 +1092,13 @@ impl PortfolioState {
position.refresh_day_pnl();
continue;
}
let confirmed_pause = data.market(date, &position.symbol).is_some_and(|row| row.paused)
|| data.candidate(date, &position.symbol).is_some_and(|row| row.is_paused);
let price = data
.price(date, &position.symbol, field)
.or_else(|| data.price_on_or_before(date, &position.symbol, field))
.or_else(|| confirmed_pause.then(|| data.price_on_or_before(date, &position.symbol, field)).flatten())
.or_else(|| {
(position.last_price.is_finite() && position.last_price > 0.0)
(confirmed_pause && position.last_price.is_finite() && position.last_price > 0.0)
.then_some(position.last_price)
})
.ok_or_else(|| DataSetError::MissingSnapshot {
@@ -1128,8 +1195,11 @@ impl PortfolioState {
.map(|position| {
let market_value = position.market_value();
let entry_average_cost = position
.average_entry_price()
.average_price
.is_finite()
.then_some(position.average_price)
.filter(|value| value.is_finite() && *value > 0.0)
.or_else(|| position.average_entry_price())
.unwrap_or(position.average_cost);
HoldingSummary {
date,
@@ -1143,7 +1213,7 @@ impl PortfolioState {
} else {
0.0
},
unrealized_pnl: position.unrealized_entry_pnl(),
unrealized_pnl: position.unrealized_average_price_pnl(),
realized_pnl: position.realized_entry_pnl(),
pnl: position.entry_pnl(),
trading_pnl: position.trading_pnl,
@@ -1180,7 +1250,10 @@ impl PortfolioState {
}
let old_quantity = old_position.quantity;
let old_opened_date = old_position.opened_date;
let old_last_buy_date = old_position.last_buy_date;
let last_price = old_position.last_price;
let old_average_price = old_position.average_price;
let old_average_cost = old_position.average_cost;
let realized_pnl = old_position.realized_pnl;
let realized_entry_pnl = old_position.realized_entry_pnl;
@@ -1218,6 +1291,15 @@ impl PortfolioState {
.entry(new_symbol.to_string())
.or_insert_with(|| Position::new(new_symbol));
let successor_quantity_before = successor.quantity;
successor.opened_date = match (successor.opened_date, old_opened_date) {
(Some(current), Some(previous)) => Some(current.min(previous)),
(current, previous) => current.or(previous),
};
successor.last_buy_date = match (successor.last_buy_date, old_last_buy_date) {
(Some(current), Some(previous)) => Some(current.max(previous)),
(current, previous) => current.or(previous),
};
let successor_average_price_before = successor.average_price;
let successor_average_cost_before = successor.average_cost;
successor.lots.extend(converted_lots);
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
@@ -1232,6 +1314,30 @@ impl PortfolioState {
if converted_last_price > 0.0 {
successor.last_price = converted_last_price;
}
let converted_average_price = if old_average_price.is_finite()
&& old_average_price > 0.0
&& ratio.is_finite()
&& ratio > 0.0
{
Some(old_average_price / ratio)
} else {
None
};
if let Some(converted_average_price) = converted_average_price {
if successor_quantity_before > 0
&& successor_average_price_before.is_finite()
&& successor_average_price_before > 0.0
{
successor.average_price = (successor_average_price_before
* successor_quantity_before as f64
+ converted_average_price * converted_quantity as f64)
/ successor.quantity as f64;
} else {
successor.average_price = converted_average_price;
}
} else {
successor.average_price = successor.average_entry_price().unwrap_or(0.0);
}
let converted_average_cost = if old_average_cost.is_finite()
&& old_average_cost > 0.0
&& ratio.is_finite()
@@ -1375,8 +1481,10 @@ mod tests {
let realized = position.sell(100, 6.0).expect("partial FIFO sell");
assert_eq!(position.quantity, 100);
assert!((position.average_price - 7.5).abs() < 1e-12);
assert!((position.average_cost - 7.55).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
assert!((position.unrealized_average_price_pnl() + 150.0).abs() < 1e-12);
assert!((realized + 405.0).abs() < 1e-12);
assert!((position.unrealized_pnl() - 95.0).abs() < 1e-12);
assert!((position.pnl() + 310.0).abs() < 1e-12);
@@ -1409,6 +1517,7 @@ mod tests {
position.record_buy_trade_cost(22_200, 100.0);
assert!(position.average_cost > 5.66);
assert!((position.average_price - 5.66).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12);
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
}
@@ -1430,6 +1539,7 @@ mod tests {
position.sell(2700, 16.8331).expect("partial sell");
assert_eq!(position.quantity, 100);
assert!((position.average_price - 18.94711428571429).abs() < 1e-12);
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
}
@@ -1443,11 +1553,13 @@ mod tests {
position.sell(100, 6.0).expect("partial sell");
assert_eq!(position.quantity, 100);
assert!((position.average_price - 7.5).abs() < 1e-12);
assert!((position.average_cost - 7.5).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
position.buy(date, 100, 5.0);
assert_eq!(position.quantity, 200);
assert!((position.average_price - 6.25).abs() < 1e-12);
assert!((position.average_cost - 6.25).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
}
@@ -1489,6 +1601,7 @@ mod tests {
let cash = position.apply_cash_dividend_preserve_cost_basis(0.6);
assert!((cash - 600.0).abs() < 1e-12);
assert!((position.average_price - 45.85).abs() < 1e-12);
assert!((position.average_cost - cost_before).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12);
assert!((position.last_price - 45.85).abs() < 1e-12);
@@ -1571,6 +1684,7 @@ mod tests {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -1659,6 +1773,7 @@ mod tests {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -1695,7 +1810,7 @@ mod tests {
}
#[test]
fn portfolio_carries_last_price_when_position_market_row_is_missing() {
fn portfolio_missing_market_requires_formal_suspension_before_carrying_price() {
let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap();
let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
@@ -1753,9 +1868,23 @@ mod tests {
.update_prices(prev_date, &dataset, PriceField::Close)
.expect("previous close");
portfolio.begin_trading_day();
portfolio
let error = portfolio
.update_prices(missing_date, &dataset, PriceField::Close)
.expect("missing current row should carry previous close");
.expect_err("unclassified missing current price must not be filled from history");
assert!(error.to_string().contains("601028.SH"));
let paused_dataset = DataSet::from_components(
vec![dataset.instrument("601028.SH").unwrap().clone()],
vec![dataset.market(prev_date, "601028.SH").unwrap().clone()],
Vec::new(),
vec![crate::data::CandidateEligibility {
date: missing_date, symbol: "601028.SH".into(), is_st: false, is_star_st: false,
is_new_listing: false, is_paused: true, allow_buy: false, allow_sell: false,
is_kcb: false, is_one_yuan: false, risk_level_code: None,
}],
vec![dataset.benchmark(prev_date).unwrap().clone()],
).unwrap();
portfolio.update_prices(missing_date, &paused_dataset, PriceField::Close)
.expect("dated suspension permits keeping the last known valuation, not creating a fill");
let position = portfolio.position("601028.SH").expect("position");
assert!((position.last_price - 10.3).abs() < 1e-6);
+517
View File
@@ -0,0 +1,517 @@
//! Causal portfolio-loss state, independent of market-data and order adapters.
use std::collections::VecDeque;
use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
const STATE_SCHEMA: &str = "fidc.portfolio-loss-state/v1";
const MAX_OBSERVATIONS: usize = 120;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PortfolioLossConfig {
pub lookback: usize,
pub loss_trigger: f64,
pub floor_exposure: f64,
pub cooldown_trading_days: usize,
}
impl PortfolioLossConfig {
pub fn validate(&self) -> Result<(), PortfolioLossError> {
if !matches!(self.lookback, 10 | 20 | 40 | 60)
|| !self.loss_trigger.is_finite()
|| !(0.02..=0.30).contains(&self.loss_trigger)
|| !self.floor_exposure.is_finite()
|| !(0.0..=1.0).contains(&self.floor_exposure)
|| !(1..=120).contains(&self.cooldown_trading_days)
{
return Err(PortfolioLossError::InvalidConfig);
}
Ok(())
}
}
/// Finalized portfolio accounting, not a market close used as a proxy for NAV.
/// Unit NAV must already exclude external deposits and withdrawals.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ClosedPortfolioSession {
pub date: NaiveDate,
pub previous_session_date: Option<NaiveDate>,
pub available_at: DateTime<Utc>,
pub start_unit_nav: f64,
pub end_unit_nav: f64,
pub start_gross_exposure: f64,
pub end_gross_exposure: f64,
pub source_sha256: String,
}
impl ClosedPortfolioSession {
fn validate(&self) -> Result<(), PortfolioLossError> {
let earliest = self.date.and_hms_opt(7, 30, 0).unwrap().and_utc();
if [self.start_unit_nav, self.end_unit_nav]
.iter()
.any(|value| !value.is_finite() || *value <= 0.0)
|| [self.start_gross_exposure, self.end_gross_exposure]
.iter()
.any(|value| !value.is_finite() || *value < 0.0)
|| self
.previous_session_date
.is_some_and(|date| date >= self.date)
|| self.available_at < earliest
|| self.source_sha256.len() != 64
|| !self
.source_sha256
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(PortfolioLossError::InvalidObservation);
}
self.unit_return()?;
Ok(())
}
fn unit_return(&self) -> Result<Option<f64>, PortfolioLossError> {
let gross = self.start_gross_exposure.max(self.end_gross_exposure);
if gross <= 1e-12 {
return Ok(None);
}
let value = (self.end_unit_nav / self.start_unit_nav - 1.0) / gross;
if !value.is_finite() {
return Err(PortfolioLossError::InvalidObservation);
}
Ok(Some(value))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PortfolioLossDecision {
pub execution_date: NaiveDate,
pub observed_through: Option<NaiveDate>,
pub observation_count: usize,
pub trailing_unit_return: Option<f64>,
pub threshold_breached: bool,
pub newly_triggered: bool,
pub risk_off: bool,
pub cooldown_before: usize,
pub cooldown_after: usize,
pub target_exposure: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PortfolioLossState {
schema_version: String,
config: PortfolioLossConfig,
started_on: NaiveDate,
observations: VecDeque<ClosedPortfolioSession>,
last_session: Option<ClosedPortfolioSession>,
cooldown_remaining: usize,
trigger_count: usize,
last_decision: Option<PortfolioLossDecision>,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum PortfolioLossError {
#[error("invalid portfolio loss configuration")]
InvalidConfig,
#[error("invalid finalized portfolio session observation")]
InvalidObservation,
#[error("portfolio loss state does not match its frozen configuration")]
StateMismatch,
#[error("portfolio session history is missing, reordered or corrected")]
SessionDiscontinuity,
#[error("portfolio loss observation is not visible at the decision")]
ObservationNotVisible,
#[error("portfolio loss decisions must follow trading-session order")]
DecisionOrder,
}
impl PortfolioLossState {
pub fn new(
config: PortfolioLossConfig,
started_on: NaiveDate,
) -> Result<Self, PortfolioLossError> {
config.validate()?;
Ok(Self {
schema_version: STATE_SCHEMA.to_owned(),
config,
started_on,
observations: VecDeque::new(),
last_session: None,
cooldown_remaining: 0,
trigger_count: 0,
last_decision: None,
})
}
/// Validation is required after deserialization; a JSON hash alone is not
/// account/generation authorization, which belongs to the state owner.
pub fn validate(&self, expected: &PortfolioLossConfig) -> Result<(), PortfolioLossError> {
expected.validate()?;
if self.schema_version != STATE_SCHEMA
|| &self.config != expected
|| self.observations.len() > MAX_OBSERVATIONS
|| self.cooldown_remaining >= expected.cooldown_trading_days
{
return Err(PortfolioLossError::StateMismatch);
}
let mut previous = None;
for item in &self.observations {
item.validate()?;
if item.date < self.started_on
|| previous.is_some_and(|date| item.date <= date)
|| item.unit_return()?.is_none()
{
return Err(PortfolioLossError::StateMismatch);
}
previous = Some(item.date);
}
if let Some(last) = &self.last_session {
last.validate()?;
if last.date < self.started_on
|| previous.is_some_and(|date| date > last.date)
|| (last.unit_return()?.is_some() && self.observations.back() != Some(last))
{
return Err(PortfolioLossError::StateMismatch);
}
} else if !self.observations.is_empty() {
return Err(PortfolioLossError::StateMismatch);
}
if let Some(decision) = &self.last_decision {
let breached = decision
.trailing_unit_return
.is_some_and(|value| value <= -expected.loss_trigger);
let triggered = decision.cooldown_before == 0 && breached;
let after = if decision.cooldown_before > 0 {
decision.cooldown_before - 1
} else if triggered {
expected.cooldown_trading_days - 1
} else {
0
};
if decision.execution_date < self.started_on
|| decision
.observed_through
.is_some_and(|date| date >= decision.execution_date)
|| !decision.target_exposure.is_finite()
|| !(0.0..=1.0).contains(&decision.target_exposure)
|| decision
.trailing_unit_return
.is_some_and(|value| !value.is_finite())
|| decision.cooldown_after != self.cooldown_remaining
|| decision.observation_count > MAX_OBSERVATIONS
|| decision.cooldown_before >= expected.cooldown_trading_days
|| decision.threshold_breached != breached
|| decision.newly_triggered != triggered
|| decision.risk_off != (decision.cooldown_before > 0 || triggered)
|| decision.cooldown_after != after
|| decision.trailing_unit_return.is_some()
!= (decision.observation_count >= expected.lookback)
|| self.trigger_count
> (decision.execution_date - self.started_on).num_days() as usize + 1
{
return Err(PortfolioLossError::StateMismatch);
}
} else if self.cooldown_remaining != 0 || self.trigger_count != 0 {
return Err(PortfolioLossError::StateMismatch);
}
Ok(())
}
/// Exact duplicate delivery is idempotent. Historical corrections require
/// explicit reconciliation instead of changing an already-used window.
pub fn observe(&mut self, session: ClosedPortfolioSession) -> Result<bool, PortfolioLossError> {
self.validate(&self.config)?;
session.validate()?;
if self.last_session.as_ref() == Some(&session) {
return Ok(false);
}
let previous_date = self.last_session.as_ref().map(|value| value.date);
if session.date < self.started_on
|| session.previous_session_date != previous_date
|| previous_date.is_some_and(|date| session.date <= date)
|| (previous_date.is_none() && session.date != self.started_on)
|| self
.last_session
.as_ref()
.is_some_and(|last| session.start_unit_nav != last.end_unit_nav)
{
return Err(PortfolioLossError::SessionDiscontinuity);
}
if session.unit_return()?.is_some() {
self.observations.push_back(session.clone());
if self.observations.len() > MAX_OBSERVATIONS {
self.observations.pop_front();
}
}
self.last_session = Some(session);
Ok(true)
}
pub fn decide(
&mut self,
execution_date: NaiveDate,
previous_completed_session: Option<NaiveDate>,
decision_at: DateTime<Utc>,
risk_on_exposure: f64,
) -> Result<PortfolioLossDecision, PortfolioLossError> {
self.validate(&self.config)?;
if !risk_on_exposure.is_finite() || !(0.0..=1.0).contains(&risk_on_exposure) {
return Err(PortfolioLossError::InvalidConfig);
}
if execution_date < self.started_on
|| previous_completed_session.is_some_and(|date| date >= execution_date)
|| decision_at
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
.date_naive()
!= execution_date
|| self
.last_decision
.as_ref()
.is_some_and(|last| execution_date < last.execution_date)
{
return Err(PortfolioLossError::DecisionOrder);
}
if let Some(last) = &self.last_session {
if last.date >= execution_date || last.available_at > decision_at {
return Err(PortfolioLossError::ObservationNotVisible);
}
if Some(last.date) != previous_completed_session {
return Err(PortfolioLossError::SessionDiscontinuity);
}
} else if execution_date != self.started_on {
return Err(PortfolioLossError::SessionDiscontinuity);
}
if let Some(cached) = self
.last_decision
.as_mut()
.filter(|last| last.execution_date == execution_date)
{
cached.target_exposure = if cached.risk_off {
self.config.floor_exposure.min(risk_on_exposure)
} else {
risk_on_exposure
};
return Ok(cached.clone());
}
let trailing = if self.observations.len() >= self.config.lookback {
let start = self.observations.len() - self.config.lookback;
let mut growth = 1.0;
for item in self.observations.iter().skip(start) {
growth *=
(1.0 + item.unit_return()?.expect("nonzero exposure observation")).max(0.0);
}
let result = growth - 1.0;
if !result.is_finite() {
return Err(PortfolioLossError::InvalidObservation);
}
Some(result)
} else {
None
};
let breached = trailing.is_some_and(|value| value <= -self.config.loss_trigger);
let before = self.cooldown_remaining;
let triggered = before == 0 && breached;
let risk_off = before > 0 || triggered;
let after = if before > 0 {
before - 1
} else if triggered {
self.config.cooldown_trading_days - 1
} else {
0
};
let decision = PortfolioLossDecision {
execution_date,
observed_through: self.last_session.as_ref().map(|value| value.date),
observation_count: self.observations.len(),
trailing_unit_return: trailing,
threshold_breached: breached,
newly_triggered: triggered,
risk_off,
cooldown_before: before,
cooldown_after: after,
target_exposure: if risk_off {
self.config.floor_exposure.min(risk_on_exposure)
} else {
risk_on_exposure
},
};
self.cooldown_remaining = after;
self.trigger_count += usize::from(triggered);
self.last_decision = Some(decision.clone());
Ok(decision)
}
pub fn last_session(&self) -> Option<&ClosedPortfolioSession> {
self.last_session.as_ref()
}
pub fn last_decision(&self) -> Option<&PortfolioLossDecision> {
self.last_decision.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone};
fn date(day: i64) -> NaiveDate {
NaiveDate::from_ymd_opt(2023, 1, 3).unwrap() + Duration::days(day)
}
fn time(day: i64, hour: u32) -> DateTime<Utc> {
Utc.from_utc_datetime(&date(day).and_hms_opt(hour, 0, 0).unwrap())
}
fn config() -> PortfolioLossConfig {
PortfolioLossConfig {
lookback: 10,
loss_trigger: 0.05,
floor_exposure: 0.2,
cooldown_trading_days: 3,
}
}
fn session(day: i64, start: f64, end: f64, gross: f64) -> ClosedPortfolioSession {
ClosedPortfolioSession {
date: date(day),
previous_session_date: (day > 0).then(|| date(day - 1)),
available_at: time(day, 8),
start_unit_nav: start,
end_unit_nav: end,
start_gross_exposure: gross,
end_gross_exposure: gross,
source_sha256: "a".repeat(64),
}
}
#[test]
fn restart_is_exact_and_duplicate_decisions_do_not_consume_cooldown() {
let mut state = PortfolioLossState::new(config(), date(0)).unwrap();
let mut nav = 1.0;
for day in 0..10 {
let end = nav * 0.994;
state.observe(session(day, nav, end, 1.0)).unwrap();
nav = end;
}
let first = state
.decide(date(10), Some(date(9)), time(10, 1), 0.9)
.unwrap();
assert!(first.newly_triggered);
assert_eq!(first.cooldown_after, 2);
let serialized = serde_json::to_string(&state).unwrap();
let mut restored: PortfolioLossState = serde_json::from_str(&serialized).unwrap();
restored.validate(&config()).unwrap();
assert_eq!(
first,
restored
.decide(date(10), Some(date(9)), time(10, 1), 0.9)
.unwrap()
);
let lowered = restored
.decide(date(10), Some(date(9)), time(10, 2), 0.1)
.unwrap();
assert_eq!(lowered.target_exposure, 0.1);
assert_eq!(lowered.cooldown_after, 2);
for day in 10..15 {
let end = nav * 1.01;
let row = session(day, nav, end, 0.2);
state.observe(row.clone()).unwrap();
restored.observe(row).unwrap();
nav = end;
assert_eq!(
state
.decide(date(day + 1), Some(date(day)), time(day + 1, 1), 0.9)
.unwrap(),
restored
.decide(date(day + 1), Some(date(day)), time(day + 1, 1), 0.9)
.unwrap()
);
}
}
#[test]
fn refuses_future_missing_corrected_and_incomplete_accounting() {
let mut state = PortfolioLossState::new(config(), date(0)).unwrap();
let first = session(0, 1.0, 0.99, 1.0);
assert!(state.observe(first.clone()).unwrap());
assert!(!state.observe(first.clone()).unwrap());
let original = state.clone();
let mut changed = first;
changed.end_unit_nav = 0.98;
assert_eq!(
state.observe(changed),
Err(PortfolioLossError::SessionDiscontinuity)
);
assert_eq!(state, original);
assert_eq!(
state.decide(date(0), None, time(0, 1), 0.9),
Err(PortfolioLossError::ObservationNotVisible)
);
assert_eq!(
state.decide(date(2), Some(date(1)), time(2, 1), 0.9),
Err(PortfolioLossError::SessionDiscontinuity)
);
let mut late = PortfolioLossState::new(config(), date(0)).unwrap();
let mut delayed = session(0, 1.0, 0.99, 1.0);
delayed.available_at = time(2, 1);
late.observe(delayed).unwrap();
assert_eq!(
late.decide(date(1), Some(date(0)), time(1, 1), 0.9),
Err(PortfolioLossError::ObservationNotVisible)
);
let mut invalid = session(1, 0.99, 1.0, 1.0);
invalid.end_unit_nav = f64::NAN;
assert_eq!(
state.observe(invalid),
Err(PortfolioLossError::InvalidObservation)
);
}
#[test]
fn cash_sessions_preserve_continuity_without_inventing_returns() {
let mut state = PortfolioLossState::new(config(), date(0)).unwrap();
for day in 0..20 {
state.observe(session(day, 1.0, 1.0, 0.0)).unwrap();
}
let decision = state
.decide(date(20), Some(date(19)), time(20, 1), 0.9)
.unwrap();
assert_eq!(decision.observation_count, 0);
assert_eq!(decision.trailing_unit_return, None);
assert_eq!(decision.target_exposure, 0.9);
assert_eq!(state.last_session().unwrap().date, date(19));
}
#[test]
fn restored_state_rejects_changed_policy_and_forged_cooldown() {
let state = PortfolioLossState::new(config(), date(0)).unwrap();
let mut changed = config();
changed.floor_exposure = 0.5;
assert_eq!(
state.validate(&changed),
Err(PortfolioLossError::StateMismatch)
);
let mut forged = state.clone();
forged.cooldown_remaining = 1;
assert_eq!(
forged.validate(&config()),
Err(PortfolioLossError::StateMismatch)
);
}
#[test]
fn nav_serialization_preserves_float_bits() {
let mut seed = 0xabcddcba12345678_u64;
for _ in 0..2000 {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let value = 0.01 + (seed as f64 / u64::MAX as f64) * 9.99;
let serialized = serde_json::to_string(&value).unwrap();
let restored: f64 = serde_json::from_str(&serialized).unwrap();
assert_eq!(value.to_bits(), restored.to_bits());
}
}
}
+240 -20
View File
@@ -3,6 +3,7 @@ use std::collections::BTreeSet;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::OrderSide;
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
use crate::instrument::Instrument;
use crate::portfolio::Position;
@@ -77,6 +78,13 @@ impl Default for StaticRiskRuleConfig {
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TradingConstraintConfig {
/// Shared execution limits. These fields intentionally use the same
/// names and defaults as the FIDC trading-core RiskLimits contract so a
/// strategy cannot appear protected in paper/live while being unlimited
/// in a backtest.
pub max_order_quantity: f64,
pub max_order_notional: f64,
pub max_symbol_position: f64,
pub volume_limit_enabled: bool,
pub volume_percent: f64,
pub liquidity_limit_enabled: bool,
@@ -91,6 +99,9 @@ pub struct TradingConstraintConfig {
impl Default for TradingConstraintConfig {
fn default() -> Self {
Self {
max_order_quantity: 1_000_000.0,
max_order_notional: 100_000_000.0,
max_symbol_position: 10_000_000.0,
volume_limit_enabled: true,
volume_percent: 0.25,
liquidity_limit_enabled: true,
@@ -127,6 +138,16 @@ pub struct FidcRiskDecisionAudit {
}
impl FidcRiskDecisionAudit {
pub fn rejected_buy_plan(date: NaiveDate, symbol: &str, reason: &str) -> Self {
Self {
date, symbol: symbol.into(), scope: RiskCheckScope::Buy,
stage: "buy_planning".into(), accepted: false,
rule_code: reason.into(), reason: reason.into(),
config_version: Some("inline_risk_policy".into()), data_epoch: date.to_string(),
selection_batch_id: None, order_id: None,
}
}
pub fn rejected_selection(
date: NaiveDate,
symbol: impl Into<String>,
@@ -197,14 +218,8 @@ impl ChinaAShareRiskControl {
{
return Some("inactive_or_delisted");
}
let status = instrument.status.trim().to_ascii_lowercase();
let terminal_status = matches!(
status.as_str(),
"inactive" | "delisted" | "terminated" | "expired"
);
if terminal_status && instrument.delisted_at.is_none() {
return Some("inactive_or_delisted");
}
// Latest reference status has no historical as-of date. Execution-day
// risk snapshots remain authoritative; missing quotes are not waived.
None
}
@@ -386,7 +401,7 @@ impl ChinaAShareRiskControl {
RiskCheckScope::Buy => config.static_rules.reject_kcb_buy,
RiskCheckScope::Sell => false,
};
if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) {
if reject_kcb && candidate.is_kcb {
return Some("kcb");
}
let reject_bjse = match scope {
@@ -399,10 +414,14 @@ impl ChinaAShareRiskControl {
}
let reject_one_yuan = match scope {
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
RiskCheckScope::Buy => false,
RiskCheckScope::Sell => false,
};
if reject_one_yuan && (candidate.is_one_yuan || market.day_open <= 1.0) {
if reject_one_yuan
&& !instrument.is_some_and(Instrument::is_exchange_traded_fund)
&& (candidate.is_one_yuan
|| (market.day_open.is_finite() && market.day_open > 0.0 && market.day_open <= 1.0))
{
return Some("one_yuan");
}
if Self::missing_risk_state_rejected(candidate, config, scope) {
@@ -469,6 +488,15 @@ impl ChinaAShareRiskControl {
) {
return Some(reason);
}
if !check_price.is_finite() || check_price <= 0.0 {
return Some("invalid execution price");
}
// Daily candidate flags can describe the later close. Execution
// price constraints must use this order's actual pricing clock.
if config.static_rules.reject_one_yuan_buy && check_price <= 1.0
&& !instrument.is_some_and(Instrument::is_exchange_traded_fund) {
return Some("one_yuan");
}
if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy {
return Some("buy_disabled");
}
@@ -479,6 +507,36 @@ impl ChinaAShareRiskControl {
None
}
/// Apply the shared quantity/notional/position limits at the same stage
/// as paper/live `RiskLimits`. Static instrument rules remain in the
/// side-specific methods above; this helper only checks order sizing and
/// never changes selection semantics.
pub fn order_size_rejection_reason_with_config(
side: OrderSide,
requested_quantity: u32,
current_position_quantity: u32,
check_price: f64,
config: &FidcRiskControlConfig,
) -> Option<&'static str> {
let limits = &config.trading_constraints;
if (requested_quantity as f64) > limits.max_order_quantity {
return Some("quantity exceeds max_order_quantity");
}
if check_price.is_finite()
&& check_price > 0.0
&& (requested_quantity as f64) * check_price > limits.max_order_notional
{
return Some("notional exceeds max_order_notional");
}
if side == OrderSide::Buy
&& (current_position_quantity as f64) + (requested_quantity as f64)
> limits.max_symbol_position
{
return Some("position exceeds max_symbol_position");
}
None
}
pub fn sell_rejection_reason(
date: NaiveDate,
candidate: &CandidateEligibility,
@@ -515,6 +573,15 @@ impl ChinaAShareRiskControl {
) {
return Some(reason);
}
// When instrument metadata is unavailable, an explicit candidate
// lifecycle fact must still protect the sell path. Otherwise a
// `inactive_or_delisted` candidate could fall through to a synthetic
// sell price and violate the unresolved-delisted holding contract.
if let Some(reason) =
candidate_active_status_rejection(candidate, config, RiskCheckScope::Sell)
{
return Some(reason);
}
if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) {
return Some("paused");
}
@@ -547,11 +614,6 @@ impl ChinaAShareRiskControl {
}
}
fn symbol_is_kcb(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase();
(normalized.starts_with("688") || normalized.starts_with("689")) && normalized.ends_with(".SH")
}
fn symbol_is_bjse(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase();
normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE")
@@ -616,7 +678,6 @@ fn missing_buy_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -
|| config.static_rules.reject_new_listing_buy
|| config.static_rules.reject_kcb_buy
|| config.static_rules.reject_bjse_buy
|| config.static_rules.reject_one_yuan_buy
|| config.static_rules.reject_upper_limit_buy
|| config.static_rules.respect_allow_buy_sell;
}
@@ -693,7 +754,7 @@ fn missing_single_field_rejected(
},
"is_one_yuan" | "one_yuan" => match scope {
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
RiskCheckScope::Buy => false,
RiskCheckScope::Sell => false,
},
"allow_buy" => match scope {
@@ -737,7 +798,6 @@ fn missing_single_field_rejected(
|| config.static_rules.reject_new_listing_buy
|| config.static_rules.reject_kcb_buy
|| config.static_rules.reject_bjse_buy
|| config.static_rules.reject_one_yuan_buy
|| config.static_rules.reject_upper_limit_buy
|| config.static_rules.respect_allow_buy_sell
}
@@ -795,7 +855,7 @@ mod tests {
Some(&instrument("delisted", None)),
date,
),
Some("inactive_or_delisted")
None
);
assert_eq!(
ChinaAShareRiskControl::instrument_rejection_reason(
@@ -854,6 +914,75 @@ mod tests {
position
}
#[test]
fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() {
let day = d(2025, 2, 6);
let mut candidate = candidate(day);
let mut snapshot = market(day, 1.2, 0.5);
let config = FidcRiskControlConfig::default();
candidate.is_one_yuan = true;
snapshot.day_open = 0.9;
snapshot.close = 0.8;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 1.2, &config), None);
candidate.is_one_yuan = false;
snapshot.day_open = 1.2;
snapshot.close = 1.3;
for price in [0.9, 1.0] {
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, price, &config), Some("one_yuan"));
}
let mut relaxed = config;
relaxed.static_rules.reject_one_yuan_buy = false;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 0.9, &relaxed), None);
}
#[test]
fn fund_identity_excludes_stock_one_yuan_rule_but_not_actual_price_and_pause_checks() {
let day=d(2025,2,6);
let mut candidate=candidate(day);
let mut snapshot=market(day,1.2,0.5);
snapshot.lower_limit=0.01;snapshot.upper_limit=10.;
let instrument=Instrument{symbol:candidate.symbol.clone(),name:"fixture fund".into(),board:"ETF".into(),round_lot:100,listed_at:Some(d(2024,1,2)),delisted_at:None,status:"active".into()};
let config=FidcRiskControlConfig::default();
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),None);
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.,&config),Some("invalid execution price"));
candidate.is_paused=true;snapshot.paused=true;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),Some("paused"));
}
#[test]
fn execution_quote_covers_missing_one_yuan_flag_but_not_other_risk_facts() {
let day = d(2025, 2, 6);
let mut candidate = candidate(day);
let snapshot = market(day, 1.2, 0.5);
let config = FidcRiskControlConfig::default();
candidate.risk_level_code = Some("missing_risk_state:is_one_yuan".into());
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 1.2, &config), None);
candidate.risk_level_code = Some("missing_risk_state:is_st".into());
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 1.2, &config), Some("missing_risk_state"));
candidate.risk_level_code = None;
for price in [0.0, f64::NAN, f64::INFINITY] {
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, price, &config), Some("invalid execution price"));
}
}
#[test]
fn explicit_one_yuan_selection_policy_still_uses_selection_facts() {
let day = d(2025, 2, 6);
let mut candidate = candidate(day);
candidate.is_one_yuan = true;
let snapshot = market(day, 1.2, 0.5);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_one_yuan_selection = true;
assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config(
day, &candidate, &snapshot, None, &config), Some("one_yuan"));
}
#[test]
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
let prev_date = d(2024, 4, 16);
@@ -956,6 +1085,24 @@ mod tests {
assert_eq!(configured_reason, None);
}
#[test]
fn kcb_filter_uses_classification_instead_of_security_code() {
let date = d(2025, 1, 2);
let market = market(date, 6.27, 5.63);
let mut candidate = candidate(date);
let config = FidcRiskControlConfig::default();
for symbol in ["688001.SH", "689001.SH", "000001.SZ"] {
candidate.symbol = symbol.to_string();
for is_kcb in [false, true] {
candidate.is_kcb = is_kcb;
let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
date, &candidate, &market, None, 6.27, &config,
);
assert_eq!(reason, is_kcb.then_some("kcb"), "{symbol}");
}
}
}
#[test]
fn st_and_star_st_filters_are_independent() {
let date = d(2025, 1, 2);
@@ -1086,6 +1233,7 @@ mod tests {
let date = d(2025, 1, 2);
let mut candidate = candidate(date);
candidate.symbol = "688506.SH".to_string();
candidate.is_kcb = true;
candidate.risk_level_code = Some("missing_risk_state".to_string());
let market = market(date, 6.27, 5.63);
let mut config = FidcRiskControlConfig::default();
@@ -1138,6 +1286,29 @@ mod tests {
);
}
#[test]
fn sell_rejects_explicit_candidate_inactive_status_without_instrument_row() {
let date = d(2025, 1, 2);
let mut candidate = candidate(date);
candidate.allow_sell = true;
candidate.risk_level_code = Some("inactive_or_delisted".to_string());
let market = market(date, 6.27, 5.63);
let position = position(d(2024, 12, 31));
assert_eq!(
ChinaAShareRiskControl::sell_rejection_reason_with_config(
date,
&candidate,
&market,
None,
Some(&position),
6.27,
&FidcRiskControlConfig::default(),
),
Some("inactive_or_delisted")
);
}
#[test]
fn missing_risk_state_default_selection_ignores_allow_flags_but_buy_rejects() {
let date = d(2025, 1, 2);
@@ -1396,4 +1567,53 @@ mod tests {
assert_eq!(enabled_reason, Some("lower_limit"));
assert_eq!(configured_reason, None);
}
#[test]
fn shared_order_size_limits_apply_to_both_sides_and_buy_position() {
let mut config = FidcRiskControlConfig::default();
config.trading_constraints.max_order_quantity = 500.0;
config.trading_constraints.max_order_notional = 5_000.0;
config.trading_constraints.max_symbol_position = 800.0;
assert_eq!(
ChinaAShareRiskControl::order_size_rejection_reason_with_config(
OrderSide::Buy,
600,
0,
5.0,
&config,
),
Some("quantity exceeds max_order_quantity")
);
assert_eq!(
ChinaAShareRiskControl::order_size_rejection_reason_with_config(
OrderSide::Sell,
400,
10_000,
20.0,
&config,
),
Some("notional exceeds max_order_notional")
);
assert_eq!(
ChinaAShareRiskControl::order_size_rejection_reason_with_config(
OrderSide::Buy,
300,
600,
5.0,
&config,
),
Some("position exceeds max_symbol_position")
);
assert_eq!(
ChinaAShareRiskControl::order_size_rejection_reason_with_config(
OrderSide::Sell,
200,
10_000,
5.0,
&config,
),
None
);
}
}
+42
View File
@@ -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,
@@ -265,6 +275,38 @@ 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();
+398
View File
@@ -0,0 +1,398 @@
//! Completed, same-session minute events. These bars never become execution quotes.
use crate::{
daily_patterns::{PatternResult, PatternSpec},
factor_events::{Expr, Frame},
};
use chrono::{FixedOffset, NaiveDateTime, NaiveTime, TimeZone, Timelike};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::sync::Arc;
pub const CONTRACT: &str = "fidc_completed_session_events_v1";
pub const EVENTS: &[&str] = &[
"PRICE_CROSS_VWAP_UP",
"PRICE_CROSS_VWAP_DOWN",
"INTRADAY_HIGH_BREAKOUT",
"INTRADAY_LOW_BREAKDOWN",
"OPENING_RANGE_BREAKOUT_UP",
"OPENING_RANGE_BREAKOUT_DOWN",
"INTRADAY_VOLUME_SPIKE",
"MORNING_HIGH_BREAKOUT",
"MORNING_LOW_BREAKDOWN",
"AFTERNOON_MOMENTUM_UP",
"AFTERNOON_MOMENTUM_DOWN",
"LATE_SESSION_STRENGTH",
"LATE_SESSION_WEAKNESS",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MinuteBar {
pub symbol: String,
pub timestamp: NaiveDateTime,
pub available_at: NaiveDateTime,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
pub amount: f64,
}
pub type BarStore = Arc<BTreeMap<(chrono::NaiveDate, String), Vec<MinuteBar>>>;
pub fn bar_store(bars: Vec<MinuteBar>) -> Result<BarStore, String> {
let mut groups = BTreeMap::<(chrono::NaiveDate, String), Vec<MinuteBar>>::new();
for bar in bars {
groups
.entry((bar.timestamp.date(), bar.symbol.clone()))
.or_default()
.push(bar);
}
for rows in groups.values_mut() {
rows.sort_by_key(|r| r.timestamp);
if rows
.windows(2)
.any(|pair| pair[0].timestamp == pair[1].timestamp)
{
return Err("duplicate_completed_minute_bar".into());
}
}
Ok(Arc::new(groups))
}
fn f(name: &str) -> Expr {
Expr::Field { name: name.into() }
}
fn n(value: f64) -> Expr {
Expr::Number { value }
}
fn op(name: &str, args: Vec<Expr>, window: Option<usize>) -> Expr {
Expr::Operator {
name: name.into(),
args,
window,
}
}
fn time(minutes: u32) -> NaiveTime {
NaiveTime::from_hms_opt(minutes / 60, minutes % 60, 0).unwrap()
}
pub fn is_regular_label(t: NaiveTime) -> bool {
t.second() == 0 && (time(570) <= t && t <= time(690) || time(780) < t && t <= time(900))
}
pub fn expression(event: &str, p: &BTreeMap<String, Value>) -> Result<Expr, String> {
let cross = |up: bool, a: Expr, b: Expr| {
op(
if up { "CROSS_ABOVE" } else { "CROSS_BELOW" },
vec![a, b],
None,
)
};
Ok(match event {
"PRICE_CROSS_VWAP_UP" => cross(true, f("close"), f("session_vwap")),
"PRICE_CROSS_VWAP_DOWN" => cross(false, f("close"), f("session_vwap")),
"INTRADAY_HIGH_BREAKOUT" => op(
"GT",
vec![
f("close"),
op("LAG", vec![op("CUMMAX", vec![f("high")], None)], Some(1)),
],
None,
),
"INTRADAY_LOW_BREAKDOWN" => op(
"LT",
vec![
f("close"),
op("LAG", vec![op("CUMMIN", vec![f("low")], None)], Some(1)),
],
None,
),
"OPENING_RANGE_BREAKOUT_UP" => cross(true, f("close"), f("opening_high")),
"OPENING_RANGE_BREAKOUT_DOWN" => cross(false, f("close"), f("opening_low")),
"MORNING_HIGH_BREAKOUT" => cross(true, f("close"), f("morning_high")),
"MORNING_LOW_BREAKDOWN" => cross(false, f("close"), f("morning_low")),
"AFTERNOON_MOMENTUM_UP" => cross(true, f("afternoon_return"), n(0.)),
"AFTERNOON_MOMENTUM_DOWN" => cross(false, f("afternoon_return"), n(0.)),
"LATE_SESSION_STRENGTH" => cross(true, f("late_return"), n(0.)),
"LATE_SESSION_WEAKNESS" => cross(false, f("late_return"), n(0.)),
"INTRADAY_VOLUME_SPIKE" => op(
"GTE",
vec![
f("volume"),
op(
"MUL",
vec![
op(
"LAG",
vec![op(
"ROLLING_MEAN",
vec![f("volume")],
Some(p["volume_window"].as_u64().unwrap() as usize),
)],
Some(1),
),
n(p["volume_multiple"].as_f64().unwrap()),
],
None,
),
],
None,
),
_ => return Err("session_event_not_registered".into()),
})
}
pub fn evaluate(
spec: &PatternSpec,
symbol: &str,
bars: &[MinuteBar],
decision: NaiveDateTime,
) -> Result<PatternResult, String> {
let mut result = PatternResult {
symbol: symbol.into(),
name: None,
matched: false,
score: None,
checks: vec![],
values: json!({}),
anchor: Value::Null,
exclusion: None,
};
if bars.is_empty() {
return Err(format!(
"session_source_missing: {symbol} {}",
decision.date()
));
}
let visible = bars
.iter()
.filter(|b| {
b.timestamp.date() == decision.date()
&& b.timestamp < decision
&& b.available_at <= decision
})
.collect::<Vec<_>>();
if visible.is_empty() {
result.exclusion = Some(json!({"reason":"session_before_first_completed_bar"}));
return Ok(result);
}
let last = visible.last().unwrap().timestamp;
let expected = (570..=690)
.chain(781..=900)
.map(|m| decision.date().and_time(time(m)))
.filter(|t| *t < decision)
.last();
if expected != Some(last) {
return Err(format!(
"session_latest_bar_missing: {symbol} expected={expected:?} actual={last}"
));
}
let mut indexed = BTreeMap::new();
for b in &visible {
if b.symbol != symbol
|| !is_regular_label(b.timestamp.time())
|| b.available_at < b.timestamp
|| [b.open, b.high, b.low, b.close, b.volume, b.amount]
.iter()
.any(|v| !v.is_finite())
|| b.low <= 0.
|| b.open <= 0.
|| b.close <= 0.
|| b.high < b.open.max(b.close)
|| b.low > b.open.min(b.close)
|| b.volume < 0.
|| b.amount < 0.
|| indexed.insert(b.timestamp, b).is_some()
{
return Err(format!("session_bar_invalid: {symbol} {}", b.timestamp));
}
}
for minute in (571..=690).chain(781..=900) {
let stamp = decision.date().and_time(time(minute));
if stamp <= last && !indexed.contains_key(&stamp) {
return Err(format!(
"session_bar_gap: {symbol} {stamp}; no filling or calendar compression"
));
}
}
let opening_end = time(570 + spec.n("opening_minutes") as u32);
let (mut volume, mut amount) = (0., 0.);
let (mut opening_high, mut opening_low) = (f64::NEG_INFINITY, f64::INFINITY);
let (mut morning_high, mut morning_low) = (f64::NEG_INFINITY, f64::INFINITY);
let (mut morning_close, mut late_close) = (None, None);
let mut fields: BTreeMap<String, Vec<Option<f64>>> = [
"open",
"high",
"low",
"close",
"volume",
"amount",
"session_vwap",
"opening_high",
"opening_low",
"morning_high",
"morning_low",
"afternoon_return",
"late_return",
]
.into_iter()
.map(|s| (s.into(), vec![]))
.collect();
let mut timestamps = vec![];
let mut available_at = vec![];
let zone = FixedOffset::east_opt(8 * 3600).unwrap();
for b in indexed.values() {
let t = b.timestamp.time();
volume += b.volume;
amount += b.amount;
if t <= opening_end {
opening_high = opening_high.max(b.high);
opening_low = opening_low.min(b.low);
}
if t <= time(690) {
morning_high = morning_high.max(b.high);
morning_low = morning_low.min(b.low);
}
if t == time(690) {
morning_close = Some(b.close);
}
if t == time(870) {
late_close = Some(b.close);
}
for (name, value) in [
("open", Some(b.open)),
("high", Some(b.high)),
("low", Some(b.low)),
("close", Some(b.close)),
("volume", Some(b.volume)),
("amount", Some(b.amount)),
("session_vwap", (volume > 0.).then_some(amount / volume)),
("opening_high", (t >= opening_end).then_some(opening_high)),
("opening_low", (t >= opening_end).then_some(opening_low)),
("morning_high", (t >= time(690)).then_some(morning_high)),
("morning_low", (t >= time(690)).then_some(morning_low)),
("afternoon_return", morning_close.map(|v| b.close / v - 1.)),
("late_return", late_close.map(|v| b.close / v - 1.)),
] {
fields.get_mut(name).unwrap().push(value);
}
timestamps.push(zone.from_local_datetime(&b.timestamp).single().unwrap());
available_at.push(zone.from_local_datetime(&b.available_at).single().unwrap());
}
let frame = Frame {
symbol: symbol.into(),
frequency: "1m".into(),
decision_at: zone.from_local_datetime(&decision).single().unwrap(),
timestamps,
available_at,
fields,
};
let event = spec
.session_event
.as_deref()
.ok_or("session_event_id_required")?;
let values = crate::factor_events::evaluate(&expression(event, &spec.parameters)?, &frame)?;
let latest = values.values.last().copied().flatten();
result.score = latest;
result.matched = latest == Some(1.);
result.values = json!({"session_event":event,"session_contract":CONTRACT,"expression":values,"signal_bar_end":last,"decision_at":decision,"bars":visible.len(),"bar_times":frame.timestamps.iter().map(|t|t.format("%Y-%m-%dT%H:%M:%S").to_string()).collect::<Vec<_>>(),"close":visible.last().unwrap().close,"session_return":visible.last().unwrap().close/visible.first().unwrap().open-1.,"price_policy":"same_session_raw_ohlcv"});
if latest.is_none() {
result.exclusion = Some(json!({"reason":"session_warmup_or_undefined"}));
} else {
result.checks.push(json!({"label":"分钟事件","actual":latest,"operator":"==","threshold":1,"passed":result.matched}));
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(event: &str) -> PatternSpec {
serde_json::from_value::<PatternSpec>(
json!({"template":"session_event","session_event":event,"parameters":{}}),
)
.unwrap()
.validate()
.unwrap()
}
fn bars() -> Vec<MinuteBar> {
let date = chrono::NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
(570..=690)
.chain(781..=900)
.enumerate()
.map(|(i, m)| {
let timestamp = date.and_time(time(m));
let price = 100. + (i % 17) as f64 / 10.;
let volume = if i % 39 == 0 { 1000. } else { 100. };
MinuteBar {
symbol: "300395.SZ".into(),
timestamp,
available_at: timestamp,
open: price,
high: price + 0.1,
low: price - 0.1,
close: price,
volume,
amount: volume * price,
}
})
.collect()
}
#[test]
fn all_thirteen_events_return_native_boolean_series() {
let bars = bars();
let decision = "2026-09-08T15:00:01".parse().unwrap();
for event in EVENTS {
let value = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
assert!(value.score.is_some(), "{event}");
assert_eq!(value.values["expression"]["value_type"], "boolean");
}
}
#[test]
fn decision_uses_the_previous_completed_label_and_future_prices_do_not_rewrite() {
let mut bars = bars();
let decision = "2026-09-08T10:02:00".parse().unwrap();
for event in EVENTS {
let before = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
for bar in &mut bars {
if bar.timestamp >= decision {
bar.open = 1000.;
bar.close = 1000.;
bar.high = 1001.;
bar.low = 999.;
}
}
let after = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
assert_eq!(before.values, after.values);
assert_eq!(after.values["signal_bar_end"], "2026-09-08T10:01:00");
}
}
#[test]
fn gaps_and_stale_last_bars_do_not_become_false_or_repeated_signals() {
let mut values = bars();
let decision = "2026-09-08T10:02:00".parse().unwrap();
values.retain(|r| r.timestamp.time() != time(600));
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &values, decision)
.unwrap_err()
.contains("session_bar_gap"));
let stale = bars()
.into_iter()
.filter(|r| r.timestamp.time() < time(601))
.collect::<Vec<_>>();
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &stale, decision)
.unwrap_err()
.contains("latest_bar_missing"));
}
#[test]
fn opening_range_is_unavailable_before_the_range_has_completed() {
let value = evaluate(
&spec("OPENING_RANGE_BREAKOUT_UP"),
"300395.SZ",
&bars(),
"2026-09-08T09:59:01".parse().unwrap(),
)
.unwrap();
assert_eq!(value.score, None);
assert!(!value.matched);
}
}
+590
View File
@@ -0,0 +1,590 @@
//! Immutable, account-independent trading signals. Quantity and execution
//! prices are intentionally absent; the existing broker owns those decisions.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::strategy::{OrderIntent, StrategyContext};
use crate::portfolio::PortfolioState;
pub const SIGNAL_BOOK_SCHEMA: &str = "fidc.signal-book/v2";
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SignalBookReference {
pub book_id: String,
pub version_sha256: String,
pub artifact_sha256: String,
}
impl SignalBookReference {
pub fn validate(&self) -> Result<(), String> {
if !valid_sha(&self.version_sha256) || !valid_sha(&self.artifact_sha256)
|| self.book_id != format!("signal_book_{}",self.version_sha256)
{ return Err("signal_book_reference_invalid".into()); }
Ok(())
}
}
#[derive(Default)]
struct SignalCache {
entries: BTreeMap<String,Weak<ValidatedSignalBook>>,
retained: std::collections::VecDeque<(String,Arc<ValidatedSignalBook>,usize)>,
}
fn signal_cache() -> &'static Mutex<SignalCache> {
static CACHE: OnceLock<Mutex<SignalCache>> = OnceLock::new();
CACHE.get_or_init(||Mutex::new(SignalCache::default()))
}
pub fn cached_signal_book(reference: &SignalBookReference) -> Result<Option<Arc<ValidatedSignalBook>>,String> {
reference.validate()?;
let cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
let book=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade);
if book.as_ref().is_some_and(|book|book.version_sha256()!=reference.version_sha256) {
return Err("signal_book_cached_version_mismatch".into());
}
Ok(book)
}
pub fn register_signal_book(reference: &SignalBookReference, body: &[u8]) -> Result<Arc<ValidatedSignalBook>,String> {
use sha2::{Digest,Sha256};
reference.validate()?;
if body.len()>64*1024*1024 || format!("{:x}",Sha256::digest(body))!=reference.artifact_sha256 {
return Err("signal_book_artifact_hash_or_size_invalid".into());
}
let raw:SignalBook=serde_json::from_slice(body).map_err(|error|format!("signal_book_decode_failed: {error}"))?;
if raw.version_sha256!=reference.version_sha256 { return Err("signal_book_version_mismatch".into()); }
let book=Arc::new(raw.validate()?);
let mut cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
cache.entries.retain(|_,value|value.strong_count()>0);
if let Some(existing)=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade) { return Ok(existing); }
cache.entries.insert(reference.artifact_sha256.clone(),Arc::downgrade(&book));
let estimated=body.len().saturating_mul(4);
if estimated<=128*1024*1024 {
cache.retained.push_back((reference.artifact_sha256.clone(),book.clone(),estimated));
while cache.retained.len()>4 || cache.retained.iter().map(|entry|entry.2).sum::<usize>()>128*1024*1024 {
cache.retained.pop_front();
}
}
Ok(book)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SignalProvenance {
Observed,
Reconstructed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SignalFrequency {
Daily,
Minute,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum SignalAction {
TargetWeight { symbol: String, weight: f64 },
BuyCondition { symbol: String, allowed: bool },
Exit { symbol: String },
Reduce { symbol: String, remaining_ratio: f64 },
}
impl SignalAction {
fn symbol(&self) -> &str {
match self {
Self::TargetWeight { symbol, .. }
| Self::BuyCondition { symbol, .. }
| Self::Exit { symbol }
| Self::Reduce { symbol, .. } => symbol,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SignalSnapshot {
pub signal_at: DateTime<Utc>,
pub decision_at: DateTime<Utc>,
pub input_as_of: DateTime<Utc>,
pub input_available_at: DateTime<Utc>,
pub generated_at: DateTime<Utc>,
pub published_at: DateTime<Utc>,
pub input_sha256: String,
pub complete_targets: bool,
pub actions: Vec<SignalAction>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SignalBook {
pub schema: String,
pub version_sha256: String,
pub generator_sha256: String,
pub model_sha256: Option<String>,
pub knowledge_cutoff: Option<DateTime<Utc>>,
pub provenance: SignalProvenance,
pub frequency: SignalFrequency,
pub expected_decisions: Vec<DateTime<Utc>>,
pub snapshots: Vec<SignalSnapshot>,
}
#[derive(Debug, Clone)]
pub struct ValidatedSignalBook {
book: SignalBook,
index: BTreeMap<NaiveDateTime, usize>,
}
fn valid_sha(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn shanghai(value: DateTime<Utc>) -> NaiveDateTime {
value.with_timezone(&FixedOffset::east_opt(8 * 3600).expect("Shanghai offset")).naive_local()
}
impl SignalBook {
pub fn content_sha256(&self) -> Result<String, String> {
let mut value=serde_json::to_value(self).map_err(|error|error.to_string())?;
value.as_object_mut().ok_or("signal_book_object_required")?.remove("versionSha256");
value["knowledgeCutoff"]=self.knowledge_cutoff.map(|at|serde_json::json!(at.timestamp_micros())).unwrap_or(serde_json::Value::Null);
value["expectedDecisions"]=serde_json::json!(self.expected_decisions.iter().map(DateTime::timestamp_micros).collect::<Vec<_>>());
for (raw,snapshot) in value["snapshots"].as_array_mut().ok_or("signal_snapshots_required")?.iter_mut().zip(&self.snapshots) {
let object=raw.as_object_mut().ok_or("signal_snapshot_required")?;
object.remove("generatedAt");
object.remove("publishedAt");
for (key,at) in [("signalAt",snapshot.signal_at),("decisionAt",snapshot.decision_at),
("inputAsOf",snapshot.input_as_of),("inputAvailableAt",snapshot.input_available_at)] {
object.insert(key.into(),serde_json::json!(at.timestamp_micros()));
}
for (raw,action) in object.get_mut("actions").and_then(serde_json::Value::as_array_mut).ok_or("signal_actions_required")?.iter_mut().zip(&snapshot.actions) {
match action {
SignalAction::TargetWeight{weight,..}=>raw["weight"]=serde_json::json!(format!("{:016x}",weight.to_bits())),
SignalAction::Reduce{remaining_ratio,..}=>raw["remaining_ratio"]=serde_json::json!(format!("{:016x}",remaining_ratio.to_bits())),
_=>{}
}
}
}
fn sorted(value:serde_json::Value)->serde_json::Value {
match value {
serde_json::Value::Object(map)=>serde_json::Value::Object(map.into_iter().map(|(key,value)|(key,sorted(value)))
.collect::<BTreeMap<_,_>>().into_iter().collect()),
serde_json::Value::Array(rows)=>serde_json::Value::Array(rows.into_iter().map(sorted).collect()),
other=>other,
}
}
let raw=serde_json::to_vec(&sorted(value)).map_err(|error|error.to_string())?;
Ok(format!("{:x}",Sha256::digest(raw)))
}
pub fn validate(self) -> Result<ValidatedSignalBook, String> {
if self.schema != SIGNAL_BOOK_SCHEMA || !valid_sha(&self.version_sha256)
|| !valid_sha(&self.generator_sha256)
{
return Err("signal_book_identity_invalid".into());
}
if self.model_sha256.as_ref().is_some_and(|value| !valid_sha(value))
|| self.model_sha256.is_some() != self.knowledge_cutoff.is_some()
{ return Err("signal_model_training_identity_incomplete".into()); }
if self.expected_decisions.is_empty() || self.expected_decisions.len() > 100_000
|| self.expected_decisions.len() != self.snapshots.len()
{
return Err("signal_book_decision_coverage_incomplete".into());
}
let mut index = BTreeMap::new();
let mut previous = None;
let mut total_actions = 0usize;
for (number, (expected, snapshot)) in self.expected_decisions.iter().zip(&self.snapshots).enumerate() {
if [*expected,snapshot.signal_at,snapshot.input_as_of,snapshot.input_available_at,snapshot.generated_at,snapshot.published_at]
.iter().any(|at|at.timestamp_subsec_nanos()%1000!=0) || self.knowledge_cutoff.is_some_and(|at|at.timestamp_subsec_nanos()%1000!=0) {
return Err("signal_timestamp_requires_microsecond_precision".into());
}
if snapshot.decision_at != *expected || previous.is_some_and(|value| value >= *expected) {
return Err("signal_book_decisions_duplicate_or_unordered".into());
}
previous = Some(*expected);
if self.knowledge_cutoff.is_some_and(|cutoff| cutoff > snapshot.signal_at) || snapshot.signal_at > *expected
|| snapshot.input_available_at > snapshot.signal_at || snapshot.input_as_of > snapshot.input_available_at
|| snapshot.published_at < snapshot.generated_at || !valid_sha(&snapshot.input_sha256)
|| snapshot.generated_at < snapshot.input_available_at
|| self.knowledge_cutoff.is_some_and(|cutoff| snapshot.generated_at < cutoff)
{
return Err("signal_book_future_or_invalid_input".into());
}
if self.provenance == SignalProvenance::Observed && snapshot.published_at > *expected {
return Err("observed_signal_not_available_at_decision".into());
}
total_actions = total_actions.checked_add(snapshot.actions.len()).ok_or("signal_book_action_limit")?;
if total_actions > 2_000_000 { return Err("signal_book_action_limit".into()); }
let mut action_keys = BTreeSet::new();
let mut target_symbols = BTreeSet::new();
let mut reductions = BTreeSet::new();
let mut total_weight = 0.0;
for action in &snapshot.actions {
let symbol = action.symbol();
if symbol.is_empty() || symbol.trim() != symbol { return Err("signal_symbol_invalid".into()); }
let kind = match action {
SignalAction::TargetWeight { weight, .. } => {
if !weight.is_finite() || !(0.0..=1.0).contains(weight) { return Err("signal_target_weight_invalid".into()); }
target_symbols.insert(symbol);
total_weight += weight;
"target"
}
SignalAction::BuyCondition { .. } => "buy_condition",
SignalAction::Exit { .. } => { reductions.insert(symbol); "exit" }
SignalAction::Reduce { remaining_ratio, .. } => {
if !remaining_ratio.is_finite() || !(0.0..1.0).contains(remaining_ratio) { return Err("signal_reduction_invalid".into()); }
reductions.insert(symbol);
"reduce"
}
};
if !action_keys.insert((symbol, kind)) { return Err("signal_action_duplicate".into()); }
}
if total_weight > 1.0 + 1e-12 { return Err("signal_target_exposure_exceeds_one".into()); }
if snapshot.complete_targets && !reductions.is_empty() {
return Err("complete_target_snapshot_cannot_mix_relative_exits".into());
}
if !target_symbols.is_disjoint(&reductions) { return Err("signal_target_exit_conflict".into()); }
for symbol in &reductions {
if action_keys.contains(&(*symbol, "exit")) && action_keys.contains(&(*symbol, "reduce")) {
return Err("signal_exit_reduction_conflict".into());
}
}
index.insert(shanghai(*expected), number);
}
if self.content_sha256()? != self.version_sha256 {
return Err("signal_book_content_hash_mismatch".into());
}
Ok(ValidatedSignalBook { book: self, index })
}
}
impl ValidatedSignalBook {
pub fn require_observed(&self) -> Result<(), String> {
if self.book.provenance != SignalProvenance::Observed {
return Err("reconstructed_signal_forbidden_in_online_execution".into());
}
Ok(())
}
pub fn version_sha256(&self) -> &str { &self.book.version_sha256 }
pub fn generator_sha256(&self) -> &str { &self.book.generator_sha256 }
pub fn decision_dates(&self) -> BTreeSet<NaiveDate> {
self.index.keys().map(|value| value.date()).collect()
}
pub fn symbols(&self) -> BTreeSet<String> {
self.book.snapshots.iter().flat_map(|snapshot| &snapshot.actions)
.map(|action| action.symbol().to_owned()).collect()
}
pub fn snapshot_for(&self, ctx: &StrategyContext<'_>) -> Result<&SignalSnapshot, String> {
let snapshot = self.snapshot_at(ctx.execution_date, ctx.current_time(), ctx.is_lagged_execution())?;
if self.book.provenance == SignalProvenance::Observed && ctx.current_datetime().is_none() {
return Err("observed_signal_consumption_clock_missing".into());
}
let consumption_clock=ctx.current_datetime()
.unwrap_or(ctx.decision_date.and_hms_opt(15,0,0).expect("completed decision session"));
let lagged_daily=ctx.is_lagged_execution() && self.book.frequency==SignalFrequency::Daily;
if lagged_daily && shanghai(snapshot.input_as_of).date()>ctx.decision_date {
return Err("next_open_signal_contains_execution_session_inputs".into());
}
if shanghai(snapshot.input_available_at)>consumption_clock || shanghai(snapshot.signal_at)>consumption_clock {
return Err("signal_not_available_at_consumption_clock".into());
}
if self.book.provenance == SignalProvenance::Observed
&& (shanghai(snapshot.generated_at)>consumption_clock || shanghai(snapshot.published_at)>consumption_clock) {
return Err("observed_signal_published_after_consumption_clock".into());
}
Ok(snapshot)
}
pub fn is_due_on(&self, execution_date: NaiveDate) -> bool {
self.index.range(execution_date.and_hms_opt(0,0,0).expect("session start")..)
.next().is_some_and(|(at,_)|at.date()==execution_date)
}
fn snapshot_at(&self, execution_date: NaiveDate, current_time: Option<NaiveTime>, lagged: bool) -> Result<&SignalSnapshot, String> {
let at = if self.book.frequency == SignalFrequency::Daily && lagged {
execution_date.and_hms_opt(9, 30, 0).expect("next open")
} else {
execution_date.and_time(current_time.unwrap_or(NaiveTime::from_hms_opt(15, 0, 0).expect("daily close")))
};
self.index.get(&at).map(|index| &self.book.snapshots[*index])
.ok_or_else(|| format!("signal_snapshot_missing_at_decision: {at}"))
}
pub fn intents(&self, ctx: &StrategyContext<'_>) -> Result<Vec<OrderIntent>, String> {
let snapshot = self.snapshot_for(ctx)?;
self.snapshot_intents(snapshot, ctx.portfolio)
}
fn snapshot_intents(&self, snapshot: &SignalSnapshot, portfolio: &PortfolioState) -> Result<Vec<OrderIntent>, String> {
let reason = format!("信号执行 version={} decision={}", self.book.version_sha256, snapshot.decision_at);
let mut intents = Vec::new();
let mut weights = BTreeMap::new();
for action in &snapshot.actions {
match action {
SignalAction::TargetWeight { symbol, weight } if snapshot.complete_targets => {
weights.insert(symbol.clone(), *weight);
}
SignalAction::TargetWeight { symbol, weight } => intents.push(OrderIntent::TargetPercent {
symbol: symbol.clone(), target_percent: *weight, reason: reason.clone(),
}),
SignalAction::Exit { symbol } => intents.push(OrderIntent::TargetPercent {
symbol: symbol.clone(), target_percent: 0.0, reason: reason.clone(),
}),
SignalAction::Reduce { symbol, remaining_ratio } => {
if let Some(position) = portfolio.position(symbol).filter(|position| position.quantity > 0) {
let quantity = (f64::from(position.quantity) * remaining_ratio).floor() as u32;
let target_quantity = i32::try_from(quantity).map_err(|_| "signal_reduction_quantity_overflow")?;
intents.push(OrderIntent::TargetShares { symbol: symbol.clone(), target_quantity, reason: reason.clone() });
}
}
SignalAction::BuyCondition { .. } => {}
}
}
if snapshot.complete_targets {
if weights.is_empty() {
for position in portfolio.positions().values().filter(|position| position.quantity > 0) {
intents.push(OrderIntent::TargetPercent { symbol: position.symbol.clone(), target_percent: 0.0, reason: reason.clone() });
}
} else {
intents.push(OrderIntent::TargetPortfolioSmart { target_weights: weights,
order_prices: None, valuation_prices: None, reason });
}
}
Ok(intents)
}
pub fn buy_denials(&self, ctx: &StrategyContext<'_>) -> Result<BTreeMap<String, String>, String> {
Ok(self.snapshot_for(ctx)?.actions.iter().filter_map(|action| match action {
SignalAction::BuyCondition { symbol, allowed: false } => Some((symbol.clone(), "信号买入条件未满足".into())),
_ => None,
}).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use serde_json::json;
fn book() -> SignalBook {
let decision: DateTime<Utc> = "2025-01-07T09:30:00+08:00".parse().unwrap();
let source: DateTime<Utc> = "2025-01-06T15:00:00+08:00".parse().unwrap();
seal(SignalBook {
schema: SIGNAL_BOOK_SCHEMA.into(), version_sha256: "a".repeat(64), generator_sha256: "b".repeat(64),
model_sha256: Some("d".repeat(64)),
knowledge_cutoff: Some("2024-12-31T15:00:00+08:00".parse().unwrap()),
provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily,
expected_decisions: vec![decision], snapshots: vec![SignalSnapshot {
signal_at: source,
decision_at: decision, input_as_of: source, input_available_at: source,
generated_at: decision + Duration::days(10), published_at: decision + Duration::days(10),
input_sha256: "c".repeat(64), complete_targets: true,
actions: vec![SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight: 0.5 }],
}],
})
}
fn seal(mut book:SignalBook)->SignalBook {
book.version_sha256=book.content_sha256().unwrap();
book
}
fn at_context<T>(at: Option<NaiveDateTime>, action: impl FnOnce(&StrategyContext<'_>) -> T) -> T {
let data = crate::DataSet::from_components(vec![], vec![], vec![], vec![], vec![crate::BenchmarkSnapshot {
date:NaiveDate::from_ymd_opt(2025,1,6).unwrap(), benchmark:"clock-fixture".into(),
open:100.0, close:100.0, prev_close:100.0, volume:1,
}]).unwrap();
let portfolio = PortfolioState::new(10_000.0);
let symbols = BTreeSet::new();
action(&StrategyContext {
execution_date: NaiveDate::from_ymd_opt(2025,1,7).unwrap(),
decision_date: NaiveDate::from_ymd_opt(2025,1,6).unwrap(), decision_index:0,
data:&data, portfolio:&portfolio, futures_account:None, open_orders:&[],
dynamic_universe:None, subscriptions:&symbols, process_events:&[], active_process_event:None,
active_datetime:at, order_events:&[], fills:&[],
})
}
#[test]
fn observed_next_open_never_backdates_a_morning_publication_into_yesterdays_orders() {
let mut raw = book();
raw.provenance=SignalProvenance::Observed;
raw.snapshots[0].generated_at="2025-01-07T08:45:00+08:00".parse().unwrap();
raw.snapshots[0].published_at="2025-01-07T08:46:00+08:00".parse().unwrap();
let value=seal(raw).validate().unwrap();
for clock in ["2025-01-06T15:00:00", "2025-01-07T08:45:00"] {
at_context(Some(clock.parse().unwrap()), |ctx| {
assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_published_after_consumption_clock");
assert!(ctx.portfolio.positions().is_empty());
});
}
at_context(Some("2025-01-07T09:30:00".parse().unwrap()), |ctx| {
assert_eq!(value.intents(ctx).unwrap().len(),1);
assert!(ctx.portfolio.positions().is_empty());
});
at_context(None, |ctx| assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_consumption_clock_missing"));
}
#[test]
fn reconstruction_ignores_research_wall_clock_but_never_early_input_availability() {
let value=book().validate().unwrap();
at_context(Some("2025-01-06T15:00:00".parse().unwrap()), |ctx| assert!(value.intents(ctx).is_ok()));
at_context(Some("2025-01-06T14:59:59".parse().unwrap()), |ctx| {
assert_eq!(value.intents(ctx).unwrap_err(),"signal_not_available_at_consumption_clock");
});
let mut raw=book();
raw.snapshots[0].input_as_of="2025-01-07T08:30:00+08:00".parse().unwrap();
raw.snapshots[0].input_available_at=raw.snapshots[0].input_as_of;
raw.snapshots[0].signal_at=raw.snapshots[0].input_as_of;
let value=seal(raw).validate().unwrap();
at_context(Some("2025-01-07T09:30:00".parse().unwrap()), |ctx| {
assert_eq!(value.intents(ctx).unwrap_err(),"next_open_signal_contains_execution_session_inputs");
});
}
#[test]
fn historical_reconstruction_is_not_online_publication() {
let validated = book().validate().unwrap();
assert!(validated.require_observed().unwrap_err().contains("reconstructed"));
let mut observed = book();
observed.provenance = SignalProvenance::Observed;
assert!(observed.clone().validate().unwrap_err().contains("not_available"));
observed.snapshots[0].generated_at = observed.snapshots[0].decision_at;
observed.snapshots[0].published_at = observed.snapshots[0].decision_at;
seal(observed).validate().unwrap().require_observed().unwrap();
}
#[test]
fn rejects_future_inputs_and_model_knowledge() {
for field in 0..3 {
let mut value = book();
let future = value.snapshots[0].decision_at + Duration::seconds(1);
match field {
0 => value.snapshots[0].input_as_of = future,
1 => value.snapshots[0].input_available_at = future,
_ => value.knowledge_cutoff = Some(future),
}
assert!(value.validate().unwrap_err().contains("future"));
}
}
#[test]
fn rejects_quantities_prices_and_unknown_signal_fields() {
for name in ["quantity", "execution_price", "account_id", "cash"] {
let mut action = json!({"kind":"target_weight","symbol":"000001.SZ","weight":0.5});
action[name] = json!(100);
assert!(serde_json::from_value::<SignalAction>(action).is_err());
}
}
#[test]
fn coverage_and_duplicate_actions_fail_closed() {
let mut value = book();
value.expected_decisions.push(value.expected_decisions[0] + Duration::days(1));
assert!(value.validate().unwrap_err().contains("coverage"));
let mut value = book();
value.snapshots.push(value.snapshots[0].clone());
value.expected_decisions.push(value.expected_decisions[0]);
assert!(value.validate().unwrap_err().contains("duplicate"));
let mut value = book();
let repeated = value.snapshots[0].actions[0].clone();
value.snapshots[0].actions.push(repeated);
assert!(value.validate().unwrap_err().contains("duplicate"));
}
#[test]
fn rejects_overallocation_nonfinite_and_ambiguous_actions() {
for weight in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
let mut value = book();
value.snapshots[0].actions[0] = SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight };
assert!(value.validate().is_err());
}
let mut value = book();
value.snapshots[0].actions.push(SignalAction::TargetWeight { symbol:"000002.SZ".into(),weight:0.6 });
assert!(value.validate().unwrap_err().contains("exposure"));
let mut value = book();
value.snapshots[0].actions.push(SignalAction::Exit {symbol:"000001.SZ".into()});
assert!(value.validate().is_err());
}
#[test]
fn next_open_uses_decision_session_and_never_nearest_signal() {
let value = book().validate().unwrap();
let day = NaiveDate::from_ymd_opt(2025,1,7).unwrap();
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(9,30,0), true).is_ok());
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(14,59,0), false).is_err());
assert!(value.snapshot_at(day + Duration::days(1), None, true).is_err());
}
#[test]
fn reduction_is_resolved_from_each_accounts_actual_position() {
let mut raw = book();
raw.snapshots[0].complete_targets = false;
raw.snapshots[0].actions = vec![SignalAction::Reduce {symbol:"000001.SZ".into(),remaining_ratio:0.5}];
let value = seal(raw).validate().unwrap();
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
for (held, expected) in [(1000,500),(3000,1500)] {
let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut("000001.SZ").buy(day,held,10.0);
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
assert!(matches!(result[0],OrderIntent::TargetShares {target_quantity,..} if target_quantity==expected));
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity,held);
}
assert!(value.snapshot_intents(&value.book.snapshots[0],&PortfolioState::new(10_000.0)).unwrap().is_empty());
}
#[test]
fn empty_complete_snapshot_clears_only_that_accounts_holdings() {
let mut raw = book();
raw.snapshots[0].actions.clear();
let value = seal(raw).validate().unwrap();
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut("000002.SZ").buy(day,200,10.0);
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
assert!(matches!(&result[0],OrderIntent::TargetPercent {symbol,target_percent,..} if symbol=="000002.SZ" && *target_percent==0.0));
}
#[test]
fn platform_spec_consumes_book_without_running_another_selection() {
let spec = json!({"signalBook":book(),"runtimeExpressions":{"trading":{"actions":[{"kind":"consume_signal"}]}}});
let config = crate::platform_strategy_spec::platform_expr_config_from_value("signal-fixture","000001.SZ",&spec).unwrap();
assert!(!config.rotation_enabled && config.signal_book.is_some());
assert!(matches!(config.explicit_actions.as_slice(),[crate::PlatformTradeAction::ConsumeSignal]));
}
#[test]
fn changed_valid_contents_must_not_reuse_a_version_hash() {
let mut raw=book();
raw.snapshots[0].actions=vec![SignalAction::TargetWeight{symbol:"000001.SZ".into(),weight:0.4}];
assert_eq!(raw.clone().validate().unwrap_err(),"signal_book_content_hash_mismatch");
seal(raw).validate().unwrap();
}
#[test]
fn completed_daily_inputs_may_be_published_after_market_close() {
let mut raw=book();
raw.expected_decisions=vec!["2026-07-07T09:30:00+08:00".parse().unwrap()];
raw.snapshots[0].decision_at=raw.expected_decisions[0];
raw.snapshots[0].input_as_of="2026-07-06T15:30:00+08:00".parse().unwrap();
raw.snapshots[0].input_available_at="2026-07-06T16:00:00+08:00".parse().unwrap();
raw.snapshots[0].signal_at=raw.snapshots[0].input_available_at;
raw.snapshots[0].generated_at=raw.snapshots[0].input_available_at;
raw.snapshots[0].published_at=raw.snapshots[0].generated_at;
raw.provenance=SignalProvenance::Observed;
seal(raw).validate().unwrap().require_observed().unwrap();
}
}
@@ -0,0 +1,229 @@
//! Candidate provenance and ordering; contains no market-data or broker I/O.
use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
pub const CANDIDATE_SOURCES_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CandidateSourceMode {
Manual,
FilteredManual,
Automatic,
Mixed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CandidateSourcePriority {
#[default]
ManualFirst,
AutomaticFirst,
ListOrder,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CandidateSourcePolicy {
pub schema_version: u32,
pub mode: CandidateSourceMode,
#[serde(default)]
pub priority: CandidateSourcePriority,
#[serde(default)]
pub merged_order: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CandidateMember {
pub symbol: String,
pub manual: bool,
pub automatic: bool,
}
fn symbols(values: &[String], label: &str) -> Result<Vec<String>, String> {
let mut seen = BTreeSet::new();
values.iter().map(|value| {
let symbol = value.trim().to_ascii_uppercase();
if !symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
code.len() == 6 && code.bytes().all(|byte| byte.is_ascii_digit())
&& matches!(exchange, "SH" | "SZ" | "BJ")
}) {
return Err(format!("{label}: invalid qualified security code {value}"));
}
if !seen.insert(symbol.clone()) {
return Err(format!("{label}: duplicate security {symbol}"));
}
Ok(symbol)
}).collect()
}
impl CandidateSourcePolicy {
pub fn validate(&self) -> Result<(), String> {
if self.schema_version != CANDIDATE_SOURCES_SCHEMA {
return Err("candidate_sources schema_version must be 1".into());
}
symbols(&self.merged_order, "candidate_sources.merged_order")?;
if self.mode != CandidateSourceMode::Mixed && self.priority != CandidateSourcePriority::ManualFirst {
return Err("candidate source priority only applies to mixed sources".into());
}
if self.priority != CandidateSourcePriority::ListOrder && !self.merged_order.is_empty() {
return Err("merged_order requires list_order priority".into());
}
Ok(())
}
pub fn uses_screen(&self) -> bool {
self.mode != CandidateSourceMode::Manual
}
pub fn validate_screen_binding(&self, manual: &[String], has_screen: bool) -> Result<(), String> {
self.validate()?;
symbols(manual, "manual candidates")?;
if self.uses_screen() != has_screen {
return Err("candidate source mode and screen contract must agree".into());
}
if self.mode == CandidateSourceMode::FilteredManual && manual.is_empty() {
return Err("filtered_manual requires manual members; an empty scope must not become all-market".into());
}
Ok(())
}
}
/// Overlap between two valid sources denotes one member with both provenance
/// flags. Duplicates *within* a source are invalid evidence, not fixed by dedup.
pub fn resolve_candidates(
policy: &CandidateSourcePolicy,
manual: &[String],
automatic: Option<&[String]>,
) -> Result<Vec<CandidateMember>, String> {
policy.validate_screen_binding(manual, automatic.is_some())?;
let manual = symbols(manual, "manual candidates")?;
let automatic = automatic.map(|values| symbols(values, "automatic candidates")).transpose()?.unwrap_or_default();
let manual_set = manual.iter().cloned().collect::<BTreeSet<_>>();
let auto_set = automatic.iter().cloned().collect::<BTreeSet<_>>();
if policy.mode == CandidateSourceMode::FilteredManual && !auto_set.is_subset(&manual_set) {
return Err("filtered_manual snapshot contains a security outside the manual scope".into());
}
let mut ordered = match policy.mode {
CandidateSourceMode::Manual => manual.clone(),
CandidateSourceMode::FilteredManual | CandidateSourceMode::Automatic => automatic.clone(),
CandidateSourceMode::Mixed => {
let (first, second) = if policy.priority == CandidateSourcePriority::AutomaticFirst {
(&automatic, &manual)
} else { (&manual, &automatic) };
let mut union = first.clone();
let mut seen = first.iter().cloned().collect::<BTreeSet<_>>();
union.extend(second.iter().filter(|symbol| seen.insert((*symbol).clone())).cloned());
union
}
};
if policy.priority == CandidateSourcePriority::ListOrder {
let present = ordered.iter().cloned().collect::<BTreeSet<_>>();
let prefix = symbols(&policy.merged_order, "candidate_sources.merged_order")?
.into_iter().filter(|symbol| present.contains(symbol)).collect::<Vec<_>>();
let selected = prefix.iter().cloned().collect::<BTreeSet<_>>();
let tail = ordered.into_iter().filter(|symbol| !selected.contains(symbol));
ordered = prefix.into_iter().chain(tail).collect();
}
Ok(ordered.into_iter().map(|symbol| CandidateMember {
manual: manual_set.contains(&symbol), automatic: auto_set.contains(&symbol), symbol,
}).collect())
}
/// Raw daily automatic candidates remain unchanged. Every resolved list is
/// derived by the shared kernel; absent dates never inherit yesterday's list.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CandidateSourceBook {
pub schema_version: u32,
pub policy: CandidateSourcePolicy,
pub manual_symbols: Vec<String>,
pub automatic_symbols_by_date: BTreeMap<NaiveDate, Vec<String>>,
pub source_snapshot_sha256: String,
pub source_coverage_sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_symbols: Option<Vec<String>>,
}
impl CandidateSourceBook {
pub fn resolve(&self) -> Result<BTreeMap<NaiveDate, Vec<CandidateMember>>, String> {
if self.schema_version != CANDIDATE_SOURCES_SCHEMA || !self.policy.uses_screen() {
return Err("candidate source book requires schema 1 and a screened source".into());
}
for value in [&self.source_snapshot_sha256, &self.source_coverage_sha256] {
if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("candidate source book requires snapshot and coverage SHA256".into());
}
}
if self.automatic_symbols_by_date.is_empty() {
return Err("candidate source book requires explicit covered trading dates".into());
}
let scope = self.execution_symbols.as_ref().map(|values| symbols(values, "candidate execution scope")
.map(|values| values.into_iter().collect::<BTreeSet<_>>())).transpose()?;
self.automatic_symbols_by_date.iter().map(|(day, values)| {
resolve_candidates(&self.policy, &self.manual_symbols, Some(values))
.map(|members| (*day, members.into_iter().filter(|member| scope.as_ref().is_none_or(|scope| scope.contains(&member.symbol))).collect()))
}).collect()
}
pub fn resolved_symbols(&self) -> Result<BTreeMap<NaiveDate, Vec<String>>, String> {
Ok(self.resolve()?.into_iter().map(|(date, values)|
(date, values.into_iter().map(|member| member.symbol).collect())).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn list(values: &[&str]) -> Vec<String> { values.iter().map(|value| value.to_string()).collect() }
fn policy(mode: CandidateSourceMode, priority: CandidateSourcePriority) -> CandidateSourcePolicy {
CandidateSourcePolicy { schema_version: 1, mode, priority, merged_order: vec![] }
}
#[test]
fn mixed_sources_preserve_priority_and_both_provenances() {
let manual = list(&["600000.SH", "000001.SZ"]);
let automatic = list(&["000002.SZ", "000001.SZ"]);
for (priority, expected) in [
(CandidateSourcePriority::ManualFirst, list(&["600000.SH", "000001.SZ", "000002.SZ"])),
(CandidateSourcePriority::AutomaticFirst, list(&["000002.SZ", "000001.SZ", "600000.SH"])),
] {
let result = resolve_candidates(&policy(CandidateSourceMode::Mixed, priority), &manual, Some(&automatic)).unwrap();
assert_eq!(result.iter().map(|value| value.symbol.clone()).collect::<Vec<_>>(), expected);
let overlap = result.iter().find(|value| value.symbol == "000001.SZ").unwrap();
assert!(overlap.manual && overlap.automatic);
}
}
#[test]
fn list_order_reuses_explicit_prefix_and_appends_new_candidates() {
let mut p = policy(CandidateSourceMode::Mixed, CandidateSourcePriority::ListOrder);
p.merged_order = list(&["000002.SZ", "600036.SH", "600000.SH"]);
let result = resolve_candidates(&p, &list(&["600000.SH", "000001.SZ"]), Some(&list(&["000002.SZ", "000003.SZ"]))).unwrap();
assert_eq!(result.into_iter().map(|row| row.symbol).collect::<Vec<_>>(), list(&["000002.SZ", "600000.SH", "000001.SZ", "000003.SZ"]));
}
#[test]
fn missing_snapshot_duplicate_input_and_empty_filtered_scope_fail() {
let p = policy(CandidateSourceMode::Mixed, CandidateSourcePriority::ManualFirst);
assert!(resolve_candidates(&p, &[], None).is_err());
assert!(resolve_candidates(&p, &[], Some(&list(&["000001.SZ", "000001.sz"]))).is_err());
let p = policy(CandidateSourceMode::FilteredManual, CandidateSourcePriority::ManualFirst);
assert!(resolve_candidates(&p, &[], Some(&[])).unwrap_err().contains("all-market"));
assert!(resolve_candidates(&p, &list(&["000001.SZ"]), Some(&list(&["600000.SH"]))).is_err());
}
#[test]
fn zero_automatic_day_keeps_manual_members_without_inheriting_old_auto_targets() {
let day1 = NaiveDate::from_ymd_opt(2026, 9, 9).unwrap();
let day2 = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
let book = CandidateSourceBook { schema_version: 1,
policy: policy(CandidateSourceMode::Mixed, CandidateSourcePriority::AutomaticFirst),
manual_symbols: list(&["510300.SH"]),
automatic_symbols_by_date: BTreeMap::from([(day1, list(&["000001.SZ"])), (day2, vec![])]),
source_snapshot_sha256: "a".repeat(64), source_coverage_sha256: "b".repeat(64), execution_symbols:None };
let result = book.resolved_symbols().unwrap();
assert_eq!(result[&day1], list(&["000001.SZ", "510300.SH"]));
assert_eq!(result[&day2], list(&["510300.SH"]));
let mut auto = book; auto.policy = policy(CandidateSourceMode::Automatic, CandidateSourcePriority::ManualFirst);
assert!(auto.resolved_symbols().unwrap()[&day2].is_empty());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
//! Dated non-tradability and valuation are separate from execution quotations.
use super::*;
pub(super) fn validate(
signal_date: NaiveDate,
constraints: &StockPoolDecisionConstraints,
current: &BTreeMap<String, (Decimal, Decimal, Decimal)>,
) -> Result<(), String> {
for (symbol, fact) in &constraints.frozen_positions {
if constraints.execution_date != Some(fact.trade_date)
|| fact.trade_date < signal_date
|| fact.reason != "paused"
|| fact.valuation_price <= Decimal::ZERO
|| current.get(symbol).is_none_or(|row| row.0 <= Decimal::ZERO)
{
return Err(format!("stock_pool_frozen_position_invalid:{symbol}"));
}
}
if constraints
.prior_target_weights
.iter()
.any(|(symbol, weight)| {
normalize_stock_symbol(symbol).as_ref() != Some(symbol)
|| !(0..=10_000).contains(weight)
})
{
return Err("stock_pool_prior_target_weights_invalid".into());
}
Ok(())
}
pub(super) fn valuation(
symbol: &str,
quotes: &HashMap<String, &MarketSnapshot>,
frozen: &BTreeMap<String, FrozenStockPoolPosition>,
) -> Result<Decimal, String> {
frozen
.get(symbol)
.map(|fact| fact.valuation_price)
.or_else(|| quotes.get(symbol).map(|quote| quote.last_price))
.filter(|price| *price > Decimal::ZERO)
.ok_or_else(|| format!("{symbol} confirmed holding valuation missing"))
}
pub(super) fn weights(
original: &[String],
active: &[String],
members: &[StockPoolMemberSpec],
explicit: &BTreeMap<String, i32>,
constraints: &StockPoolDecisionConstraints,
reserved_slots: usize,
target_count: usize,
) -> Result<BTreeMap<String, i32>, String> {
let count = original.len() + reserved_slots;
let order = members
.iter()
.map(|member| (&member.symbol, member.requested_order))
.collect::<BTreeMap<_, _>>();
let mut original_budget_symbols = original.to_vec();
for symbol in constraints.frozen_positions.keys() {
if order.contains_key(symbol) && !original_budget_symbols.contains(symbol) {
original_budget_symbols.push(symbol.clone());
}
}
if original_budget_symbols.len() != original.len() {
original_budget_symbols
.sort_by_key(|symbol| order.get(symbol).copied().unwrap_or(i32::MAX));
}
let initial = original_budget_symbols
.iter()
.enumerate()
.map(|(index, symbol)| {
let weight = if explicit.is_empty() {
if count == 0 {
0
} else {
10_000 / count as i32 + i32::from(index < 10_000 % count)
}
} else {
*explicit.get(symbol).unwrap_or(&0)
};
(symbol.clone(), weight)
})
.collect::<Vec<_>>();
let mut frozen = BTreeMap::new();
for symbol in constraints.frozen_positions.keys() {
let weight = explicit
.get(symbol)
.copied()
.or_else(|| constraints.prior_target_weights.get(symbol).copied())
.or_else(|| {
initial
.iter()
.find(|(key, _)| key == symbol)
.map(|(_, weight)| *weight)
})
.ok_or_else(|| format!("stock_pool_frozen_position_target_weight_missing:{symbol}"))?;
frozen.insert(symbol.clone(), weight);
}
let frozen_total = frozen.values().copied().sum::<i32>();
if frozen_total > 10_000 {
return Err("stock_pool_frozen_position_weights_exceed_budget".into());
}
let mut free = initial
.into_iter()
.filter(|(symbol, _)| !frozen.contains_key(symbol))
.map(|(symbol, weight)| (symbol, weight as u32))
.collect::<Vec<_>>();
let total = free.iter().map(|(_, weight)| *weight).sum::<u32>();
let available = (10_000 - frozen_total) as u32;
// A paused holding removed from today's candidates still owns its prior
// budget. Scale only the new tradable allocation, never the frozen leg.
if total > available {
let mut remainder = available;
for (_, weight) in &mut free {
*weight = (u64::from(*weight) * u64::from(available) / u64::from(total)) as u32;
remainder -= *weight;
}
for (_, weight) in free.iter_mut().take(remainder as usize) {
*weight += 1;
}
}
let excluded = free
.iter()
.filter(|(symbol, _)| !active.contains(symbol))
.map(|(symbol, _)| symbol.clone())
.collect();
let candidates = active
.iter()
.filter(|symbol| !frozen.contains_key(*symbol))
.cloned()
.collect::<Vec<_>>();
let allocated = crate::platform_expr_strategy::replenish_target_weight_bps(
&free,
&candidates,
&excluded,
target_count.saturating_sub(
frozen
.keys()
.filter(|symbol| original.contains(symbol))
.count(),
),
);
frozen.extend(
allocated
.into_iter()
.map(|(symbol, weight)| (symbol, weight as i32)),
);
Ok(frozen)
}
@@ -0,0 +1,93 @@
//! Index exposure is independent of relative-weight maintenance. Reduce the
//! existing proportions only when the portfolio exceeds its index budget.
use super::*;
pub(super) struct IndexCapTarget {
pub quantity: Decimal,
pub blocked_by_t1: bool,
}
pub(super) fn remaining_index_targets(
current: &BTreeMap<String, (Decimal, Decimal, Decimal)>,
members: &HashMap<String, &StockPoolMemberSpec>,
automatic: &BTreeMap<String, crate::holding_policy::AutomaticTradePermission>,
already_planned: &[StockPoolPlanRow],
quotes: &HashMap<String, &MarketSnapshot>,
frozen: &BTreeMap<String, FrozenStockPoolPosition>,
budget: Decimal,
) -> Result<BTreeMap<String, IndexCapTarget>, String> {
struct Entry {
symbol: String,
quantity: Decimal,
minimum: Decimal,
price: Decimal,
}
let other = already_planned
.iter()
.map(|row| (&row.symbol, row))
.collect::<BTreeMap<_, _>>();
let mut fixed = Decimal::ZERO;
let mut entries = Vec::new();
for (symbol, (quantity, closable, _)) in current.iter().filter(|(_, row)| row.0 > Decimal::ZERO)
{
let price = super::frozen::valuation(symbol, quotes, frozen)?;
let minimum = (*quantity - *closable).max(Decimal::ZERO);
let remaining = other
.get(symbol)
.map(|row| row.target_quantity.min(*quantity))
.unwrap_or(*quantity)
.max(minimum);
if frozen.contains_key(symbol)
|| automatic
.get(symbol)
.is_some_and(|permission| permission.sell_denial.is_some())
{
fixed += *quantity * price;
} else if members.contains_key(symbol) && remaining > Decimal::ZERO {
entries.push(Entry {
symbol: symbol.clone(),
quantity: remaining,
minimum,
price,
});
} else {
fixed += remaining * price;
}
}
let mut remaining = entries
.iter()
.map(|row| row.quantity * row.price)
.sum::<Decimal>();
let mut available = (budget - fixed).max(Decimal::ZERO);
if remaining <= available {
return Ok(BTreeMap::new());
}
// The highest non-sellable proportions are fixed first; the remainder
// keeps its existing relative weights. No planned sale funds a new buy.
entries.sort_by(|left, right| {
(right.minimum / right.quantity)
.cmp(&(left.minimum / left.quantity))
.then_with(|| left.symbol.cmp(&right.symbol))
});
let mut result = BTreeMap::new();
for row in entries {
let scale = if remaining > Decimal::ZERO {
(available / remaining).min(Decimal::ONE)
} else {
Decimal::ZERO
};
let desired = (row.quantity * scale).floor();
let blocked = desired < row.minimum;
let target = desired.max(row.minimum).min(row.quantity);
remaining -= row.quantity * row.price;
available = (available - target * row.price).max(Decimal::ZERO);
result.insert(
row.symbol,
IndexCapTarget {
quantity: target,
blocked_by_t1: blocked,
},
);
}
Ok(result)
}
@@ -0,0 +1,344 @@
//! Explicit index timing, shared by historical and online stock-pool planners.
//! Inputs are completed official sessions, not a shortened available-row window.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MarketTimingPolicy {
pub enabled: bool,
pub index_code: Option<String>,
pub fast_window: Option<usize>,
pub slow_window: Option<usize>,
pub volatility_window: Option<usize>,
pub drawdown_window: Option<usize>,
pub bull_exposure: Option<f64>,
pub bear_exposure: Option<f64>,
pub volatility_threshold: Option<f64>,
pub volatility_cap: Option<f64>,
pub drawdown_threshold: Option<f64>,
pub drawdown_cap: Option<f64>,
}
impl MarketTimingPolicy {
pub fn from_allocation(value: &serde_json::Value) -> Result<Self, String> {
let raw = value.get("market_timing").filter(|value| !value.is_null());
let policy = raw
.map(|raw| serde_json::from_value::<Self>(raw.clone()))
.transpose()
.map_err(|error| format!("market_timing_invalid:{error}"))?
.unwrap_or_default();
policy.validate()?;
Ok(policy)
}
pub fn validate(&self) -> Result<(), String> {
if let Some(index) = &self.index_code {
let valid = index.split_once('.').is_some_and(|(code, exchange)| {
(6..=12).contains(&code.len())
&& code
.bytes()
.all(|v| v.is_ascii_uppercase() || v.is_ascii_digit())
&& matches!(exchange, "SH" | "SZ" | "CSI" | "CNI")
});
if !valid {
return Err("market_timing_index_code_invalid".into());
}
} else if self.enabled {
return Err("market_timing_index_code_required".into());
}
for (key, value) in [
("fast_window", self.fast_window),
("slow_window", self.slow_window),
("volatility_window", self.volatility_window),
("drawdown_window", self.drawdown_window),
] {
match value {
Some(value) if !(2..=250).contains(&value) => {
return Err(format!("market_timing_{key}_must_be_2_to_250"));
}
None if self.enabled => return Err(format!("market_timing_{key}_required")),
_ => {}
}
}
if let (Some(fast), Some(slow)) = (self.fast_window, self.slow_window) {
if fast >= slow {
return Err("market_timing_fast_window_must_be_less_than_slow_window".into());
}
}
for (key, value) in [
("bull_exposure", self.bull_exposure),
("bear_exposure", self.bear_exposure),
("volatility_threshold", self.volatility_threshold),
("volatility_cap", self.volatility_cap),
("drawdown_threshold", self.drawdown_threshold),
("drawdown_cap", self.drawdown_cap),
] {
match value {
Some(value) if !value.is_finite() || !(0.0..=1.0).contains(&value) => {
return Err(format!("market_timing_{key}_must_be_in_0_to_1"));
}
None if self.enabled => return Err(format!("market_timing_{key}_required")),
_ => {}
}
}
Ok(())
}
pub fn required_history(&self) -> Result<usize, String> {
self.validate()?;
if !self.enabled {
return Ok(0);
}
Ok(self
.slow_window
.unwrap()
.max(self.volatility_window.unwrap() + 1)
.max(self.drawdown_window.unwrap()))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IndexClose {
pub date: NaiveDate,
pub close: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MarketTimingInput {
pub index_code: String,
pub as_of_date: NaiveDate,
pub official_dates: Vec<NaiveDate>,
pub closes: Vec<IndexClose>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MarketTimingEvaluation {
pub index_code: String,
pub as_of_date: NaiveDate,
pub window_start: NaiveDate,
pub session_count: usize,
pub fast_ma: f64,
pub slow_ma: f64,
pub volatility: f64,
pub drawdown: f64,
pub exposure: f64,
pub reason_codes: Vec<String>,
pub policy_sha256: String,
pub input_sha256: String,
pub kernel_sha256: String,
}
pub fn implementation_sha256() -> String {
format!(
"{:x}",
Sha256::digest(include_bytes!("stock_pool_index_policy.rs"))
)
}
pub fn evaluate(
policy: &MarketTimingPolicy,
input: &MarketTimingInput,
decision_date: NaiveDate,
) -> Result<MarketTimingEvaluation, String> {
let needed = policy.required_history()?;
if needed == 0 {
return Err("market_timing_disabled_has_no_evaluation".into());
}
if policy.index_code.as_deref() != Some(input.index_code.as_str()) {
return Err("market_timing_input_index_mismatch".into());
}
if input.as_of_date > decision_date {
return Err("market_timing_future_input".into());
}
if input.official_dates.len() != needed
|| input.closes.len() != needed
|| input.official_dates.last() != Some(&input.as_of_date)
|| input
.official_dates
.windows(2)
.any(|pair| pair[0] >= pair[1])
{
return Err("market_timing_official_calendar_incomplete".into());
}
if input
.closes
.iter()
.zip(&input.official_dates)
.any(|(row, date)| row.date != *date || !row.close.is_finite() || row.close <= 0.)
{
return Err("market_timing_completed_index_rows_incomplete".into());
}
let closes = input.closes.iter().map(|row| row.close).collect::<Vec<_>>();
let mean = |window: usize| closes[needed - window..].iter().sum::<f64>() / window as f64;
let fast_ma = mean(policy.fast_window.unwrap());
let slow_ma = mean(policy.slow_window.unwrap());
let returns = closes[needed - policy.volatility_window.unwrap() - 1..]
.windows(2)
.map(|pair| pair[1] / pair[0] - 1.)
.collect::<Vec<_>>();
let average = returns.iter().sum::<f64>() / returns.len() as f64;
let volatility = (returns
.iter()
.map(|value| (value - average).powi(2))
.sum::<f64>()
/ (returns.len() - 1) as f64)
.sqrt();
let peak = closes[needed - policy.drawdown_window.unwrap()..]
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let drawdown = 1. - closes[needed - 1] / peak;
let bullish = fast_ma > slow_ma;
let mut exposure = if bullish {
policy.bull_exposure.unwrap()
} else {
policy.bear_exposure.unwrap()
};
let mut reason_codes = vec![
if bullish {
"fast_above_slow"
} else {
"fast_not_above_slow"
}
.into(),
];
if volatility >= policy.volatility_threshold.unwrap() {
exposure = exposure.min(policy.volatility_cap.unwrap());
reason_codes.push("volatility_cap".into());
}
if drawdown >= policy.drawdown_threshold.unwrap() {
exposure = exposure.min(policy.drawdown_cap.unwrap());
reason_codes.push("drawdown_cap".into());
}
if [fast_ma, slow_ma, volatility, drawdown, exposure]
.iter()
.any(|value| !value.is_finite())
{
return Err("market_timing_nonfinite_result".into());
}
let hash = |value: &serde_json::Value| -> Result<String, String> {
Ok(format!(
"{:x}",
Sha256::digest(serde_json::to_vec(value).map_err(|error| error.to_string())?)
))
};
Ok(MarketTimingEvaluation {
index_code: input.index_code.clone(),
as_of_date: input.as_of_date,
window_start: input.official_dates[0],
session_count: needed,
fast_ma,
slow_ma,
volatility,
drawdown,
exposure,
reason_codes,
policy_sha256: hash(&serde_json::to_value(policy).map_err(|error| error.to_string())?)?,
input_sha256: hash(&serde_json::to_value(input).map_err(|error| error.to_string())?)?,
kernel_sha256: implementation_sha256(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn policy() -> MarketTimingPolicy {
serde_json::from_value(serde_json::json!({"enabled":true,"index_code":"000300.SH","fast_window":10,"slow_window":30,
"volatility_window":20,"drawdown_window":60,"bull_exposure":1.0,"bear_exposure":0.3,"volatility_threshold":0.025,
"volatility_cap":0.3,"drawdown_threshold":0.08,"drawdown_cap":0.2})).unwrap()
}
fn input(values: Vec<f64>) -> MarketTimingInput {
let dates = (0..values.len())
.map(|i| {
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Duration::days(i as i64)
})
.collect::<Vec<_>>();
MarketTimingInput {
index_code: "000300.SH".into(),
as_of_date: *dates.last().unwrap(),
closes: dates
.iter()
.zip(values)
.map(|(date, close)| IndexClose { date: *date, close })
.collect(),
official_dates: dates,
}
}
#[test]
fn trend_and_caps_match_the_declared_math_and_keep_zero_meaningful() {
for (prices, expected) in [
((0..60).map(|i| 100. + i as f64).collect(), 1.),
((0..60).map(|i| 200. - i as f64).collect(), 0.2),
(
(0..59)
.map(|i| 100. + i as f64 * 0.1)
.chain([150.])
.collect(),
0.3,
),
] {
let input = input(prices);
let result = evaluate(&policy(), &input, input.as_of_date).unwrap();
assert_eq!(result.exposure, expected);
assert_eq!(result.session_count, 60);
assert_eq!(result.input_sha256.len(), 64);
}
let input = input(vec![100.; 60]);
let mut zero = policy();
zero.bear_exposure = Some(0.);
assert_eq!(
evaluate(&zero, &input, input.as_of_date).unwrap().exposure,
0.
);
}
#[test]
fn missing_or_duplicate_or_future_rows_never_shorten_the_window() {
let base = input(vec![100.; 60]);
let mut invalid = base.clone();
invalid.closes.remove(5);
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
let mut invalid = base.clone();
invalid.closes[5].date = invalid.closes[4].date;
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
let mut invalid = base.clone();
invalid.closes[5].close = f64::NAN;
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
assert!(
evaluate(
&policy(),
&base,
base.as_of_date - chrono::Duration::days(1)
)
.is_err()
);
let mut invalid = base.clone();
invalid.index_code = "000852.SH".into();
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
}
#[test]
fn disabled_is_optional_but_enabled_parameters_are_not_invented() {
assert_eq!(
MarketTimingPolicy::from_allocation(&serde_json::Value::Null)
.unwrap()
.required_history()
.unwrap(),
0
);
assert!(
MarketTimingPolicy::from_allocation(
&serde_json::json!({"market_timing":{"enabled":true}})
)
.is_err()
);
let mut invalid = policy();
invalid.fast_window = invalid.slow_window;
assert!(invalid.validate().is_err());
let mut invalid = policy();
invalid.volatility_window = Some(250);
assert_eq!(invalid.required_history().unwrap(), 251);
}
}
@@ -0,0 +1,179 @@
//! Shared causal indicator calculation for stock-pool screening.
use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::factor_events::{self, Expr, Frame};
pub fn implementation_sha256() -> String {
use sha2::{Digest,Sha256};
let mut identity=Sha256::new();
identity.update(include_bytes!("stock_pool_indicators.rs"));
identity.update(factor_events::catalog()["expression_kernel_sha256"].as_str().expect("native kernel identity"));
format!("{:x}",identity.finalize())
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct IndicatorSpec {
pub indicator: String,
pub field: String,
pub window: usize,
#[serde(default)]
pub output: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InputSeries {
pub frame: Frame,
/// Source-admitted historical suspension/lifecycle gaps, not guessed from
/// missing prices. The pure endpoint never certifies those source facts.
#[serde(default)]
pub admitted_missing_dates: BTreeSet<NaiveDate>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub specs: BTreeMap<String, IndicatorSpec>,
pub series: Vec<InputSeries>,
pub output_start_date: NaiveDate,
pub output_end_date: NaiveDate,
}
#[derive(Debug, Serialize)]
pub struct OutputSeries {
pub symbol: String,
pub indices: Vec<usize>,
pub values: BTreeMap<String, Vec<Option<f64>>>,
}
pub fn evaluate(request: Request) -> Result<Vec<OutputSeries>, String> {
let row_count: usize = request.series.iter().map(|series| series.frame.timestamps.len()).sum();
if request.specs.is_empty() || request.specs.len() > 64 || row_count > 60_000
|| row_count.saturating_mul(request.specs.len()) > 1_000_000
|| request.output_start_date > request.output_end_date {
return Err("stock_pool_indicator_request_budget_or_range_invalid".into());
}
for spec in request.specs.values() {
if !matches!(spec.indicator.as_str(), "ma" | "ema" | "kdj") || !(2..=10_000).contains(&spec.window)
|| !matches!(spec.field.as_str(), "close" | "volume" | "high" | "low")
|| (spec.indicator == "kdj" && !matches!(spec.output.as_deref(), Some("k" | "d" | "j"))) {
return Err("stock_pool_indicator_spec_invalid".into());
}
}
let mut seen = BTreeSet::new();
let mut result = Vec::new();
for series in request.series {
let frame = series.frame;
frame.validate()?;
if frame.frequency != "1d" { return Err("stock_pool_indicators_require_daily_source".into()); }
if series.admitted_missing_dates.iter().any(|day| !frame.timestamps.iter().any(|stamp| stamp.date_naive() == *day)) {
return Err("admitted_missing_date_is_outside_the_input_frame".into());
}
if !seen.insert(frame.symbol.clone()) { return Err("stock_pool_indicator_duplicate_symbol".into()); }
let indices = frame.timestamps.iter().enumerate().filter(|(_, stamp)| {
let day = stamp.date_naive(); request.output_start_date <= day && day <= request.output_end_date
}).map(|(index, _)| index).collect::<Vec<_>>();
let mut values = BTreeMap::new();
for (key, spec) in &request.specs {
let fields: Vec<&str> = if spec.indicator == "kdj" { vec!["high", "low", "close"] } else { vec![&spec.field] };
for field in &fields {
let raw = frame.fields.get(*field).ok_or_else(|| format!("indicator_field_missing:{field}"))?;
if raw.len() != frame.timestamps.len() { return Err("indicator_field_length_mismatch".into()); }
}
for index in 0..frame.timestamps.len() {
let valid = fields.iter().all(|field| frame.fields[*field][index].is_some_and(f64::is_finite));
if valid {
if fields.iter().any(|field| frame.fields[*field][index].is_some_and(|value| if *field == "volume" { value < 0.0 } else { value <= 0.0 })) {
return Err(format!("indicator_input_value_invalid:{}:{}", frame.symbol, frame.timestamps[index]));
}
if spec.indicator == "kdj" {
let (hi, lo, close) = (frame.fields["high"][index].unwrap(), frame.fields["low"][index].unwrap(), frame.fields["close"][index].unwrap());
if hi < lo || close < lo || close > hi { return Err(format!("indicator_ohlc_invalid:{}:{}", frame.symbol, frame.timestamps[index])); }
}
}
else if !series.admitted_missing_dates.contains(&frame.timestamps[index].date_naive()) {
return Err(format!("unclassified_indicator_input_gap:{}:{}", frame.symbol, frame.timestamps[index]));
}
}
let native_values = {
// The native KDJ has its own initialisation and lookback. Do
// not recreate it as STOCH or an independently seeded loop.
// Preserve admitted NULL rows too: the shared kernel defines
// gap/warmup semantics; compressing the calendar changes them.
let expression: Expr = serde_json::from_value(if spec.indicator == "kdj" {
json!({"kind":"indicator","name":"KDJ","inputs":[],
"parameters":{"optInFastK_Period":spec.window,"optInSlowK_Period":3,
"optInSlowK_MAType":13,"optInSlowD_Period":3,"optInSlowD_MAType":13},
"output":match spec.output.as_deref() { Some("k") => 0, Some("d") => 1, _ => 2 }})
} else {
json!({"kind":"indicator","name":if spec.indicator == "ma" {"SMA"} else {"EMA"},
"inputs":[{"kind":"field","name":spec.field}],"parameters":{"optInTimePeriod":spec.window}})
}).map_err(|error| error.to_string())?;
factor_events::evaluate(&expression, &frame)?.values
};
values.insert(key.clone(), indices.iter().map(|index| native_values[*index]).collect());
}
result.push(OutputSeries { symbol: frame.symbol, indices, values });
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn request() -> Request {
serde_json::from_value(json!({"specs":{"ma":{"indicator":"ma","field":"close","window":3},"ema":{"indicator":"ema","field":"close","window":3},"j":{"indicator":"kdj","field":"close","window":3,"output":"j"}},
"series":[{"frame":{"symbol":"000001.SZ","frequency":"1d","decision_at":"2026-09-10T17:00:00+08:00",
"timestamps":["2026-09-07T15:00:00+08:00","2026-09-08T15:00:00+08:00","2026-09-09T15:00:00+08:00","2026-09-10T15:00:00+08:00"],
"available_at":["2026-09-07T16:00:00+08:00","2026-09-08T16:00:00+08:00","2026-09-09T16:00:00+08:00","2026-09-10T16:00:00+08:00"],
"fields":{"close":[1.,2.,3.,4.],"high":[2.,3.,4.,5.],"low":[0.5,1.,2.,3.]}}}],
"output_start_date":"2026-09-09","output_end_date":"2026-09-10"})).unwrap()
}
#[test]
fn uses_shared_ma_ema_and_preserves_recursive_prefix_before_output_window() {
let rows = evaluate(request()).unwrap();
assert_eq!(rows[0].indices,vec![2,3]);
assert_eq!(rows[0].values["ma"],vec![Some(2.),Some(3.)]);
assert_eq!(rows[0].values["ema"],vec![Some(2.),Some(3.)]);
assert_eq!(rows[0].values["j"], vec![None, None]); // KDJ has a longer native lookback.
let mut later = request(); later.output_start_date = NaiveDate::from_ymd_opt(2026,9,10).unwrap();
assert_eq!(evaluate(later).unwrap()[0].values["ema"],vec![rows[0].values["ema"][1]]);
}
#[test]
fn kdj_uses_the_same_frozen_native_kernel_as_event_conditions() {
let mut data = request();
let frame = &mut data.series[0].frame;
for i in 4..16 {
let day = NaiveDate::from_ymd_opt(2026,9,7).unwrap() + chrono::Duration::days(i);
frame.timestamps.push(chrono::DateTime::parse_from_rfc3339(&format!("{day}T15:00:00+08:00")).unwrap());
frame.available_at.push(chrono::DateTime::parse_from_rfc3339(&format!("{day}T16:00:00+08:00")).unwrap());
frame.fields.get_mut("close").unwrap().push(Some(i as f64 + 1.));
frame.fields.get_mut("high").unwrap().push(Some(i as f64 + 2.));
frame.fields.get_mut("low").unwrap().push(Some(i as f64));
}
frame.decision_at = chrono::DateTime::parse_from_rfc3339("2026-09-23T17:00:00+08:00").unwrap();
data.output_start_date=NaiveDate::from_ymd_opt(2026,9,7).unwrap();
data.output_end_date=NaiveDate::from_ymd_opt(2026,9,22).unwrap();
let native:Expr=serde_json::from_value(json!({"kind":"indicator","name":"KDJ","inputs":[],"parameters":{"optInFastK_Period":3},"output":2})).unwrap();
let expected=factor_events::evaluate(&native,frame).unwrap().values;
assert!(expected.iter().any(Option::is_some));
assert_eq!(evaluate(data).unwrap()[0].values["j"],expected);
}
#[test]
fn missing_inputs_require_source_admission_and_future_inputs_fail() {
let mut data = request(); data.series[0].frame.fields.get_mut("close").unwrap()[1] = None;
assert!(evaluate(data).unwrap_err().contains("unclassified_indicator_input_gap"));
let mut data = request(); data.series[0].frame.available_at[3] = chrono::DateTime::parse_from_rfc3339("2026-09-11T16:00:00+08:00").unwrap();
assert!(evaluate(data).is_err());
let mut data=request();data.series[0].frame.fields.get_mut("close").unwrap()[1]=None;
data.series[0].admitted_missing_dates.insert(NaiveDate::from_ymd_opt(2026,9,8).unwrap());
let native:Expr=serde_json::from_value(json!({"kind":"indicator","name":"EMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}})).unwrap();
let expected=factor_events::evaluate(&native,&data.series[0].frame).unwrap().values;
assert_eq!(expected[2..], [None,None]);
assert_eq!(evaluate(data).unwrap()[0].values["ema"],expected[2..]);
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Durable intent progress, deliberately separate from actual-fill holding
//! protection. A published target starts no holding/protection timer.
use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::stock_pool_execution::{
Position, StockPoolMemberSpec, StockPoolPlan, normalize_stock_symbol,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolEntryProgress {
pub pending: bool,
pub observed_holding: bool,
pub first_decision_date: NaiveDate,
pub latest_generation: String,
pub latest_target_value: Decimal,
/// Fully funded entry goal, fixed at the last plan. Reconcile against
/// actual holdings before repricing, never against today's market value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_quantity: Option<Decimal>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolExecutionState {
pub schema_version: u32,
pub last_execution_date: Option<NaiveDate>,
pub entries: BTreeMap<String, StockPoolEntryProgress>,
#[serde(default)]
pub last_target_weights: BTreeMap<String, i32>,
/// First signal excluding an actually held member; not an acquisition date.
pub removed_since: BTreeMap<String, NaiveDate>,
}
pub struct StockPoolGoalObservation<'a> {
pub symbol: &'a str,
pub target_weight_bps: i32,
pub target_value: Decimal,
pub current_quantity: Decimal,
pub target_quantity: Decimal,
pub status: &'a str,
}
impl Default for StockPoolExecutionState {
fn default() -> Self {
Self {
schema_version: 1,
last_execution_date: None,
entries: BTreeMap::new(),
last_target_weights: BTreeMap::new(),
removed_since: BTreeMap::new(),
}
}
}
impl StockPoolExecutionState {
pub fn validate(&self) -> Result<(), String> {
if self.schema_version != 1
|| self.entries.len() > 10000
|| self.removed_since.len() > 10000
{
return Err("stock_pool_execution_state_invalid_schema_or_size".into());
}
for symbol in self
.entries
.keys()
.chain(self.removed_since.keys())
.chain(self.last_target_weights.keys())
{
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
return Err("stock_pool_execution_state_invalid_symbol".into());
}
}
if self.last_target_weights.len() > 10000
|| self
.last_target_weights
.values()
.any(|value| !(0..=10000).contains(value))
{
return Err("stock_pool_execution_state_invalid_weights".into());
}
if self.entries.values().any(|entry| {
entry.latest_target_value < Decimal::ZERO
|| entry.completion_quantity.is_some_and(|quantity| quantity <= Decimal::ZERO)
|| entry.latest_generation.is_empty()
|| self
.last_execution_date
.is_none_or(|last| entry.first_decision_date > last)
}) || self
.removed_since
.values()
.any(|day| self.last_execution_date.is_none_or(|last| *day > last))
{
return Err("stock_pool_execution_state_invalid_goal_or_clock".into());
}
Ok(())
}
pub fn observe(
&self,
decision_date: NaiveDate,
execution_date: NaiveDate,
official_dates: &[NaiveDate],
members: &[StockPoolMemberSpec],
positions: &[Position],
) -> Result<Self, String> {
self.validate()?;
if decision_date > execution_date
|| !official_dates.contains(&execution_date)
|| !official_dates.contains(&decision_date)
|| official_dates.windows(2).any(|pair| pair[0] >= pair[1])
|| self
.last_execution_date
.is_some_and(|last| last > execution_date)
{
return Err("stock_pool_execution_state_requires_monotone_official_clock".into());
}
let mut next = self.clone();
next.last_execution_date = Some(execution_date);
let members = members
.iter()
.map(|member| member.symbol.clone())
.collect::<BTreeSet<_>>();
let held = positions
.iter()
.filter(|position| position.quantity > Decimal::ZERO)
.map(|position| position.symbol.clone())
.collect::<BTreeSet<_>>();
next.entries.retain(|symbol, entry| {
// Confirmed flat starts a new cycle. A still-unfilled fresh target
// may remain pending while the latest pool still requests it.
!(entry.observed_holding && !held.contains(symbol))
&& (members.contains(symbol) || held.contains(symbol))
});
next.last_target_weights
.retain(|symbol, _| members.contains(symbol) || held.contains(symbol));
for (symbol, entry) in &mut next.entries {
entry.observed_holding |= held.contains(symbol);
if entry.pending
&& entry.completion_quantity.is_some_and(|goal| {
positions.iter().any(|position| {
&position.symbol == symbol && position.quantity >= goal
})
})
{
entry.pending = false;
}
}
next.removed_since
.retain(|symbol, _| held.contains(symbol) && !members.contains(symbol));
for symbol in held.difference(&members) {
next.removed_since
.entry(symbol.clone())
.or_insert(decision_date);
}
next.validate()?;
Ok(next)
}
pub fn pending_symbols(&self) -> BTreeSet<String> {
self.entries
.iter()
.filter(|(_, entry)| entry.pending)
.map(|(symbol, _)| symbol.clone())
.collect()
}
pub fn next_day_exit_symbols(&self, execution_date: NaiveDate) -> BTreeSet<String> {
self.removed_since
.iter()
.filter(|(_, removed)| **removed < execution_date)
.map(|(symbol, _)| symbol.clone())
.collect()
}
pub fn record_plan(
&self,
decision_date: NaiveDate,
generation: &str,
plan: &StockPoolPlan,
) -> Result<Self, String> {
self.record_targets(
decision_date,
generation,
plan.rows.iter().map(|row| StockPoolGoalObservation {
symbol: &row.symbol,
target_weight_bps: row.target_weight_bps,
target_value: row.target_value,
current_quantity: row.current_quantity,
target_quantity: row.target_quantity,
status: &row.status,
}),
)
}
pub fn record_targets<'a>(
&self,
decision_date: NaiveDate,
generation: &str,
rows: impl IntoIterator<Item = StockPoolGoalObservation<'a>>,
) -> Result<Self, String> {
self.validate()?;
if generation.is_empty()
|| self
.last_execution_date
.is_none_or(|date| decision_date > date)
{
return Err("stock_pool_execution_state_plan_clock_invalid".into());
}
let mut next = self.clone();
for row in rows {
if row.target_weight_bps > 0 {
next.last_target_weights
.insert(row.symbol.into(), row.target_weight_bps);
}
let eligible = row.target_weight_bps > 0 && row.target_value > Decimal::ZERO;
let completion_quantity = (row.status == "READY"
&& row.target_quantity > row.current_quantity)
.then_some(row.target_quantity);
let satisfied = matches!(
row.status,
"ALREADY_SATISFIED"
| "ENTRY_TARGET_ALREADY_SATISFIED"
| "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED"
);
if row.current_quantity == Decimal::ZERO && eligible && satisfied {
next.entries.remove(row.symbol);
continue;
}
if let Some(entry) = next.entries.get_mut(row.symbol) {
entry.latest_generation = generation.into();
entry.latest_target_value = row.target_value;
if entry.pending && completion_quantity.is_some() {
entry.completion_quantity = completion_quantity;
}
entry.observed_holding |= row.current_quantity > Decimal::ZERO;
if entry.pending && eligible && satisfied {
entry.pending = false;
}
} else if eligible && row.current_quantity == Decimal::ZERO && !satisfied {
next.entries.insert(
row.symbol.into(),
StockPoolEntryProgress {
pending: true,
observed_holding: false,
first_decision_date: decision_date,
latest_generation: generation.into(),
latest_target_value: row.target_value,
completion_quantity,
},
);
}
}
next.validate()?;
Ok(next)
}
}
+64 -1
View File
@@ -977,6 +977,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
#[derive(Debug, Clone, Default)]
pub struct StrategyDecision {
pub buy_denials: BTreeMap<String, String>,
pub rebalance: bool,
pub target_weights: BTreeMap<String, f64>,
pub exit_symbols: BTreeSet<String>,
@@ -987,7 +988,20 @@ pub struct StrategyDecision {
}
impl StrategyDecision {
pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet<String> {
let mut symbols = BTreeSet::new();
if self.rebalance {
symbols.extend(self.target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone()));
}
for intent in &self.order_intents {
intent.collect_potential_buy_symbols(open_orders, &mut symbols);
}
symbols.retain(|symbol| !symbol.trim().is_empty());
symbols
}
pub fn merge_from(&mut self, mut other: StrategyDecision) {
self.buy_denials.append(&mut other.buy_denials);
self.rebalance |= other.rebalance;
self.target_weights.append(&mut other.target_weights);
self.exit_symbols.append(&mut other.exit_symbols);
@@ -998,7 +1012,8 @@ impl StrategyDecision {
}
pub fn is_empty(&self) -> bool {
!self.rebalance
self.buy_denials.is_empty()
&& !self.rebalance
&& self.target_weights.is_empty()
&& self.exit_symbols.is_empty()
&& self.order_intents.is_empty()
@@ -1055,6 +1070,9 @@ pub enum TargetPortfolioOrderPricing {
#[derive(Debug, Clone)]
pub enum OrderIntent {
StockPool {
contract: Box<crate::stock_pool_execution::FrozenStockPoolIntent>,
},
WithTimeInForce {
intent: Box<OrderIntent>,
time_in_force: OrderTimeInForce,
@@ -1214,6 +1232,43 @@ pub enum OrderIntent {
}
impl OrderIntent {
fn collect_potential_buy_symbols(&self, open_orders: &[OpenOrderView], symbols: &mut BTreeSet<String>) {
match self.unwrapped() {
Self::StockPool { contract } => { symbols.extend(contract.selection.requested_symbols.iter().cloned()); }
Self::Shares { symbol, quantity, .. } | Self::LimitShares { symbol, quantity, .. } if *quantity > 0 => { symbols.insert(symbol.clone()); }
Self::Lots { symbol, lots, .. } | Self::LimitLots { symbol, lots, .. } if *lots > 0 => { symbols.insert(symbol.clone()); }
Self::TargetShares { symbol, target_quantity, .. } | Self::LimitTargetShares { symbol, target_quantity, .. } if *target_quantity > 0 => { symbols.insert(symbol.clone()); }
Self::Value { symbol, value, .. } | Self::LimitValue { symbol, value, .. } | Self::AlgoValue { symbol, value, .. } if *value > 0.0 => { symbols.insert(symbol.clone()); }
Self::Percent { symbol, percent, .. } | Self::LimitPercent { symbol, percent, .. } | Self::AlgoPercent { symbol, percent, .. } if *percent > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetValue { symbol, target_value, .. } | Self::LimitTargetValue { symbol, target_value, .. } | Self::TimedTargetValue { symbol, target_value, .. } if *target_value > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetPercent { symbol, target_percent, .. } | Self::LimitTargetPercent { symbol, target_percent, .. } if *target_percent > 0.0 => { symbols.insert(symbol.clone()); }
Self::TargetPortfolioSmart { target_weights, .. } => {
symbols.extend(target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone()));
}
Self::ModifyOrder { order_id, new_total_quantity, new_limit_price, .. } => {
if let Some(order) = open_orders.iter().find(|order| order.order_id == *order_id)
&& order.side == OrderSide::Buy
&& (new_total_quantity.is_some_and(|value| value > order.requested_quantity)
|| new_limit_price.is_some_and(|value| value > order.limit_price))
{
symbols.insert(order.symbol.clone());
}
}
Self::Shares { .. } | Self::LimitShares { .. }
| Self::Lots { .. } | Self::LimitLots { .. }
| Self::TargetShares { .. } | Self::LimitTargetShares { .. }
| Self::Value { .. } | Self::LimitValue { .. } | Self::AlgoValue { .. }
| Self::Percent { .. } | Self::LimitPercent { .. } | Self::AlgoPercent { .. }
| Self::TargetValue { .. } | Self::LimitTargetValue { .. } | Self::TimedTargetValue { .. }
| Self::TargetPercent { .. } | Self::LimitTargetPercent { .. }
| Self::CancelOrder { .. } | Self::CancelSymbol { .. } | Self::CancelAll { .. }
| Self::UpdateUniverse { .. } | Self::Subscribe { .. } | Self::Unsubscribe { .. }
| Self::DepositWithdraw { .. } | Self::FinanceRepay { .. } | Self::SetManagementFeeRate { .. }
| Self::Futures { .. } => {}
Self::WithTimeInForce { .. } => unreachable!("intent is unwrapped"),
}
}
pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self {
match self {
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {
@@ -1260,6 +1315,7 @@ impl OrderIntent {
pub fn supports_time_in_force(&self, time_in_force: OrderTimeInForce) -> bool {
let intent = self.unwrapped();
if matches!(intent, Self::StockPool { .. }) { return time_in_force == OrderTimeInForce::Day; }
if matches!(
intent,
Self::CancelOrder { .. }
@@ -1569,6 +1625,7 @@ impl Strategy for CnSmallCapRotationStrategy {
if self.config.in_skip_window(ctx.decision_date) {
self.last_gross_exposure = Some(0.0);
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: true,
target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
@@ -1590,6 +1647,7 @@ impl Strategy for CnSmallCapRotationStrategy {
if message.contains("signal series insufficient") =>
{
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1765,6 +1823,7 @@ impl Strategy for CnSmallCapRotationStrategy {
self.last_gross_exposure = Some(gross_exposure);
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance,
target_weights,
exit_symbols,
@@ -2773,6 +2832,7 @@ impl Strategy for OmniMicroCapStrategy {
let lagged_execution = ctx.is_lagged_execution();
if self.config.in_skip_window(signal_date) {
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
@@ -2803,6 +2863,7 @@ impl Strategy for OmniMicroCapStrategy {
if message.contains("insufficient benchmark") =>
{
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -3013,6 +3074,7 @@ impl Strategy for OmniMicroCapStrategy {
];
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols,
@@ -3154,6 +3216,7 @@ mod tests {
pe_ttm: 12.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
})
.collect::<Vec<_>>();
+7 -3
View File
@@ -119,8 +119,8 @@ pub struct StrategyAiOptimizeRequest {
}
const PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT: &str = "收益验收合同:收益、回撤、年度收益、样本外区间及比较运算符只能来自用户目标、请求约束或不可变 candidate/promotion contract;不得注入 120% 或其他默认数值,也不得提高、降低或替换已经明确的门槛。没有明确数值合同时只做策略有效性、数据时序和风险审计,禁止声称收益已经达标;存在冻结合同时必须逐项按原运算符验证,不能只看总收益。";
const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "reject_st_selection=false、reject_st_buy=true、reject_star_st_selection=false、reject_star_st_buy=true、reject_paused_selection=false、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=false、reject_inactive_buy=true、reject_inactive_sell=true、reject_new_listing_selection=false、reject_new_listing_buy=true、reject_kcb_selection=false、reject_kcb_buy=true、reject_bjse_selection=false、reject_bjse_buy=true、reject_one_yuan_selection=false、reject_one_yuan_buy=true、respect_allow_buy_sell=true、reject_upper_limit_selection=false、reject_lower_limit_selection=false、reject_upper_limit_buy=true、reject_lower_limit_sell=true、forbid_same_day_rebuy_after_sell=true、blacklist_enabled=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\"";
const DEFAULT_RISK_POLICY_DSL_CODE: &str = "reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=true, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\"";
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 {
@@ -250,7 +250,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
},
ManualSection {
title: "risk.policy / risk.blacklist".to_string(),
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 reject_st_selection、reject_st_buy、reject_star_st_selection、reject_star_st_buy、reject_paused_selection、reject_paused_buy、reject_paused_sell、reject_inactive_selection、reject_inactive_buy、reject_inactive_sell、reject_new_listing_selection、reject_new_listing_buy、reject_kcb_selection、reject_kcb_buy、reject_bjse_selection、reject_bjse_buy、reject_one_yuan_selection、reject_one_yuan_buy、respect_allow_buy_sell、reject_upper_limit_selection、reject_lower_limit_selection、reject_upper_limit_buy、reject_lower_limit_sell、forbid_same_day_rebuy_after_sell、blacklist_enabled、allow_market_orders、live_trading_enabled、blacklisted_symbols、volume_limit_enabled、liquidity_limit_enabled、volume_percent、commission_rate、minimum_commission、stamp_tax_rate_before_change、stamp_tax_rate_after_change、stamp_tax_change_date 等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。框架默认的 ST、*ST、停牌、退市、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不能被转换器隐式写进 universe.exclude 或 filter.stock_expr源策略明确写出的业务选股排除属于策略本身,必须原样保留在 filter.stock_expr,并且不能反向修改冻结的 reject_*_selection 开关。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".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(),
@@ -264,6 +264,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
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.automatic_trade_protection(...)".to_string(),
detail: r#"当前股票/ETF策略的独立自动交易保护:trading.automatic_trade_protection({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]})。配置冻结到 runtimeExpressions.trading.automaticTradeProtection,回测、paper/live 共用内核;不并入全局风控。0/null/未填关闭对应周期;成交日及之后N个完整正式交易日内,买入保护禁止自动卖出及止盈止损,卖出冷却禁止自动增加仓位;只由真实成交启动或延长,拒绝/未成交/撤单不启动。最长持有按首次实际建仓后的正式交易日计数,加仓与部分卖出不重置,清仓后再开仓重置;日期锁定两端包含且高于自动退出,持仓占用真实预算和槽位。人工交易通过独立服务路径执行,仍校验权限、券商及T+1,不接受客户端origin旁路。持仓来源、实际成交或正式日历缺失时明确拒绝;期货与股票期货混合账户尚不支持此能力,不得悄悄忽略。旧trading.max_holding_days仍保留旧含义,不得和新配置声明不同最大周期。"#.to_string(),
},
ManualSection {
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(),
+1
View File
@@ -339,6 +339,7 @@ mod tests {
pe_ttm: 10.0,
turnover_ratio: Some(0.01),
effective_turnover_ratio: Some(0.01),
adjustment_factor_backward1: None,
extra_factors: Default::default(),
}
}
@@ -0,0 +1,336 @@
use chrono::NaiveDate;
use fidc_core::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection};
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot, DataSet, Instrument,
MatchingType, OrderSide, PlatformExplicitOrderKind, PlatformExprStrategy,
PlatformExprStrategyConfig, PlatformTradeAction, PriceField,
};
fn d(day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 9, day).unwrap()
}
fn data() -> DataSet {
let dates = [11, 14, 15, 16, 17, 18].map(d);
DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".into(),
name: "测试".into(),
board: "SZ".into(),
round_lot: 100,
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
delisted_at: None,
status: "active".into(),
}],
dates
.iter()
.map(|date| DailyMarketSnapshot {
date: *date,
symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 15:00:00")),
day_open: 10.0,
open: 10.0,
high: 10.0,
low: 10.0,
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".into()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
})
.collect(),
dates
.iter()
.map(|date| fidc_core::DailyFactorSnapshot {
date: *date,
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.0),
extra_factors: Default::default(),
})
.collect(),
dates
.iter()
.map(|date| CandidateEligibility {
date: *date,
symbol: "000001.SZ".into(),
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,
})
.collect(),
dates
.iter()
.map(|date| BenchmarkSnapshot {
date: *date,
benchmark: "000300.SH".into(),
open: 100.0,
close: 100.0,
prev_close: 100.0,
volume: 1_000_000,
})
.collect(),
)
.unwrap()
}
fn action(quantity: &str, when: &str) -> PlatformTradeAction {
PlatformTradeAction::Order {
kind: PlatformExplicitOrderKind::Shares,
symbol: "000001.SZ".into(),
amount_expr: quantity.into(),
when_expr: Some(when.into()),
limit_price_expr: None,
time_in_force: None,
start_time_expr: None,
end_time_expr: None,
reason: "configured_strategy_action".into(),
}
}
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
let mut config = PlatformExprStrategyConfig::generic();
config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into();
config.rotation_enabled = false;
config.automatic_trade_protection = policy;
config.explicit_actions = vec![
action(
"100",
"decision_date == \"2026-09-11\" || decision_date == \"2026-09-18\"",
),
action("-100", "decision_date >= \"2026-09-14\""),
];
config.matching_type = MatchingType::CurrentBarClose;
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose);
BacktestEngine::new(
data(),
PlatformExprStrategy::new(config),
broker,
BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000300.SH".into(),
start_date: Some(d(11)),
end_date: Some(d(18)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.run()
.unwrap()
}
#[test]
fn framework_protection_uses_fills_and_covers_explicit_strategy_orders() {
let result = run(AutomaticTradeProtection {
buy_protection_days: 3,
sell_cooldown_days: 3,
..Default::default()
});
assert_eq!(
result
.fills
.iter()
.map(|fill| (fill.date, fill.side, fill.quantity))
.collect::<Vec<_>>(),
vec![(d(11), OrderSide::Buy, 100), (d(17), OrderSide::Sell, 100)]
);
assert!(!result.order_events.iter().any(|order| order.date == d(14)
|| order.date == d(15)
|| order.date == d(16)
|| order.date == d(18)));
}
#[test]
fn absolute_lock_blocks_initial_strategy_buy_without_a_rejected_order() {
let result = run(AutomaticTradeProtection {
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d(11),
end_date: None,
}],
..Default::default()
});
assert!(result.fills.is_empty());
assert!(result.order_events.is_empty());
}
#[test]
fn maximum_holding_policy_applies_to_discrete_strategies_and_yields_to_buy_protection() {
let result = run(AutomaticTradeProtection {
max_holding_days: 1,
buy_protection_days: 3,
sell_cooldown_days: 3,
..Default::default()
});
assert_eq!(
result
.fills
.iter()
.map(|fill| (fill.date, fill.side))
.collect::<Vec<_>>(),
vec![(d(11), OrderSide::Buy), (d(17), OrderSide::Sell)]
);
assert!(
result
.order_events
.iter()
.any(|order| order.reason == "max_holding_days_exit")
);
}
#[test]
fn serialized_framework_policy_survives_shared_alias_normalization_and_rejects_conflicts() {
let policy = serde_json::json!({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]});
for key in ["automaticTradeProtection", "automatic_trade_protection"] {
let value = serde_json::json!({"runtimeExpressions":{"trading":{key:policy}}});
let cfg = fidc_core::platform_expr_config_from_value("test", "000001.SZ", &value).unwrap();
assert_eq!(cfg.automatic_trade_protection.buy_protection_days, 3);
assert_eq!(cfg.max_holding_days, Some(90));
assert_eq!(cfg.automatic_trade_protection.locks.len(), 1);
}
let conflict = serde_json::json!({"runtimeExpressions":{"trading":{"maxHoldingDays":30,"automaticTradeProtection":policy}}});
assert!(
fidc_core::platform_expr_config_from_value("test", "000001.SZ", &conflict)
.unwrap_err()
.to_string()
.contains("conflicting maximum")
);
let unknown = serde_json::json!({"runtimeExpressions":{"trading":{"automaticTradeProtection":{"origin":"manual"}}}});
assert!(fidc_core::platform_expr_config_from_value("test", "000001.SZ", &unknown).is_err());
}
#[test]
fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
let base = data();
let dates = [11, 14, 15, 16, 17, 18].map(d);
let symbols = ["000001.SZ", "000002.SZ"];
let dataset = DataSet::from_components(
symbols
.iter()
.map(|symbol| {
let mut row = base.instruments()["000001.SZ"].clone();
row.symbol = (*symbol).into();
row
})
.collect(),
dates
.iter()
.flat_map(|date| {
symbols.iter().map(|symbol| {
let mut row = base.market(*date, "000001.SZ").unwrap().clone();
row.symbol = (*symbol).into();
row
})
})
.collect(),
dates
.iter()
.flat_map(|date| {
symbols.iter().map(|symbol| {
let mut row = base.factor(*date, "000001.SZ").unwrap().clone();
row.symbol = (*symbol).into();
row
})
})
.collect(),
dates
.iter()
.flat_map(|date| {
symbols.iter().map(|symbol| {
let mut row = base.candidate(*date, "000001.SZ").unwrap().clone();
row.symbol = (*symbol).into();
row
})
})
.collect(),
dates
.iter()
.map(|date| BenchmarkSnapshot {
date: *date,
benchmark: "000300.SH".into(),
open: 100.0,
close: 100.0,
prev_close: 100.0,
volume: 100_000,
})
.collect(),
)
.unwrap();
let mut config = PlatformExprStrategyConfig::generic();
config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into();
config.strategy_name = "protection_test".into();
config.max_positions = 1;
config.selection_limit_expr = "1".into();
config.refresh_rate = 1;
config.exposure_expr = "0.5".into();
config.market_cap_lower_expr = "0".into();
config.market_cap_upper_expr = "100".into();
config.stock_filter_expr="(decision_date == \"2026-09-11\" && symbol == \"000001.SZ\") || (decision_date != \"2026-09-11\" && symbol == \"000002.SZ\")".into();
config.automatic_trade_protection = AutomaticTradeProtection {
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d(14),
end_date: Some(d(16)),
}],
..Default::default()
};
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose);
let result = BacktestEngine::new(
dataset,
PlatformExprStrategy::new(config),
broker,
BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000300.SH".into(),
start_date: Some(d(11)),
end_date: Some(d(18)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.run()
.unwrap();
assert_eq!(
result
.fills
.first()
.map(|fill| (fill.symbol.as_str(), fill.date)),
Some(("000001.SZ", d(11)))
);
assert!(
!result
.fills
.iter()
.any(|fill| [d(14), d(15), d(16)].contains(&fill.date)),
"{:?}",
result.fills
);
assert!(
result.fills.iter().any(|fill| fill.symbol == "000002.SZ"
&& fill.side == OrderSide::Buy
&& fill.date == d(17)),
"{:?}",
result.fills
);
}
@@ -90,6 +90,7 @@ impl Strategy for BuyAndHoldStrategy {
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -144,6 +145,7 @@ fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}
}
@@ -270,6 +272,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -280,6 +283,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -290,6 +294,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -2,9 +2,10 @@ use chrono::{Duration, NaiveDate, NaiveTime};
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
StrategyDecision,
};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -15,6 +16,18 @@ fn t(hour: u32, minute: u32, second: u32) -> NaiveTime {
NaiveTime::from_hms_opt(hour, minute, second).expect("valid time")
}
fn fixture_instruments() -> Vec<Instrument> {
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "quote-plan-fixture".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}]
}
#[derive(Default)]
struct DecisionQuoteReader {
day_count: usize,
@@ -62,12 +75,197 @@ impl Strategy for DecisionQuoteReader {
}
}
struct NoLoaderDecisionQuoteStrategy {
symbol_plan_calls: Arc<Mutex<usize>>,
}
impl Strategy for NoLoaderDecisionQuoteStrategy {
fn name(&self) -> &str {
"no_loader_decision_quote_strategy"
}
fn decision_quote_times(&self) -> Vec<NaiveTime> {
vec![t(10, 18, 0)]
}
fn decision_quote_symbols(
&mut self,
_ctx: &StrategyContext<'_>,
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
*self
.symbol_plan_calls
.lock()
.expect("symbol plan counter mutex") += 1;
Ok(BTreeSet::new())
}
}
fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
DataSet::from_components(
fixture_instruments(),
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some(format!("{date} 15:00:00")),
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,
}],
vec![DailyFactorSnapshot {
date,
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,
adjustment_factor_backward1: None,
extra_factors: Default::default(),
}],
vec![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,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1001.0,
prev_close: 999.0,
volume: 1_000_000,
}],
)
.expect("dataset")
}
#[test]
fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
let date = d(2026, 1, 5);
let data = single_day_quote_plan_data(date);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Close,
)
.with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(date),
end_date: Some(date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
};
let symbol_plan_calls = Arc::new(Mutex::new(0usize));
let loader_calls = Arc::new(Mutex::new(0usize));
let strategy = NoLoaderDecisionQuoteStrategy {
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
};
let captured_loader_calls = Arc::clone(&loader_calls);
let preplanned = Arc::new(BTreeMap::from([(
date,
BTreeSet::from(["000001.SZ".to_string()]),
)]));
let mut engine = BacktestEngine::new(data, strategy, broker, config)
.with_execution_quote_loader(move |request| {
*captured_loader_calls.lock().expect("loader counter mutex") += 1;
Ok(request
.symbols
.into_iter()
.map(|symbol| IntradayExecutionQuote {
date: request.date,
symbol,
timestamp: request.date.and_time(t(10, 17, 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()),
})
.collect())
})
.with_preplanned_decision_quote_symbols_by_date(preplanned);
engine.run().expect("backtest should run");
assert_eq!(
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
0,
"the strategy plan must not be recomputed after a complete plan is supplied"
);
assert_eq!(
*loader_calls.lock().expect("loader counter mutex"),
1,
"the supplied symbols must still pass through the normal quote loader"
);
}
#[test]
fn engine_skips_decision_quote_symbol_plan_without_loader() {
let date = d(2026, 1, 5);
let data = single_day_quote_plan_data(date);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Close,
)
.with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(date),
end_date: Some(date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
};
let symbol_plan_calls = Arc::new(Mutex::new(0usize));
let strategy = NoLoaderDecisionQuoteStrategy {
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
};
let mut engine = BacktestEngine::new(data, strategy, broker, config);
engine.run().expect("backtest should run");
assert_eq!(
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
0,
"a preloaded/no-loader engine cannot use a newly computed quote symbol plan"
);
}
#[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(),
fixture_instruments(),
vec![
DailyMarketSnapshot {
date: first,
@@ -125,6 +323,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(),
},
DailyFactorSnapshot {
@@ -135,6 +334,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(),
},
],
@@ -235,7 +435,7 @@ 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(),
fixture_instruments(),
vec![
DailyMarketSnapshot {
date: first,
@@ -293,6 +493,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(),
},
DailyFactorSnapshot {
@@ -303,6 +504,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(),
},
],
@@ -468,7 +670,7 @@ 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(),
fixture_instruments(),
vec![
DailyMarketSnapshot {
date: first,
@@ -526,6 +728,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(),
},
DailyFactorSnapshot {
@@ -536,6 +739,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(),
},
],
+8
View File
@@ -24,6 +24,7 @@ impl Strategy for BuyThenHoldStrategy {
) -> Result<StrategyDecision, fidc_core::BacktestError> {
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -170,6 +171,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -180,6 +182,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -190,6 +193,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -200,6 +204,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -436,6 +441,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -446,6 +452,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -456,6 +463,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
+66 -36
View File
@@ -78,6 +78,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -144,6 +145,7 @@ fn factor_row(
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors,
}
}
@@ -293,6 +295,7 @@ impl Strategy for HookProbeStrategy {
.borrow_mut()
.push(format!("on_day:{}", ctx.execution_date));
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -332,6 +335,7 @@ impl Strategy for AuctionOrderStrategy {
_ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -375,6 +379,7 @@ impl Strategy for FuturesOrderStrategy {
return Ok(StrategyDecision::default());
}
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -718,6 +723,7 @@ impl Strategy for LimitCarryStrategy {
}
self.issued = true;
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -801,6 +807,7 @@ impl Strategy for UniverseDirectiveStrategy {
_ => Vec::new(),
};
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -842,6 +849,7 @@ impl Strategy for MinuteProbeStrategy {
_ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -883,6 +891,7 @@ impl Strategy for MinuteProbeStrategy {
}
self.ordered = true;
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -985,6 +994,7 @@ impl Strategy for OrderInspectionStrategy {
_ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1028,6 +1038,7 @@ impl Strategy for AccountFlowStrategy {
return Ok(StrategyDecision::default());
}
Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1137,6 +1148,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -1147,6 +1159,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -1263,18 +1276,24 @@ fn engine_runs_strategy_hooks_in_daily_order() {
)
.with_process_event_retention(ProcessEventRetention::Business);
let compact_result = compact_engine.run().expect("compact backtest succeeds");
assert!(compact_result
.process_events
.iter()
.all(|event| event.kind.is_business_lifecycle()));
assert!(compact_result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::OnDay));
assert!(!compact_result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::PreBeforeTrading));
assert!(
compact_result
.process_events
.iter()
.all(|event| event.kind.is_business_lifecycle())
);
assert!(
compact_result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::OnDay)
);
assert!(
!compact_result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::PreBeforeTrading)
);
assert_eq!(
result.process_events[..18]
.iter()
@@ -1347,6 +1366,7 @@ fn engine_executes_open_auction_decisions_before_on_day() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -1447,6 +1467,7 @@ fn engine_executes_futures_order_intents_against_future_account() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -2122,15 +2143,15 @@ fn strategy_context_exposes_advanced_data_helpers() {
fn engine_runs_minute_hooks_and_executes_minute_orders() {
let date = d(2025, 1, 2);
let data = DataSet::from_components_with_actions_and_quotes(
vec![Instrument {
symbol: "000001.SZ".to_string(),
["000001.SZ", "000002.SZ"].into_iter().map(|symbol| Instrument {
symbol: symbol.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(),
}],
}).collect(),
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
@@ -2153,7 +2174,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
}, market_row(date, "000002.SZ", 20.0, 20.4)],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
@@ -2162,6 +2183,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -2457,6 +2479,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
})
.collect::<Vec<_>>();
@@ -2625,6 +2648,7 @@ fn strategy_context_exposes_final_order_runtime_view() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -2868,6 +2892,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -2878,6 +2903,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -3063,6 +3089,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -3073,6 +3100,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -3260,6 +3288,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -3270,6 +3299,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -3280,6 +3310,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -3515,6 +3546,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -3525,6 +3557,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -3535,6 +3568,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -3880,6 +3914,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -3890,6 +3925,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
pe_ttm: 12.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
]
@@ -4034,6 +4070,7 @@ fn engine_exposes_current_process_context_to_strategies() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -4106,6 +4143,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
) -> Result<StrategyDecision, fidc_core::BacktestError> {
if ctx.execution_date == d(2025, 5, 26) {
return Ok(StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4124,7 +4162,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
}
#[test]
fn engine_carries_position_price_when_current_market_row_is_missing() {
fn engine_rejects_an_unexplained_missing_holding_close() {
let date1 = d(2025, 5, 26);
let date2 = d(2025, 5, 27);
let data = DataSet::from_components(
@@ -4192,20 +4230,16 @@ fn engine_carries_position_price_when_current_market_row_is_missing() {
},
);
let result = engine
let error = engine
.run()
.expect("backtest should not fail on one missing holding row");
assert_eq!(result.equity_curve.len(), 2);
assert!(
result
.daily_holdings
.iter()
.any(|holding| holding.date == date2 && holding.symbol == "601028.SH")
);
.expect_err("unknown missing market data must not become a carried close");
let detail = format!("{error:?}");
assert!(detail.contains("MissingSnapshot") && detail.contains("close price"));
assert!(detail.contains("601028.SH") && detail.contains("2025-05-27"));
}
#[test]
fn platform_strategy_skips_position_stop_take_when_current_market_row_is_missing() {
fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
let date1 = d(2025, 5, 26);
let date2 = d(2025, 5, 27);
let data = DataSet::from_components(
@@ -4295,14 +4329,10 @@ fn platform_strategy_skips_position_stop_take_when_current_market_row_is_missing
},
);
let result = engine
let error = engine
.run()
.expect("platform strategy should hold through a missing current market row");
assert_eq!(result.equity_curve.len(), 2);
assert!(
result
.daily_holdings
.iter()
.any(|holding| holding.date == date2 && holding.symbol == "601028.SH")
);
.expect_err("skipping a stop condition cannot fabricate the missing valuation");
let detail = format!("{error:?}");
assert!(detail.contains("MissingSnapshot") && detail.contains("close price"));
assert!(detail.contains("601028.SH") && detail.contains("2025-05-27"));
}
+112 -19
View File
@@ -50,6 +50,7 @@ fn order_value_rounding_data(date: NaiveDate, symbol: &str, price: f64) -> DataS
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -119,6 +120,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -207,6 +209,7 @@ fn execute_single_value_order(
&mut portfolio,
data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -272,6 +275,7 @@ fn single_symbol_limit_price_data(
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -343,6 +347,7 @@ fn broker_executes_explicit_order_value_buy() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -395,6 +400,7 @@ fn broker_executes_explicit_order_value_buy() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -495,6 +501,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -552,6 +559,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -629,6 +637,7 @@ fn broker_executes_order_shares_and_order_lots() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -681,6 +690,7 @@ fn broker_executes_order_shares_and_order_lots() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -757,6 +767,7 @@ fn broker_executes_target_shares_like_order_to() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -799,6 +810,7 @@ fn broker_executes_target_shares_like_order_to() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -904,6 +916,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -914,6 +927,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
pe_ttm: 14.0,
turnover_ratio: Some(2.2),
effective_turnover_ratio: Some(2.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -972,6 +986,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1062,6 +1077,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
pe_ttm: 14.0,
turnover_ratio: Some(2.2),
effective_turnover_ratio: Some(2.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -1129,6 +1145,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1205,6 +1222,7 @@ fn broker_executes_order_percent_and_target_percent() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -1243,6 +1261,7 @@ fn broker_executes_order_percent_and_target_percent() {
&mut percent_portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1267,6 +1286,7 @@ fn broker_executes_order_percent_and_target_percent() {
&mut target_percent_portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1329,6 +1349,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -1368,6 +1389,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1435,6 +1457,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -1474,6 +1497,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1538,6 +1562,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -1576,6 +1601,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1653,6 +1679,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -1692,6 +1719,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1712,8 +1740,9 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
}
#[test]
fn broker_applies_dynamic_slippage_on_snapshot_fills() {
fn broker_applies_explicit_historical_slippage_on_snapshot_fills() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let previous_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
let data = DataSet::from_components(
vec![Instrument {
symbol: "000002.SZ".to_string(),
@@ -1724,20 +1753,20 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
[previous_date, date].into_iter().map(|day| DailyMarketSnapshot {
date: day,
symbol: "000002.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
timestamp: Some(format!("{day} 15:00:00")),
day_open: 10.0,
open: 10.0,
high: 10.1,
low: 9.9,
close: 10.0,
high: if day == previous_date { 10.1 } else { 10.9 },
low: if day == previous_date { 9.9 } else { 9.1 },
close: if day == previous_date { 10.0 } else { 10.8 },
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 10.0,
volume: 100_000,
volume: if day == previous_date { 100_000 } else { 2_000_000 },
minute_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
@@ -1746,7 +1775,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
}).collect(),
vec![DailyFactorSnapshot {
date,
symbol: "000002.SZ".to_string(),
@@ -1755,10 +1784,11 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
[previous_date, date].into_iter().map(|day| CandidateEligibility {
date: day,
symbol: "000002.SZ".to_string(),
is_st: false,
is_star_st: false,
@@ -1769,15 +1799,15 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
}).collect(),
[previous_date, date].into_iter().map(|day| BenchmarkSnapshot {
date: day,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
}).collect(),
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -1786,7 +1816,9 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_slippage_model(SlippageModel::Dynamic(DynamicSlippageConfig::new(
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(
0.5, 0.3, 0.1,
)));
@@ -1796,6 +1828,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1862,6 +1895,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -1916,6 +1950,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -1982,6 +2017,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -2021,6 +2057,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2091,6 +2128,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -2144,6 +2182,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2209,6 +2248,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -2262,6 +2302,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2339,6 +2380,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -2377,6 +2419,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2445,6 +2488,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -2512,6 +2556,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2616,6 +2661,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -2684,6 +2730,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2771,6 +2818,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -2864,6 +2912,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -2931,6 +2980,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -3011,6 +3061,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -3029,23 +3080,24 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
)
.expect("broker execution");
assert_eq!(report.fill_events.len(), 3);
assert_eq!(report.fill_events.len(), 2);
assert_eq!(
report
.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>(),
300
200
);
assert!(report.fill_events.iter().all(|fill| fill.quantity == 100));
assert!(report.fill_events.iter().all(|fill| fill.execution_timestamp.is_some_and(|at| at >= date.and_hms_opt(10, 15, 0).unwrap())));
assert_eq!(
report
.process_events
.iter()
.filter(|event| event.kind == ProcessEventKind::Trade)
.count(),
3
2
);
}
@@ -3093,6 +3145,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -3146,6 +3199,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -3210,6 +3264,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -3263,6 +3318,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -3365,6 +3421,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -3375,6 +3432,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
pe_ttm: 18.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -3432,6 +3490,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: true,
target_weights: BTreeMap::from([("000002.SZ".to_string(), 1.0)]),
exit_symbols: BTreeSet::new(),
@@ -3558,6 +3617,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -3568,6 +3628,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -3626,6 +3687,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: true,
target_weights: BTreeMap::from([
("000001.SZ".to_string(), 0.5),
@@ -3744,6 +3806,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -3754,6 +3817,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
pe_ttm: 18.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -3808,6 +3872,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: true,
target_weights: BTreeMap::from([
("000001.SZ".to_string(), 0.2),
@@ -3926,6 +3991,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -3936,6 +4002,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
pe_ttm: 18.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -3990,6 +4057,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: true,
target_weights: BTreeMap::from([
("000001.SZ".to_string(), 0.48),
@@ -4062,6 +4130,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
pe_ttm: 20.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -4103,6 +4172,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4166,6 +4236,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
pe_ttm: 20.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -4207,6 +4278,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4272,6 +4344,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -4312,6 +4385,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4386,6 +4460,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
})
.collect::<Vec<_>>();
@@ -4444,6 +4519,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4526,6 +4602,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
})
.collect::<Vec<_>>();
@@ -4587,6 +4664,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4640,6 +4718,7 @@ fn broker_configured_policy_can_allow_upper_limit_buy() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4686,6 +4765,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4778,6 +4858,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
@@ -4788,6 +4869,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
},
],
@@ -4859,6 +4941,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -4899,6 +4982,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -5825,6 +5909,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -5863,6 +5948,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -5907,6 +5993,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
&mut value_portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -5932,6 +6019,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
&mut percent_portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -5968,6 +6056,7 @@ fn broker_cancels_open_order_by_order_id() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -5991,6 +6080,7 @@ fn broker_cancels_open_order_by_order_id() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -6038,6 +6128,7 @@ fn broker_emits_cancellation_reject_for_unknown_order() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -6104,6 +6195,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
@@ -6145,6 +6237,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
&mut portfolio,
&data,
&StrategyDecision {
buy_denials: Default::default(),
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
@@ -0,0 +1,275 @@
{
"strategyId": "fixture_hold_without_stops_backtest",
"version": "1.0.0",
"market": "CN_A",
"benchmark": {
"instrumentId": "000300.SH",
"fallbackInstrumentId": "000300.SH",
"note": "必须使用真实指数链路;若 000852.SH 不可用,应直接报错而不是退化到其他标的。"
},
"universe": {
"exclude": [],
"implementationNotes": [
"ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量和费用由 riskPolicy / RiskLimits 统一执行",
"上市日期与退市日期取自 instrument 结构化字段,不再使用股票名称做 ST/退市判断",
"盘中 current_price / last_price 由策略交易时刻批量 tick 查询驱动"
],
"include": [
"000001.SZ",
"000002.SZ"
]
},
"selectors": [
{
"type": "dynamicRange",
"field": "market_cap",
"lowerExpr": "0",
"upperExpr": "1000000000000",
"mapping": "close -> strategy_factory_source_lake.runtime_fields.close"
},
{
"type": "filter",
"expr": "(close > 0)"
},
{
"type": "rank",
"orderBy": [
"market_cap asc"
],
"limitExpr": "2"
}
],
"rebalance": {
"frequencyDays": 1,
"tradeTimes": [],
"dailyApproximation": "日线回测按 matching_type 撮合;分钟线回测按交易时刻分钟价格撮合",
"schedule": {
"frequency": "daily"
}
},
"risk": {
"takeProfitExpr": "",
"stopLossExpr": "",
"indexThrottleExpr": "max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0))",
"stopTakeReferencePriceMode": "position_average_entry_price"
},
"seasonality": {
"skipWindows": []
},
"execution": {
"selectionGranularity": "strategy_factory_source_lake.daily_source_rows_v1",
"executionGranularity": "daily_or_minute_bar",
"priceSource": "current_bar_close_or_next_bar_open_or_minute_bar",
"matchingType": "current_bar_close",
"rebalanceCashMode": "sell_then_buy",
"slippageModel": "none",
"slippageValue": 0,
"riskPolicy": {
"rejectStSelection": false,
"rejectStarStSelection": false,
"rejectPausedSelection": false,
"rejectInactiveSelection": false,
"rejectNewListingSelection": false,
"rejectKcbSelection": false,
"rejectBjseSelection": false,
"rejectOneYuanSelection": false,
"rejectUpperLimitSelection": false,
"rejectLowerLimitSelection": false,
"rejectStBuy": true,
"rejectStarStBuy": true,
"rejectPausedBuy": true,
"rejectInactiveBuy": true,
"rejectNewListingBuy": true,
"rejectKcbBuy": true,
"rejectBjseBuy": true,
"rejectOneYuanBuy": true,
"rejectUpperLimitBuy": true,
"rejectPausedSell": true,
"rejectInactiveSell": true,
"rejectLowerLimitSell": true,
"respectAllowBuySell": true,
"forbidSameDayRebuyAfterSell": true,
"blacklistEnabled": true,
"blacklistedSymbols": [],
"allowMarketOrders": true,
"liveTradingEnabled": false,
"volumeLimitEnabled": true,
"liquidityLimitEnabled": true,
"volumePercent": 0.25,
"maxOrderQuantity": 1000000,
"maxOrderNotional": 100000000,
"maxSymbolPosition": 10000000,
"commissionRate": 0.0003,
"minimumCommission": 5,
"stampTaxRateBeforeChange": 0.001,
"stampTaxRateAfterChange": 0.0005,
"stampTaxChangeDate": "2023-08-28"
},
"sourceLanguage": "engine-script",
"sourceKind": "platform-strategy",
"extractor": "omniquant-engine-script-v2",
"sellThenBuyDelaySlippageRate": 0,
"strictValueBudget": true
},
"factorRefs": [
"close"
],
"runtimeExpressions": {
"prelude": "",
"schedule": {
"frequency": "daily"
},
"selection": {
"limitExpr": "2",
"candidateLimitExpr": "2",
"marketCapField": "close",
"marketCapLowerExpr": "0",
"marketCapUpperExpr": "1000000000000",
"stockFilterExpr": "(close > 0)"
},
"risk": {
"exposureExpr": "max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0))",
"stopLossExpr": "",
"takeProfitExpr": "",
"stopTakeReferencePriceMode": "position_average_entry_price"
},
"allocation": {
"buyScaleExpr": "1.0"
},
"ordering": {
"rankBy": "market_cap",
"rankExpr": "(symbol == \"000001.SZ\" || symbol == \"000002.SZ\") ? (symbol == \"000001.SZ\" ? (0) : (1)) : 2",
"rankOrder": "asc"
},
"trading": {
"rotationEnabled": true,
"subscriptionGuardRequired": false,
"stage": "on_day",
"actions": []
}
},
"engineConfig": {
"templateId": "fixture_hold_without_stops_backtest",
"benchmarkSymbol": "000300.SH",
"signalSymbol": "000300.SH",
"rankLimit": 2,
"refreshRate": 1,
"rsiRate": 1.0001,
"dynamicRange": {
"baseIndexLevel": 2000,
"baseCapFloor": 7,
"capSpan": 1000000000000,
"xs": 0.008
},
"stopLossMultiplier": null,
"takeProfitMultiplier": null,
"matchingType": "current_bar_close",
"rebalanceCashMode": "sell_then_buy",
"slippageModel": "none",
"slippageValue": 0,
"riskPolicy": {
"rejectStSelection": false,
"rejectStarStSelection": false,
"rejectPausedSelection": false,
"rejectInactiveSelection": false,
"rejectNewListingSelection": false,
"rejectKcbSelection": false,
"rejectBjseSelection": false,
"rejectOneYuanSelection": false,
"rejectUpperLimitSelection": false,
"rejectLowerLimitSelection": false,
"rejectStBuy": true,
"rejectStarStBuy": true,
"rejectPausedBuy": true,
"rejectInactiveBuy": true,
"rejectNewListingBuy": true,
"rejectKcbBuy": true,
"rejectBjseBuy": true,
"rejectOneYuanBuy": true,
"rejectUpperLimitBuy": true,
"rejectPausedSell": true,
"rejectInactiveSell": true,
"rejectLowerLimitSell": true,
"respectAllowBuySell": true,
"forbidSameDayRebuyAfterSell": true,
"blacklistEnabled": true,
"blacklistedSymbols": [],
"allowMarketOrders": true,
"liveTradingEnabled": false,
"volumeLimitEnabled": true,
"liquidityLimitEnabled": true,
"volumePercent": 0.25,
"maxOrderQuantity": 1000000,
"maxOrderNotional": 100000000,
"maxSymbolPosition": 10000000,
"commissionRate": 0.0003,
"minimumCommission": 5,
"stampTaxRateBeforeChange": 0.001,
"stampTaxRateAfterChange": 0.0005,
"stampTaxChangeDate": "2023-08-28"
},
"skipWindows": [],
"rebalanceSchedule": {
"frequency": "daily"
},
"dividendReinvestment": false,
"sellThenBuyDelaySlippageRate": 0,
"strictValueBudget": true
},
"stockPool": {
"schema_version": 1,
"pool_id": "fixture-pool",
"version_id": "fixture-version",
"members": [
{
"symbol": "000001.SZ",
"requested_order": 0,
"recommendation_reason": "",
"target_weight_bps": null,
"stop_loss": null,
"take_profit": null
},
{
"symbol": "000002.SZ",
"requested_order": 1,
"recommendation_reason": "",
"target_weight_bps": null,
"stop_loss": null,
"take_profit": null
}
],
"allocation_policy": {
"target_holding_count": 2,
"portfolio_policy": {
"schema_version": 1,
"membership": "retain_holdings",
"rebalance_weights": false
},
"invest_ratio_bps": 10000,
"reserve_cash": 0
},
"timing_policy": {
"schema_version": 1,
"auto_execute": true,
"freeze_time": "00:00",
"window_start": "09:30",
"window_end": "15:00",
"trigger_mode": "scheduled_bar",
"pricing_mode": "first_tick",
"automatic_trade_protection": {
"buy_protection_days": 0,
"sell_cooldown_days": 0,
"max_holding_days": 0,
"locks": []
}
},
"stop_take_policy": {
"stop_loss": null,
"take_profit": null
},
"out_of_pool_policy": "hold"
},
"signalSymbol": "000300.SH",
"sourceCode": "strategy(\"fixture_hold_without_stops_backtest\") {\n mode(\"rotation\")\n market(\"CN_A\")\n benchmark(\"000300.SH\")\n signal(\"000300.SH\")\n rebalance.every_days(1)\n universe.include([\"000001.SZ\", \"000002.SZ\"])\n selection.limit(2)\n selection.candidate_limit(2)\n selection.market_cap_band(field=\"close\", lower=0, upper=1000000000000)\n filter.stock_expr(close > 0)\n ordering.rank_expr((symbol == \"000001.SZ\" || symbol == \"000002.SZ\") ? (symbol == \"000001.SZ\" ? (0) : (1)) : 2, \"asc\")\n risk.index_exposure(max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0)))\n allocation.buy_scale(1.0)\n stock_pool.config({\"schema_version\":1,\"pool_id\":\"fixture-pool\",\"version_id\":\"fixture-version\",\"members\":[{\"symbol\":\"000001.SZ\",\"requested_order\":0,\"recommendation_reason\":\"\",\"target_weight_bps\":null,\"stop_loss\":null,\"take_profit\":null},{\"symbol\":\"000002.SZ\",\"requested_order\":1,\"recommendation_reason\":\"\",\"target_weight_bps\":null,\"stop_loss\":null,\"take_profit\":null}],\"allocation_policy\":{\"target_holding_count\":2,\"portfolio_policy\":{\"schema_version\":1,\"membership\":\"retain_holdings\",\"rebalance_weights\":false},\"invest_ratio_bps\":10000,\"reserve_cash\":0},\"timing_policy\":{\"schema_version\":1,\"auto_execute\":true,\"freeze_time\":\"00:00\",\"window_start\":\"09:30\",\"window_end\":\"15:00\",\"trigger_mode\":\"scheduled_bar\",\"pricing_mode\":\"first_tick\",\"automatic_trade_protection\":{\"buy_protection_days\":0,\"sell_cooldown_days\":0,\"max_holding_days\":0,\"locks\":[]}},\"stop_take_policy\":{\"stop_loss\":null,\"take_profit\":null},\"out_of_pool_policy\":\"hold\"})\n risk.reference_price_mode(\"position_average_entry_price\")\n execution.matching_type(\"current_bar_close\")\n}\n",
"mode": "rotation"
}
@@ -52,9 +52,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
.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");
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 {
@@ -0,0 +1,632 @@
use chrono::NaiveDate;
use fidc_core::stock_pool_execution::*;
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
FidcRiskControlConfig, Instrument, MatchingType, OrderIntent, PlatformExprStrategy,
PortfolioState, PriceField, StrategyDecision, platform_expr_config_from_value,
};
use rust_decimal::Decimal;
use std::collections::{BTreeMap, BTreeSet};
fn day(n: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 1, n).unwrap()
}
fn code(n: usize) -> String {
format!("{n:06}.SZ")
}
fn data(low_volume: bool) -> DataSet {
data_with_first_volume(if low_volume { 100 } else { 1_000_000 })
}
fn data_with_first_volume(first_volume: u64) -> DataSet {
data_with_suspension(first_volume, None)
}
fn data_with_suspension(first_volume: u64, suspension: Option<NaiveDate>) -> DataSet {
data_with_fund_rules(first_volume, suspension, false)
}
fn data_with_fund_rules(
first_volume: u64,
suspension: Option<NaiveDate>,
fund_rules: bool,
) -> DataSet {
let mut instruments: Vec<Instrument> = (1..=2)
.map(|n| Instrument {
symbol: code(n),
name: code(n),
board: if fund_rules && n == 2 {
"ETF".into()
} else {
"SZ".into()
},
round_lot: 100,
listed_at: Some(day(1)),
delisted_at: None,
status: "active".into(),
})
.collect();
instruments.push(Instrument {
symbol: "000300.SH".into(),
name: "fixture reference index".into(),
board: "INDEX".into(),
round_lot: 1,
listed_at: Some(day(1)),
delisted_at: None,
status: "active".into(),
});
let mut market = Vec::new();
let mut candidates = Vec::new();
for date in [day(2), day(5), day(6)] {
for n in 1..=2 {
let price = if fund_rules && n == 2 {
0.934
} else if n == 1 && date >= day(5) {
20.0
} else {
10.0
};
market.push(DailyMarketSnapshot {
date,
symbol: code(n),
timestamp: None,
day_open: price,
open: price,
high: price,
low: price,
close: price,
last_price: price,
bid1: price,
ask1: price,
prev_close: 10.,
volume: if n == 1 { first_volume } else { 1_000_000 },
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: None,
paused: n == 2 && suspension == Some(date),
upper_limit: 100.,
lower_limit: 0.1,
price_tick: if fund_rules && n == 2 { 0.001 } else { 0.01 },
});
candidates.push(CandidateEligibility {
date,
symbol: code(n),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: n == 2 && suspension == Some(date),
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
});
}
let mut reference = market.last().unwrap().clone();
reference.symbol = "000300.SH".into();
reference.day_open = 100.;
reference.open = 100.;
reference.high = 100.;
reference.low = 100.;
reference.close = 100.;
reference.last_price = 100.;
reference.bid1 = 100.;
reference.ask1 = 100.;
reference.prev_close = 100.;
reference.upper_limit = 1000.;
market.push(reference);
}
let benchmarks = [day(2), day(5), day(6)]
.into_iter()
.map(|date| BenchmarkSnapshot {
date,
benchmark: "000300.SH".into(),
open: 100.,
close: 100.,
prev_close: 100.,
volume: 1_000_000,
})
.collect();
let factors = [day(2), day(5), day(6)]
.into_iter()
.flat_map(|date| {
(1..=2).map(move |n| DailyFactorSnapshot {
date,
symbol: code(n),
market_cap_bn: 10.,
free_float_cap_bn: 10.,
pe_ttm: 10.,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.),
extra_factors: Default::default(),
})
})
.collect();
DataSet::from_components(instruments, market, factors, candidates, benchmarks).unwrap()
}
fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
let mut risk = FidcRiskControlConfig::default();
risk.trading_constraints.commission_rate = 0.;
risk.trading_constraints.minimum_commission = 0.;
risk.trading_constraints.transfer_fee_rate = 0.;
risk.trading_constraints.stamp_tax_rate_before_change = 0.;
risk.trading_constraints.stamp_tax_rate_after_change = 0.;
risk.trading_constraints.volume_limit_enabled = volume;
risk.trading_constraints.volume_percent = 0.25;
risk.trading_constraints.liquidity_limit_enabled = false;
BrokerSimulator::new(
ChinaAShareCostModel::from_trading_constraints(risk.trading_constraints),
ChinaEquityRuleHooks,
)
.with_matching_type(MatchingType::NextBarOpen)
.with_risk_config(risk)
}
fn contract(signal: NaiveDate, target: usize, preserve: bool) -> FrozenStockPoolIntent {
let symbols = vec![code(1), code(2)];
FrozenStockPoolIntent {
pool_id: "fixture-pool".into(),
signal_date: signal,
frozen_equity: Decimal::from(30000),
selection: StockPoolSelection {
trade_date: signal,
requested_symbols: symbols.clone(),
normal_trading_symbols: symbols.clone(),
risk_eligible_symbols: symbols,
final_symbols: vec![code(target)],
exclusion_reasons: BTreeMap::new(),
inherited_from_generation: None,
explicit_empty: false,
generation: Some(format!("g-{signal}")),
},
members: (1..=2)
.map(|n| StockPoolMemberSpec {
symbol: code(n),
requested_order: n as i32,
recommendation_reason: String::new(),
target_weight_bps: None,
stop_loss: None,
take_profit: None,
})
.collect(),
rule: StockPoolExecutionRule {
pricing_mode: POOL_PRICE_FIRST_TICK.into(),
..Default::default()
},
constraints: StockPoolDecisionConstraints {
target_holding_count: Some(1),
portfolio_policy: Some(StockPoolPortfolioPolicy {
schema_version: 1,
membership: MembershipPolicy::FollowCandidates,
rebalance_weights: !preserve,
}),
..Default::default()
},
invest_ratio_bps: 10000,
reserve_cash: Decimal::ZERO,
out_of_pool_policy: "hold".into(),
generation: format!("g-{signal}"),
}
}
fn decision(contract: FrozenStockPoolIntent) -> StrategyDecision {
StrategyDecision {
order_intents: vec![OrderIntent::StockPool {
contract: Box::new(contract),
}],
..Default::default()
}
}
#[test]
fn paused_execution_day_keeps_the_prior_slot_and_never_submits_an_exit() {
let data = data_with_suspension(1_000_000, Some(day(6)));
let broker = broker(false);
let mut account = PortfolioState::new(30_000.);
let first = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(contract(day(2), 2, false)),
)
.unwrap();
assert_eq!(first.fill_events.len(), 1);
let quantity = account.position(&code(2)).unwrap().quantity;
let mut replacement = contract(day(5), 1, false);
replacement
.members
.retain(|member| member.symbol != code(2));
replacement
.selection
.requested_symbols
.retain(|symbol| symbol != &code(2));
replacement.out_of_pool_policy = "reduce_to_zero_when_sellable".into();
let paused = broker
.execute_with_event_dates(
day(6),
day(5),
day(5),
&mut account,
&data,
&decision(replacement),
)
.unwrap();
assert!(paused.fill_events.is_empty(), "{paused:?}");
assert_eq!(account.position(&code(2)).unwrap().quantity, quantity);
assert!(
paused
.diagnostics
.iter()
.any(|line| line.contains("MARKET_SUSPENDED"))
);
}
#[test]
fn mixed_fund_and_stock_round_trip_uses_declared_ticks_and_asset_specific_fees() {
let data = data_with_fund_rules(1_000_000, None, true);
let mut costs = ChinaAShareCostModel::default();
costs.set_transfer_fee_rate(0.00001);
let broker = BrokerSimulator::new(costs, ChinaEquityRuleHooks)
.with_matching_type(MatchingType::NextBarOpen);
let mut account = PortfolioState::new(30_000.);
let mut entry = contract(day(2), 1, false);
entry.selection.final_symbols = vec![code(1), code(2)];
entry.constraints.target_holding_count = Some(2);
entry.rule.buy_offset_bps = 1;
entry.rule.sell_offset_bps = -1;
let buys = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(entry.clone()),
)
.unwrap();
assert_eq!(buys.fill_events.len(), 2, "{buys:?}");
let fund = buys
.fill_events
.iter()
.find(|fill| fill.symbol == code(2))
.unwrap();
assert_eq!(fund.quantity, 16000);
assert_eq!(fund.price, 0.934);
assert_eq!(fund.stamp_tax, 0.);
assert_eq!(fund.transfer_fee, 0.);
let stock = buys
.fill_events
.iter()
.find(|fill| fill.symbol == code(1))
.unwrap();
assert_eq!(stock.quantity, 700);
assert_eq!(stock.transfer_fee, 0.14);
entry.signal_date = day(5);
entry.selection.trade_date = day(5);
entry.generation = "exit".into();
entry.invest_ratio_bps = 0;
let sells = broker
.execute_with_event_dates(
day(6),
day(5),
day(5),
&mut account,
&data,
&decision(entry),
)
.unwrap();
assert_eq!(sells.fill_events.len(), 2, "{sells:?}");
let fund = sells
.fill_events
.iter()
.find(|fill| fill.symbol == code(2))
.unwrap();
assert_eq!(fund.stamp_tax, 0.);
assert_eq!(fund.transfer_fee, 0.);
let stock = sells
.fill_events
.iter()
.find(|fill| fill.symbol == code(1))
.unwrap();
assert_eq!(stock.stamp_tax, 7.);
assert_eq!(stock.transfer_fee, 0.14);
assert!(
(account.cash() - 29972.72).abs() < 0.000001,
"cash={}",
account.cash()
);
assert!(
account
.positions()
.values()
.all(|position| position.quantity == 0)
);
}
#[test]
fn new_daily_target_sells_old_member_then_buys_using_frozen_equity() {
let data = data(false);
let broker = broker(false);
let mut account = PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(2), 1000, 10.);
let report = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(contract(day(2), 2, false)),
)
.unwrap();
assert_eq!(report.fill_events.len(), 2, "{report:?}");
assert!(account.position(&code(1)).is_none_or(|p| p.quantity == 0));
assert_eq!(account.position(&code(2)).unwrap().quantity, 3000);
assert!(
(account.cash() - 10000.).abs() < 1e-8,
"next-open equity must not replace the frozen 30000 budget"
);
let unique = report
.fill_events
.iter()
.map(|fill| (fill.symbol.clone(), format!("{:?}", fill.side)))
.collect::<BTreeSet<_>>();
assert_eq!(unique.len(), 2);
let next = broker
.execute_with_event_dates(
day(6),
day(5),
day(5),
&mut account,
&data,
&decision(contract(day(5), 2, true)),
)
.unwrap();
assert!(
next.fill_events.is_empty(),
"preserved shares must not be rebalanced: {next:?}"
);
}
#[test]
fn partial_sell_does_not_release_a_slot_or_authorize_replacement() {
let data = data(true);
let broker = broker(true).with_matching_type(MatchingType::CurrentBarClose);
let mut account = PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(2), 1000, 10.);
let report = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(contract(day(2), 2, false)),
)
.unwrap();
assert!(account.position(&code(2)).is_none());
assert_eq!(account.position(&code(1)).unwrap().quantity, 975);
assert!(
report
.diagnostics
.iter()
.any(|text| text.contains("DEFERRED_POSITION_SLOTS"))
);
}
#[test]
fn actual_fill_protection_is_evaluated_on_execution_date() {
let data = data(false);
let broker = broker(false);
let mut account = PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(2), 1000, 10.);
let mut intent = contract(day(2), 2, false);
intent.rule.automatic_trade_protection.buy_protection_days = 3;
let report = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(intent),
)
.unwrap();
assert!(report.fill_events.is_empty(), "{report:?}");
assert_eq!(account.position(&code(1)).unwrap().quantity, 1000);
}
#[test]
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
let intent = contract(day(2), 1, false);
for quote_condition in ["", "price<5"] {
let program = StockPoolProgram {
schema_version: 1,
pool_id: "pool-fixture".into(),
version_id: "version-fixture".into(),
members: intent.members.clone(),
allocation_policy: serde_json::json!({"target_holding_count":1,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":false}}),
// Disabled natural trading must not disable an explicitly requested historical backtest.
timing_policy: serde_json::json!({"auto_execute":false,"pricing_mode":"first_tick","buy_condition":quote_condition}),
stop_take_policy: serde_json::json!({"stop_loss":null,"take_profit":0}),
out_of_pool_policy: "hold".into(),
};
let mut config=platform_expr_config_from_value("pool-fixture",&code(1),&serde_json::json!({
"stockPool":program,"signalSymbol":code(1),"benchmark":{"instrumentId":"000300.SH"},"universe":{"include":[code(1),code(2)]}
})).unwrap();
config.market_cap_field = "close".into();
config.market_cap_lower_expr = "0".into();
config.market_cap_upper_expr = "1000000000000".into();
config.stock_filter_expr = "true".into();
config.selection_limit_expr = "1".into();
config.selection_candidate_limit_expr = "2".into();
config.rank_expr = format!(
"decision_date == \"2026-01-02\" ? (symbol == \"{}\" ? 0 : 1) : (symbol == \"{}\" ? 0 : 1)",
code(1),
code(2)
);
config.matching_type = MatchingType::CurrentBarClose;
let result = BacktestEngine::new(
data(false),
PlatformExprStrategy::new(config),
broker(false).with_matching_type(MatchingType::CurrentBarClose),
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(6)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.run()
.unwrap();
if quote_condition.is_empty() {
assert_eq!(
result.fills.len(),
3,
"fills={:#?}, decisions={:#?}, days={:#?}",
result.fills,
result.risk_decisions,
result
.equity_curve
.iter()
.map(|point| (&point.date, &point.diagnostics))
.collect::<Vec<_>>()
);
assert_eq!(result.fills[0].symbol, code(1));
assert_eq!(result.fills[1].symbol, code(1));
assert_eq!(result.fills[2].symbol, code(2));
assert_eq!(result.fills[2].quantity, 6000);
} else {
assert!(
result.fills.is_empty(),
"configured quote condition must reach the actual executor"
);
}
}
}
#[test]
fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() {
// Generated by OmniQuant's actual handoff and compiler, not a hand-written
// replacement runtimeExpressions contract. It used to inject 0.93/1.07.
let spec: serde_json::Value = serde_json::from_str(include_str!(
"fixtures/stock_pool_disabled_stops_compiled.json"
))
.unwrap();
let config =
platform_expr_config_from_value("fixture_hold_without_stops", "000300.SH", &spec).unwrap();
assert!(config.stop_loss_expr.is_empty());
assert!(config.take_profit_expr.is_empty());
let result = BacktestEngine::new(
data(false),
PlatformExprStrategy::new(config),
broker(false).with_matching_type(MatchingType::CurrentBarClose),
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(6)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.run()
.unwrap();
assert_eq!(
result.fills.len(),
2,
"stock one doubles in price, but disabled stops and weight rebalancing must not sell it: {:?}",
result.fills
);
assert!(
result
.fills
.iter()
.all(|fill| fill.side == fidc_core::OrderSide::Buy)
);
assert_eq!(result.equity_curve.len(), 3);
}
#[test]
fn partial_backtest_fills_do_not_turn_into_completed_preserved_holdings() {
let data = data_with_first_volume(2000);
let broker = broker(true).with_matching_type(MatchingType::CurrentBarClose);
let mut account = PortfolioState::new(30000.);
let first = broker
.execute_with_event_dates(
day(2),
day(2),
day(2),
&mut account,
&data,
&decision(contract(day(2), 1, true)),
)
.unwrap();
let partial = account
.position(&code(1))
.map(|position| position.quantity)
.unwrap_or(0);
assert!(partial > 0 && partial < 3000, "{first:?}");
let second = broker
.execute_with_event_dates(
day(5),
day(5),
day(5),
&mut account,
&data,
&decision(contract(day(5), 1, true)),
)
.unwrap();
assert!(
account.position(&code(1)).unwrap().quantity > partial,
"partial entry must continue on the next valid execution: {second:?}"
);
}
#[test]
fn next_day_outside_policy_executes_after_the_first_exclusion_signal() {
let data = data(false);
let broker = broker(false).with_matching_type(MatchingType::CurrentBarClose);
let mut account = PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(1), 1000, 10.);
let outside = |signal| {
let mut value = contract(signal, 2, true);
value.members.retain(|member| member.symbol == code(2));
value.selection.requested_symbols = vec![code(2)];
value.selection.normal_trading_symbols = vec![code(2)];
value.selection.risk_eligible_symbols = vec![code(2)];
value.out_of_pool_policy = "reduce_next_trading_day".into();
value
};
let first = broker
.execute_with_event_dates(
day(2),
day(2),
day(2),
&mut account,
&data,
&decision(outside(day(2))),
)
.unwrap();
assert!(first.fill_events.is_empty(), "{first:?}");
let next = broker
.execute_with_event_dates(
day(5),
day(5),
day(5),
&mut account,
&data,
&decision(outside(day(5))),
)
.unwrap();
assert!(
account
.position(&code(1))
.is_none_or(|position| position.quantity == 0),
"{next:?}"
);
assert_eq!(account.position(&code(2)).unwrap().quantity, 3000);
}
@@ -0,0 +1,280 @@
use chrono::NaiveDate;
use fidc_core::stock_pool_execution::*;
use fidc_core::stock_pool_state::StockPoolExecutionState;
use rust_decimal::Decimal;
use serde_json::json;
use std::collections::BTreeMap;
fn day(value: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 9, value).unwrap()
}
fn member() -> StockPoolMemberSpec {
StockPoolMemberSpec {
symbol: "000001.SZ".into(),
requested_order: 0,
recommendation_reason: String::new(),
target_weight_bps: None,
stop_loss: None,
take_profit: None,
}
}
fn held(quantity: i64, closable: i64) -> Position {
Position {
symbol: "000001.SZ".into(),
quantity: quantity.into(),
closable_quantity: closable.into(),
average_cost: 10.into(),
}
}
fn quote() -> MarketSnapshot {
MarketSnapshot {
symbol: "000001.SZ".into(),
last_price: 10.into(),
prev_close: Some(10.into()),
volume: Some(1000000.into()),
turnover: Some(10000000.into()),
bid_price_1: Some(10.into()),
ask_price_1: Some(10.into()),
is_kcb: Some(false),
instrument_rules: None,
buy_sizing_price: None,
sell_sizing_price: None,
}
}
fn plan(
state: &StockPoolExecutionState,
at: NaiveDate,
members: &[StockPoolMemberSpec],
positions: &[Position],
cash: i64,
outside: &str,
) -> StockPoolPlan {
plan_at_price(state, at, members, positions, cash, outside, 10)
}
fn plan_at_price(
state: &StockPoolExecutionState,
at: NaiveDate,
members: &[StockPoolMemberSpec],
positions: &[Position],
cash: i64,
outside: &str,
price: i64,
) -> StockPoolPlan {
let symbols = members
.iter()
.map(|member| member.symbol.clone())
.collect::<Vec<_>>();
let selection = StockPoolSelection {
trade_date: at,
requested_symbols: symbols.clone(),
normal_trading_symbols: symbols.clone(),
risk_eligible_symbols: symbols.clone(),
final_symbols: symbols,
exclusion_reasons: BTreeMap::new(),
inherited_from_generation: None,
explicit_empty: false,
generation: Some("same-goal".into()),
};
let mut constraints = stock_pool_constraints_from_configuration(
&json!({"top_n_rebalance_policy":"preserve_existing"}),
&json!({}),
)
.unwrap();
constraints.pending_entry_symbols = state.pending_symbols();
constraints.next_day_outside_exit_symbols = state.next_day_exit_symbols(at);
let mut market = quote();
market.last_price = price.into();
market.bid_price_1 = Some(price.into());
market.ask_price_1 = Some(price.into());
build_stock_pool_target_plan_with_constraints(
&selection,
members,
&StockPoolExecutionRule::default(),
&AccountSnapshot {
total_equity: 10000.into(),
cash: cash.into(),
frozen_cash: Decimal::ZERO,
},
positions,
&[market],
10000,
Decimal::ZERO,
outside,
"preserve_existing",
&constraints,
"same-goal",
Decimal::ZERO,
Decimal::ZERO,
Decimal::ZERO,
)
.unwrap()
}
#[test]
fn fully_filled_entry_is_not_reopened_when_price_falls_before_next_observation() {
let members = vec![member()];
let calendar = vec![day(11), day(14)];
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
let first_plan = plan(&initial, day(11), &members, &[], 10000, "hold");
let state = initial.record_plan(day(11), "first-entry", &first_plan).unwrap();
assert_eq!(state.entries["000001.SZ"].completion_quantity, Some(1000.into()));
let state: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
let observed = state.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)]).unwrap();
assert!(!observed.pending_symbols().contains("000001.SZ"));
let next = plan_at_price(&observed, day(14), &members, &[held(1000, 1000)], 1000, "hold", 8);
assert_eq!(next.rows[0].target_quantity, 1000.into());
assert_eq!(next.rows[0].delta_quantity, Decimal::ZERO);
assert_eq!(next.rows[0].status, "PRESERVED_EXISTING_POSITION");
}
#[test]
fn cash_clipped_or_rejected_entry_does_not_claim_full_completion() {
let members = vec![member()];
let calendar = vec![day(11), day(14)];
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
let first_plan = plan(&initial, day(11), &members, &[], 5000, "hold");
assert_eq!(first_plan.rows[0].status, "REDUCE_TO_ALLOWED_QUANTITY");
let state = initial.record_plan(day(11), "limited-entry", &first_plan).unwrap();
assert_eq!(state.entries["000001.SZ"].completion_quantity, None);
for quantity in [0, 500] {
let positions = if quantity == 0 { vec![] } else { vec![held(quantity, quantity)] };
let observed = state.observe(day(14), day(14), &calendar, &members, &positions).unwrap();
assert!(observed.pending_symbols().contains("000001.SZ"));
}
}
#[test]
fn legacy_state_without_quantity_keeps_its_serialized_identity() {
let original = json!({
"schema_version":1,"last_execution_date":"2026-09-11",
"entries":{"000001.SZ":{"pending":true,"observed_holding":false,
"first_decision_date":"2026-09-11","latest_generation":"legacy",
"latest_target_value":"10000"}},"last_target_weights":{},"removed_since":{}
});
let state: StockPoolExecutionState = serde_json::from_value(original.clone()).unwrap();
state.validate().unwrap();
assert_eq!(serde_json::to_value(state).unwrap(), original);
}
#[test]
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
let members = vec![member()];
let calendar = vec![day(11), day(14)];
let first = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &members, &[])
.unwrap();
let initial = plan(&first, day(11), &members, &[], 10000, "hold");
assert_eq!(initial.rows[0].target_quantity, 1000.into());
let pending = first.record_plan(day(11), "same-goal", &initial).unwrap();
assert!(pending.entries["000001.SZ"].pending);
assert!(!pending.entries["000001.SZ"].observed_holding);
let persisted = serde_json::to_vec(&pending).unwrap();
let restored: StockPoolExecutionState = serde_json::from_slice(&persisted).unwrap();
let partial = restored
.observe(day(11), day(11), &calendar, &members, &[held(500, 0)])
.unwrap();
let retry = plan(&partial, day(11), &members, &[held(500, 0)], 5000, "hold");
assert_eq!(retry.rows[0].delta_quantity, 500.into(), "{retry:?}");
let pending = partial.record_plan(day(11), "same-goal", &retry).unwrap();
assert!(pending.entries["000001.SZ"].pending);
let filled = pending
.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)])
.unwrap();
let satisfied = plan(&filled, day(14), &members, &[held(1000, 1000)], 0, "hold");
assert_eq!(satisfied.rows[0].status, "PRESERVED_EXISTING_POSITION");
let completed = filled.record_plan(day(14), "new-day", &satisfied).unwrap();
assert!(!completed.entries["000001.SZ"].pending);
assert_eq!(
plan(
&completed,
day(14),
&members,
&[held(1000, 1000)],
0,
"hold"
)
.rows[0]
.status,
"PRESERVED_EXISTING_POSITION"
);
}
#[test]
fn removal_anchor_is_not_reset_by_rechecks_weekends_or_t_plus_one() {
let calendar = vec![day(11), day(14), day(15)];
let positions = vec![held(1000, 1000)];
let removed = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &[], &positions)
.unwrap();
assert_eq!(removed.removed_since["000001.SZ"], day(11));
assert_eq!(
plan(
&removed,
day(11),
&[],
&positions,
0,
"reduce_next_trading_day"
)
.rows[0]
.status,
"DEFERRED_T_PLUS_ONE"
);
assert!(
removed
.observe(day(12), day(12), &calendar, &[], &positions)
.is_err()
);
let restored: StockPoolExecutionState =
serde_json::from_str(&serde_json::to_string(&removed).unwrap()).unwrap();
let monday = restored
.observe(day(14), day(14), &calendar, &[], &[held(1000, 0)])
.unwrap();
assert_eq!(monday.removed_since["000001.SZ"], day(11));
assert_eq!(
plan(
&monday,
day(14),
&[],
&[held(1000, 0)],
0,
"reduce_next_trading_day"
)
.rows[0]
.delta_quantity,
Decimal::ZERO
);
let next = monday
.observe(day(15), day(15), &calendar, &[], &positions)
.unwrap();
let exit = plan(
&next,
day(15),
&[],
&positions,
0,
"reduce_next_trading_day",
);
assert_eq!(exit.rows[0].target_quantity, Decimal::ZERO);
assert_eq!(exit.rows[0].side, Some(OrderSide::Sell));
let returned = next
.observe(day(15), day(15), &calendar, &[member()], &positions)
.unwrap();
assert!(returned.removed_since.is_empty());
}
#[test]
fn cloned_preview_does_not_start_a_timer_and_next_open_uses_signal_removal_date() {
let state = StockPoolExecutionState::default();
let calendar = vec![day(11), day(14)];
let preview = state
.observe(day(11), day(14), &calendar, &[], &[held(1000, 1000)])
.unwrap();
assert!(state.removed_since.is_empty());
assert!(preview.next_day_exit_symbols(day(14)).contains("000001.SZ"));
let mut invalid = preview;
invalid.schema_version = 0;
assert!(invalid.validate().is_err());
}
@@ -72,6 +72,7 @@ fn factor(
pe_ttm: 18.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: Default::default(),
}
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "fidc-signal-client"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
fidc-core = { path = "../fidc-core" }
reqwest.workspace = true
serde_json.workspace = true
+43
View File
@@ -0,0 +1,43 @@
//! Shared signal transport for FIDC backtest and trading services.
use std::sync::Arc;
use fidc_core::signal_contract::{SignalBookReference,ValidatedSignalBook,cached_signal_book,register_signal_book};
use reqwest::Client;
use serde_json::{Value,json};
#[derive(Clone,Copy)]
pub enum Purpose { Backtest, Online }
pub async fn load(client:&Client, source_url:&str, token:&str, reference:&SignalBookReference, purpose:Purpose)
-> Result<Arc<ValidatedSignalBook>,String>
{
reference.validate()?;
if token.len()<32 {return Err("signal_service_auth_not_configured".into());}
let purpose_name=match purpose {Purpose::Backtest=>"backtest",Purpose::Online=>"online"};
let payload=json!({"reference":reference,"purpose":purpose_name});
let root=format!("{}/api/strategy-signals/internal",source_url.trim_end_matches('/'));
// Registration/purpose validation always precedes a process-cache hit.
let response=client.post(format!("{root}/validate"))
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
.map_err(|_|"signal_validation_service_unavailable")?;
if !response.status().is_success() {return Err(format!("signal_validation_rejected_http_{}",response.status()));}
let validation:Value=response.json().await.map_err(|_|"signal_validation_response_invalid")?;
if validation.get("ok")!=Some(&Value::Bool(true)) || validation.get("reference")!=Some(&json!(reference)) {
return Err("signal_validation_identity_mismatch".into());
}
let book=if let Some(book)=cached_signal_book(reference)? {book} else {
let mut response=client.post(format!("{root}/book"))
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
.map_err(|_|"signal_book_service_unavailable")?;
if !response.status().is_success() {return Err(format!("signal_book_rejected_http_{}",response.status()));}
if response.content_length().is_some_and(|bytes|bytes>64*1024*1024) {return Err("signal_book_transport_size_exceeded".into());}
let mut bytes=Vec::new();
while let Some(chunk)=response.chunk().await.map_err(|_|"signal_book_transport_incomplete")? {
if bytes.len().saturating_add(chunk.len())>64*1024*1024 {return Err("signal_book_transport_size_exceeded".into());}
bytes.extend_from_slice(&chunk);
}
register_signal_book(reference,&bytes)?
};
if matches!(purpose,Purpose::Online) {book.require_observed()?;}
Ok(book)
}
@@ -0,0 +1,25 @@
# 策略级自动交易保护
## 统一合同
`runtimeExpressions.trading.automaticTradeProtection` 是每个股票/ETF策略自己的不可变配置。股票池、表达式轮动和显式订单复用 `holding_policy` 内核,不新增全局共享配置,也不修改未配置的历史策略。
```json
{"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":"2026-09-16"}]}
```
- 周期为空、null或0关闭,必须为0—3650整数;锁定支持同股多个区间,起止日包含当日,截止null持续有效。
- 买入保护禁止自动减仓/清仓及止盈止损;卖出冷却禁止自动增加仓位。只有实际成交计时,部分成交延长对应最后成交日;未成交、拒绝、撤单不启动。
- 成交日及后续N个完整正式交易日均受保护。例如周五成交、N=3,保护到下周三结束,周四恢复;不按72小时或自然日替代。
- 最长持有从连续持仓第一次实际买入开始,跨正式交易日计数;加仓、部分卖出和有证据的证券转换不重置,完全卖出再买入开启新周期。锁定和买入保护优先于最长持有退出。
- 日期锁定禁止自动买卖,已接受的挂单不自动撤销;手工路径只绕过自动策略保护,不绕过账户授权、T+1、券商和风控。
- 保留的真实持仓继续占用资金与席位,不把未完成卖出当现金。最长持有退出先形成唯一最终目标,不能叠加一笔策略部分卖出和一笔框架全量卖出。
- 在线上下文重建必须注入已经校验的真实成交/持仓快照,不能把重建日或旧行情日当建仓日。期货或股票期货混合账户未纳入本合同,显式拒绝。
## 根因补充修复
组合 `decision_date == "2026-09-11" && symbol == "000001.SZ"` 会落到字符串表达式路径。旧代码遗漏日期等内建标识符的保留登记,又按“额外因子”注入NaN,覆盖同名真实日期,造成选股错误。现登记全部已注入内建字段,并禁止额外因子覆盖已存在的作用域变量。单独数字VM日期测试不足以发现该问题,新增日期+证券混合选择回归。
## 验证与边界
原生完整回测测试验证:显式策略真实模拟成交日启动3日保护/禁买、日期锁定零委托、最长持有让位于保护、锁定持仓占据资金与席位、解锁后才按候选顺序买入;序列化和别名归一不改max_holding_days字段,冲突策略拒绝。现有534核心用例通过(6个既有忽略项)。这些是隔离内核测试,不是GT实际成交验收。
+24
View File
@@ -0,0 +1,24 @@
# 完成日线形态与次日信号
`fidc_daily_ohlcv_pattern_v1``fidc-core::daily_patterns` 单一计算核实现。Source Lake 只读取、核验及传输真实 OHLCV;研究服务和策略表达式不分别维护数值算法。
四种量价条件为趋势强势、前高突破、放量上涨、缩量突破;额外提供独立的均线下方、放量下跌卖出条件。前三者名称不暗示当日金叉或价格突破等未实际检验的事实。
## 应用阶段
- `filter.stock_expr(pattern_signal("<模板 JSON>"))`:选择候选,再按既有顺序和 Top N 取目标。
- `filter.buy_expr(pattern_signal("<模板 JSON>"))`:只限制正向仓位增量,不移除目标、不反向清仓,正常减仓不受影响。
- `risk.stop_loss(pattern_signal("<独立卖出模板 JSON>"))`:独立退出条件,不使用买入条件的反值。
- `pattern_score` 只可用于已通过形态条件的对象;没有放量参照或合法排除对象不伪造零分。
参数是 JSON 字符串,例如 `pattern_signal("{\"template\":\"ma_below\",\"parameters\":{\"ma_window\":20}}")`
新规则必须显式 `execution.matching_type("next_bar_open")`。信号日 D 的完整日线不能用于 D 日盘前或盘中;历史回放按 D 决策、下一真实交易日执行,实时上下文使用已完成 D 日窗口。实际委托仍需要执行日行情、资金、可卖数量、交易许可和风控。不得用研究结果开启交易路由。
## 数据与预热
所有价格统一用真实 backward1 因子,成交量不复权。缺失、非有限值、无效 OHLC、重复、未来行、未声明停牌状态均拒绝。仅按明确上市日期证明的上市前窗口或正式停牌记录可以返回结构化排除;不补价、不跳过日期压缩窗口。有效价格但缺复权因子即使停牌也报错。回测和运行态须从表达式提取真实窗口需求,冻结完整日历预热。
研究选择的范围及日期、上市/停牌排除证据、源查询和哈希需保留。固定候选的后续规则回测不等于历史全市场动态选股。CAPM 全区间拟合属于解释性诊断;要成为次日条件,必须另行使用截至 D 日的滚动估计并验证样本外表现,不得回填到拟合区间内。
旧任务默认撮合、历史筛选记录和策略源码不变;用户显式创建新规则后才采用此合同。
+10 -2
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
@@ -39,7 +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 | Stock execution now 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 and account units. Market indicators and return statistics remain `f64` outside the execution boundary. | Migrate position cost/PnL and the standalone futures cash ledger only after independent artifact and performance A/B gates. |
| 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. |
@@ -58,6 +59,10 @@ futures path. Confirmed aligned areas:
- [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] Intraday subscription guards at strategy API level; intraday execution uses minute quote semantics.
@@ -73,6 +78,9 @@ futures path. Confirmed aligned areas:
- [x] Trading-date range, previous-date, and next-date helpers.
- [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,43 @@
{
"schemaVersion": "fidc-batched-current-rolling-rejection/v1",
"measuredAt": "2026-09-05T02:38:00+08:00",
"host": "192.168.31.177",
"candidateCommit": "004a46c",
"revertCommit": "43b15b2098c427869a4a582b4b24325155b1370e",
"restoredRunnerBinarySha256": "a4135986b69625a0f3443e9091754874b3f9d65e9913424298c5d8fedf733985",
"candidate": {
"description": "collect static current rolling windows at strategy construction, batch them per stock, and store fixed current close/volume arrays in StockExpressionState",
"processColdEngineSeconds": 6.412,
"processHotEngineSeconds": [6.046, 6.497, 6.035, 6.309, 6.074],
"processHotMedianEngineSeconds": 6.074
},
"rollback": {
"processColdEngineSeconds": 5.18,
"processHotEngineSeconds": [5.48, 5.47, 4.602],
"processHotMedianEngineSeconds": 5.47
},
"observedCandidateRegressionPercent": 11.04204753199269,
"businessContract": {
"totalReturn": 0.9219861819172002,
"tradeCount": 26088,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"testGate": {
"coreUnitTotal": 421,
"corePassed": 415,
"ignoredManualBenchmarks": 6,
"failed": 0,
"integrationSuitesPassed": true
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/batched-current-rolling-primary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/batched-current-rolling-rollback-20260905.json"
],
"acceptance": {
"status": "rejected_and_removed",
"reason": "the larger per-stock state and eager batch work cost more than the repeated scalar helper calls on the real five-year workload"
}
}
@@ -0,0 +1,54 @@
{
"schemaVersion": "fidc-cached-symbol-board-rejection/v1",
"measuredAt": "2026-09-05T03:22:00+08:00",
"host": "192.168.31.177",
"candidateCommit": "eb8b146",
"revertCommit": "f210539",
"candidate": {
"description": "precompute a symbol-id-aligned BJSE bit vector and share one suffix classifier between platform expressions and risk control",
"primaryHotEngineSeconds": [4.656, 4.759, 4.669, 4.698, 4.614],
"primaryHotMedianEngineSeconds": 4.669,
"acceptedPrimaryBaselineMedianEngineSeconds": 4.729,
"primaryObservedImprovementPercent": 1.2687661249735674,
"secondaryLowContentionEngineSeconds": [4.404, 4.327, 4.346],
"secondaryLowContentionMedianEngineSeconds": 4.346,
"acceptedSecondaryBaselineMedianEngineSeconds": 4.049,
"secondaryObservedRegressionPercent": 7.33514448011855,
"highContentionSecondaryEngineSecondsExcluded": [6.398]
},
"rollbackComparison": {
"primaryEngineSecondsExcluded": [12.736, 12.795, 12.899, 13.845],
"hostLoadAverage": 44.84,
"reason": "managed symbolic workers entered a roughly 30-core phase, so the rollback batch cannot serve as a same-load wall-time comparison"
},
"businessContract": {
"primaryTotalReturn": 0.9219861819172002,
"primaryTradeCount": 26088,
"primaryCanonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"primaryResultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
"secondaryTotalReturn": 1.1342962298106998,
"secondaryTradeCount": 19404,
"secondaryCanonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
"secondaryResultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"profile": {
"allThreadRunId": "btr_1788549525151_3404548_13",
"allThreadRunEngineSeconds": 4.957,
"trimMatchesPercent": 2.0,
"typedBaselineSingleWorkerProfileTrimMatchesPercent": 2.85,
"comparisonLimited": true,
"reason": "the two profiles used different thread attachment sets and cannot establish an end-to-end speedup"
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/cached-symbol-board-primary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/cached-symbol-board-secondary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/cached-symbol-board-rollback-primary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/cached-symbol-board-all-threads-profile-20260905/perf.data"
],
"acceptance": {
"status": "rejected_and_removed",
"reason": "the candidate preserved correctness but did not improve both five-year strategies, and the later rollback batch was too heavily contended to overturn the cross-strategy regression"
}
}
@@ -0,0 +1,85 @@
{
"schemaVersion": "fidc-calendar-major-series-boundary-index/v1",
"measuredAt": "2026-09-05T02:24:00+08:00",
"host": "192.168.31.177",
"engineCommit": "abe4fed4527e07ad7ae4464e574fa582150e306e",
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
"runnerBinarySha256": "a4135986b69625a0f3443e9091754874b3f9d65e9913424298c5d8fedf733985",
"implementation": {
"description": "transpose immutable decision/current market-series boundary indexes from symbol-major vectors to calendar-major contiguous symbol rows",
"logicalEntryCountChanged": false,
"entryType": "u32",
"missingSentinel": "u32::MAX",
"factorValuesCached": false,
"selectionResultsCached": false,
"pitSemanticsChanged": false
},
"primaryFiveYearContract": {
"startDate": "2021-08-23",
"endDate": "2026-08-28",
"frequency": "1d",
"matchingType": "next_bar_open",
"totalReturn": 0.9219861819172002,
"tradeCount": 26088,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
"processCold": {
"totalSeconds": 18.605,
"dataSeconds": 12.849,
"dataSetConstructSeconds": 4.052,
"marketIndexBuildSeconds": 2.396,
"engineSeconds": 5.06
},
"processHotEngineSeconds": [5.297, 5.227, 5.029, 5.012, 5.202],
"processHotMedianEngineSeconds": 5.202,
"fieldProjectionBaselineMedianEngineSeconds": 5.356,
"observedMedianImprovementPercent": 2.875280059746078,
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"secondaryFiveYearContract": {
"totalReturn": 1.1342962298106998,
"tradeCount": 19404,
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
"engineSeconds": [4.646, 4.87, 4.886, 5.056],
"medianEngineSeconds": 4.878,
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"profile": {
"runId": "btr_1788546155152_3120069_10",
"engineSeconds": 5.211,
"eventCycles": 14556215580,
"seriesEndIndexPresentInTopProfile": false,
"fieldProjectionBaselineSeriesEndIndexPercent": 4.74,
"stockStateSelfPercent": 15.19,
"adjustedCloseMovingAveragePercent": 6.24,
"numericVmPercent": 6.77
},
"memory": {
"serviceCgroupCurrentBytes": 11493711872,
"serviceCgroupPeakBytes": 11495387136,
"processRssKiB": 11212504,
"processAnonymousKiB": 11196148,
"fieldProjectionBaselineCgroupCurrentBytes": 11485425664,
"observedCgroupIncreaseBytes": 8286208
},
"testGate": {
"coreUnitTotal": 421,
"corePassed": 415,
"ignoredManualBenchmarks": 6,
"failed": 0,
"integrationSuitesPassed": true
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/calendar-major-series-boundary-primary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/calendar-major-series-boundary-secondary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/calendar-major-series-boundary-profile-20260905/perf.data",
"/srv/fidc/canonical/run/fidc-private/evidence/calendar-major-series-boundary-profile-20260905/perf-report.txt"
],
"acceptance": {
"status": "accepted_generic_calendar_major_boundary_index",
"reason": "both five-year contracts preserve exact outputs and clean terminal audits, the primary hot median improves, the former boundary lookup leaves the top profile, and steady-state memory remains effectively unchanged"
}
}
@@ -0,0 +1,46 @@
{
"schemaVersion": "fidc-compact-adjusted-close-rejection/v1",
"measuredAt": "2026-09-05T04:29:00+08:00",
"host": "192.168.31.177",
"candidateCommit": "ab87e18",
"revertCommit": "0c2681e6996800eae5f3b881e75a01e7a078863f",
"restoredRunnerBinarySha256": "3e69af42e41321d31c69b552cf22d7033ce1ea8d94305a32e32461148cdbfc60",
"candidate": {
"description": "replace two Vec<Option<f64>> adjusted-close arrays with f64 arrays using NaN as the internal missing sentinel",
"theoreticalSteadyStateMemoryReductionBytesPerMarketRow": 16,
"hotEngineSeconds": [5.119, 5.168, 5.516, 5.332, 4.69],
"hotMedianEngineSeconds": 5.168
},
"acceptedBaseline": {
"historicalMedianEngineSeconds": 3.896,
"sameWindowRollbackEngineSeconds": [4.144, 4.169],
"sameWindowRollbackMedianEngineSeconds": 4.169
},
"observed": {
"regressionVersusHistoricalBaselinePercent": 32.64887063655031,
"regressionVersusSameWindowRollbackPercent": 23.962580954665402
},
"businessContract": {
"totalReturn": 0.9219861819172002,
"tradeCount": 26088,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"testGate": {
"coreUnitTotal": 422,
"corePassed": 416,
"ignoredManualBenchmarks": 6,
"failed": 0,
"integrationSuitesPassed": true
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/compact-adjusted-close-primary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/compact-adjusted-close-rollback-primary-20260905.json"
],
"acceptance": {
"status": "rejected_and_removed",
"reason": "the memory-dense NaN representation materially slowed the hottest moving-average path despite preserving exact business results"
}
}
@@ -0,0 +1,79 @@
{
"schemaVersion": "fidc-compact-daily-stock-state-cache-key/v1",
"measuredAt": "2026-09-05T01:20:00+08:00",
"host": "192.168.31.177",
"engineCommit": "6f81e1940ad4ddd4eefbe12c056be28f2b64d127",
"runnerBinarySha256": "c2d9101a89b71b51051b8762375476518e40d40e29a8ee0f9615ac4c55fcdb0a",
"implementation": {
"description": "remove the redundant execution date from stock-state cache keys because the complete cache is already cleared before the active date changes",
"keyBefore": "(execution_date, factor_date, symbol_id, execution_time, use_intraday_quote)",
"keyAfter": "(factor_date, symbol_id, execution_time, use_intraday_quote)",
"crossDateResetTested": true,
"cachedFactorValues": false,
"cachedSelectionResults": false,
"pitSemanticsChanged": false
},
"primaryFiveYearContract": {
"startDate": "2021-08-23",
"endDate": "2026-08-28",
"frequency": "1d",
"matchingType": "next_bar_open",
"totalReturn": 0.9219861819172002,
"tradeCount": 26088,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
"hotEngineSeconds": [5.262, 5.734, 5.89, 5.612, 6.301],
"hotMedianEngineSeconds": 5.734,
"sharedOrderBaselineMedianEngineSeconds": 5.742,
"observedMedianImprovementPercent": 0.13932427725531174,
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"secondaryFiveYearContract": {
"totalReturn": 1.1342962298106998,
"tradeCount": 19404,
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
"engineSeconds": [5.889, 5.853, 5.342],
"medianEngineSeconds": 5.853,
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"coldDataObservation": {
"runId": "btr_1788542108713_2936456_0",
"totalSeconds": 54.502,
"dataSeconds": 46.584,
"engineSeconds": 7.011,
"sourceQuerySeconds": 17.585,
"datasetConstructSeconds": 12.913,
"marketIndexBuildSeconds": 7.671,
"adjustmentValidationSeconds": 1.855,
"hotDataSecondsRange": [0.002, 0.006],
"performanceComparisonExcluded": true
},
"memoryObservation": {
"processRssKiB": 11136672,
"processAnonymousKiB": 11120428,
"cgroupCurrentBytes": 12445237248,
"cgroupFileCacheBytes": 1029632000,
"cgroupActiveFileBytes": 1009012736,
"duplicateDataSetLeakObserved": false,
"note": "the gap between process RSS and cgroup memory is reclaimable file-page cache rather than a second resident DataSet"
},
"testGate": {
"coreUnitTotal": 420,
"corePassed": 414,
"ignoredManualBenchmarks": 6,
"failed": 0,
"integrationSuitesPassed": true
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/compact-stock-cache-key-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/compact-stock-cache-key-secondary-20260905.json"
],
"acceptance": {
"status": "accepted_structural_compaction_no_regression",
"endToEndSpeedupClaimed": false,
"reason": "the key is smaller, the cross-date isolation test passes, both five-year strategies retain exact results, and observed wall time is neutral to slightly better"
}
}
@@ -0,0 +1,74 @@
{
"schemaVersion": "fidc-current-rolling-boundary-reuse-ab/v1",
"measuredAt": "2026-09-04T15:17:49Z",
"host": "192.168.31.177",
"engineCommit": "52b07be19bdd1597e5b412b5a549e57640efa415",
"implementation": {
"description": "resolve the current market-series end once per stock state and reuse the integer boundary for literal stock rolling_mean_current calls",
"cachedFactorValues": false,
"cachedStrategyResults": false,
"additionalHeapCollectionsPerStock": 0,
"fallback": "dynamic or non-market fields retain the original date and factor-series lookup",
"pitSemanticsChanged": false,
"adjustmentSemanticsChanged": false
},
"componentBenchmark": {
"iterations": 100000,
"helperCallsPerIteration": 5,
"repeatedLookupSeconds": 0.009142143,
"reusedBoundarySeconds": 0.006728638,
"speedup": 1.3586914617787433,
"equal": true
},
"testGate": {
"total": 418,
"passed": 413,
"ignoredManualBenchmarks": 5,
"failed": 0
},
"backtestEvidence": {
"runId": "btr_1788535029326_2441112_5",
"startDate": "2021-08-23",
"endDate": "2026-08-28",
"frequency": "1d",
"matchingType": "next_bar_open",
"totalSeconds": 7.509,
"engineSeconds": 6.494,
"totalReturn": 0.9219861819172002,
"tradeCount": 26088,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54"
},
"secondaryStrategyEvidence": {
"description": "same frozen bundle and execution contract with target positions changed from 30 to 20",
"runId": "btr_1788535290995_2441112_6",
"totalSeconds": 9.081,
"engineSeconds": 8.367,
"dataSeconds": 0.003,
"totalReturn": 1.1342962298106998,
"tradeCount": 19404,
"tradeDateCount": 1216,
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
"matchesPreOptimizationBusinessResult": true
},
"artifacts": [
{
"path": "/srv/fidc/canonical/run/fidc-private/evidence/current-rolling-boundary-profile-20260904/perf.data",
"sha256": "be9f6e04c01a98cb834bc3f207359dc17555e0e660e178d552b525722a13873a"
},
{
"path": "/srv/fidc/canonical/run/fidc-private/evidence/current-rolling-boundary-profile-20260904/perf-report.txt",
"sha256": "6f4e1e4370ce996fcf7308a7a723469b417d8c03156eb43b5a26a5fb42c42511"
},
{
"path": "/srv/fidc/canonical/run/fidc-private/evidence/current-rolling-boundary-profile-20260904/run.json",
"sha256": "fee9f104d72d8918cdcd7660d7cd40dced9a84edcabcd68e89588ce5690523a5"
}
],
"acceptance": {
"status": "accepted_component_improvement_end_to_end_no_regression",
"endToEndSpeedupClaimed": false,
"reason": "the component result is positive and exact, while concurrent non-FIDC tan load and symbolic phases make cross-build wall-time medians non-comparable"
}
}
@@ -0,0 +1,71 @@
{
"schemaVersion": "fidc-daily-snapshot-view-stock-state-rejection/v1",
"measuredAt": "2026-09-05T00:30:00+08:00",
"host": "192.168.31.177",
"candidateCommit": "1df0081479b93a051f9a40d2acf7060d0eb6929d",
"revertCommit": "1f10a6bb3decdd40c77397a7596c6f23a9f90a18",
"candidate": {
"description": "carry symbol_id in EligibleUniverseSnapshot and pass pre-resolved execution/factor DailySnapshotView values into stock-state construction",
"componentBenchmark": {
"symbols": 6000,
"rounds": 200,
"dateMapLookupSeconds": 0.020211,
"dailyViewLookupSeconds": 0.006410,
"speedup": 3.1530421216848675,
"equal": true
},
"fiveYearRuns": [
{"runId": "btr_1788538826285_2693431_0", "engineSeconds": 8.075},
{"runId": "btr_1788538854544_2693431_1", "engineSeconds": 8.488},
{"runId": "btr_1788538868210_2693431_2", "engineSeconds": 10.455},
{"runId": "btr_1788538887159_2693431_3", "engineSeconds": 10.672},
{"runId": "btr_1788538903432_2693431_4", "engineSeconds": 11.284},
{"runId": "btr_1788538920072_2693431_5", "engineSeconds": 9.985}
],
"hotMedianEngineSeconds": 10.455,
"runnerBinarySha256": "cbe20d1bd5c5e02372bd310107a1f4e41aaa9f256b3c31f79c6ad000648d1854"
},
"restored": {
"fiveYearRuns": [
{"runId": "btr_1788539286281_2739751_0", "engineSeconds": 7.118},
{"runId": "btr_1788539313845_2739751_1", "engineSeconds": 6.702},
{"runId": "btr_1788539325631_2739751_2", "engineSeconds": 7.093},
{"runId": "btr_1788539338757_2739751_3", "engineSeconds": 6.509}
],
"hotMedianEngineSeconds": 6.702,
"candidateRegressionPercent": 55.99820948970456,
"runnerBinarySha256": "e33dd75aa7b481975c0991cf634d07900cdefa51756802e28e64f18b980a83dc",
"matchesPreCandidateRunnerSha256": true
},
"businessContract": {
"startDate": "2021-08-23",
"endDate": "2026-08-28",
"frequency": "1d",
"matchingType": "next_bar_open",
"totalReturn": 0.9219861819172002,
"tradeCount": 26088,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
"allRunsConsistent": true
},
"profile": {
"candidateRunId": "btr_1788538966659_2693431_6",
"candidateEngineSeconds": 10.566,
"stockStatePercent": 28.14,
"adjustedCloseMovingAveragePercent": 8.27,
"stableSortPercent": 5.46,
"mallocPercent": 5.12,
"note": "The removed map lookup symbols disappeared, but their component savings were too small to offset the larger universe row and generalized optional-view state path."
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/daily-view-after-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/daily-view-profile-20260905/perf.data",
"/srv/fidc/canonical/run/fidc-private/evidence/daily-view-profile-20260905/perf-report.txt",
"/srv/fidc/canonical/run/fidc-private/evidence/daily-view-reverted-20260905.json"
],
"decision": {
"status": "rejected_and_removed",
"reason": "a positive lookup microbenchmark did not translate to end-to-end performance; the same frozen five-year workload became about 56 percent slower",
"productionEngineCommit": "1f10a6bb3decdd40c77397a7596c6f23a9f90a18"
}
}
@@ -0,0 +1,90 @@
{
"schemaVersion": "fidc.date-numeric-vm-performance/v1",
"generatedAt": "2026-09-06T18:08:00+08:00",
"host": "192.168.31.177",
"change": {
"engineCommit": "bc228980afc02cde483b1a6fe69da4048120cbc4",
"serviceCommit": "bac07c208c652a717d312e332d44f90218cc1247",
"runnerBinarySha256": "46684dea71273466f8ba0f6351eb6f6adf694393ff23ee278054e3b6b71acbe5",
"description": "Valid ISO date literals directly compared with trade_date, current_date, date, decision_date or execution_date compile to the numeric bytecode VM as YYYYMMDD keys. All other strings remain on the Rhai path. Numeric VM floating comparisons now match Rhai relative epsilon semantics."
},
"correctnessBug": {
"previousBehavior": "Numeric VM used exact f64 comparisons while Rhai used relative f64::EPSILON comparisons, so an expression could change result depending on whether it compiled to VM or fell back to Rhai.",
"capturedExample": "11.699999999999998 == 11.7",
"previousVmResult": false,
"rhaiAndFixedVmResult": true,
"operatorsAligned": [
"==",
"!=",
"<",
"<=",
">",
">="
]
},
"request": {
"sourceRunId": "btr_1788681250851_3170500_1",
"startDate": "2023-01-03",
"endDate": "2025-12-31",
"frequency": "1d",
"matchingType": "next_bar_open",
"initialCash": 10000000.0,
"benchmark": "000852.SH",
"frozenBundleReused": true
},
"baseline": {
"engineCommit": "840473362d7032b80d551ba5935723ee823dffa1",
"hotEngineMedianSeconds": 5.049,
"hotTotalMedianSeconds": 5.796
},
"candidate": {
"coldRun": {
"runId": "btr_1788688964051_3295756_0",
"totalSeconds": 21.341,
"dataSeconds": 7.904,
"engineSeconds": 12.519,
"classification": "post-deploy process cold and concurrent host-load observation; retained explicitly and excluded from the hot median"
},
"hotRunIds": [
"btr_1788688990229_3295756_1",
"btr_1788689001276_3295756_2",
"btr_1788689030863_3295756_3",
"btr_1788689059131_3295756_4"
],
"hotEngineSeconds": [
4.963,
4.934,
4.854,
4.859
],
"hotEngineMedianSeconds": 4.8965,
"hotTotalSeconds": [
5.901,
5.619,
5.738,
5.539
],
"hotTotalMedianSeconds": 5.6785,
"incrementalHotEngineImprovementPercent": 3.0204,
"incrementalHotTotalImprovementPercent": 2.0273
},
"resultParity": {
"totalReturn": -0.615167693429,
"tradeCount": 4961,
"canonicalResultSha256": "f9c26271d4f2096e51429b6106740f600757823789bf6447cd0c524f702f0836",
"resultStoreSha256": "56042bd25bd4261e41e8237c9f5ead580b782d897baae58c8e4436f331a1d91f",
"allFiveRunsEqualBaseline": true,
"returnDelta": 0.0,
"tradeCountDelta": 0
},
"validation": {
"numericVmTests": "4 passed, 1 ignored",
"capturedRegressionTest": "platform_helpers_support_generic_rolling_stats_and_normalized_factors",
"workspaceTests": "423 passed, 6 ignored plus all integration tests passed",
"invalidDateLiteralFallsBack": true,
"nonDateStringFallsBack": true,
"helperDateArgumentPreserved": true,
"runtimeUser": "boris",
"serviceHealth": "ok"
}
}
@@ -0,0 +1,18 @@
{
"date": "2026-09-08",
"host": "192.168.31.177",
"candidateCommit": "fa6f189",
"unitTestsPassed": 453,
"integrationTestsPassed": 122,
"ignoredManualBenchmarks": 8,
"failed": 0,
"backtestRunnerCargoCheck": "passed",
"covered": ["top-up rejection", "sell permission preserved", "decision context restored", "existing pending order not rewritten", "next-open target direction determined by actual price"],
"deployed": false,
"factorCompilerConnected": false,
"paperLivePlanConnected": false,
"orderAmendmentAcceptanceComplete": false,
"brokerAmendmentTestsPassed": true,
"amendmentPolicy": "Deny buy quantity increases or limit-price increases; allow validated reductions; preserve original state and queue order on rejection.",
"realStrategyAcceptanceComplete": false
}
@@ -0,0 +1,78 @@
{
"schemaVersion": "fidc-duplicate-rolling-lookback-rejection/v1",
"measuredAt": "2026-09-05T01:05:00+08:00",
"host": "192.168.31.177",
"candidateCommits": [
"1f02e78b2400c73c5f05351b0beff16d6c724e2a",
"e469b0ddf4aa3bb9fe69b785b51ce6c7cf960b90"
],
"revertCommit": "5b2a03d416a7bd709680a1adf52013e3eadc199a",
"candidate": {
"description": "reuse equal lookback values only inside one AdjustedCloseSeries moving_averages_at_end call",
"validComponentBenchmark": {
"samples": 6,
"iterationsPerSample": 3000000,
"lookbacks": [1, 2, 3, 1, 2, 3, 1],
"repeatedSeconds": 1.40643611,
"reusedSeconds": 0.853281932,
"speedup": 1.6482666012902287,
"checksum": 1458000000.0
},
"hardwareCounters": {
"runId": "btr_1788541184367_2862338_11",
"engineSeconds": 8.423,
"taskClockMilliseconds": 9030.56,
"cycles": 22931608591,
"instructions": 28199702874,
"branches": 5010114942,
"branchMisses": 23586244,
"cacheReferences": 268428800,
"cacheMisses": 146632717
}
},
"restoredBaseline": {
"warmupRunId": "btr_1788541416661_2890172_0",
"warmupEngineSeconds": 5.581,
"hardwareCounters": {
"runId": "btr_1788541455567_2890172_1",
"engineSeconds": 6.078,
"taskClockMilliseconds": 6643.43,
"cycles": 16773013609,
"instructions": 28218847364,
"branches": 5024236708,
"branchMisses": 23209586,
"cacheReferences": 266316407,
"cacheMisses": 81226408
},
"runnerBinarySha256": "188269bf0c2ee65d6c11ed37ddddfdaf86695941e8123f69f950054bea39796e"
},
"comparison": {
"candidateInstructionReductionPercent": 0.06784229696471904,
"candidateBranchReductionPercent": 0.2810720756590066,
"materialSystemImprovementProven": false,
"wallTimeComparable": false,
"reason": "host-wide thermal and external workload changes produced materially different cache-miss and clock conditions; the instruction reduction itself is below one tenth of one percent"
},
"businessContract": {
"startDate": "2021-08-23",
"endDate": "2026-08-28",
"frequency": "1d",
"matchingType": "next_bar_open",
"totalReturn": 0.9219861819172002,
"tradeCount": 26088,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
"allRunsConsistent": true
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/duplicate-lookback-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/duplicate-lookback-low-contention-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/duplicate-lookback-perf-stat-candidate-20260905/perf-stat.csv",
"/srv/fidc/canonical/run/fidc-private/evidence/duplicate-lookback-perf-stat-baseline-20260905/perf-stat.csv"
],
"decision": {
"status": "rejected_and_removed",
"reason": "the valid microbenchmark improves, but the real five-year workload saves only about 0.068 percent instructions and provides no measurable system-level benefit",
"productionEngineCommit": "5b2a03d416a7bd709680a1adf52013e3eadc199a"
}
}
@@ -0,0 +1,47 @@
{
"schemaVersion": "fidc-engine-optimization-rejection/v1",
"generatedDate": "2026-09-07",
"host": "192.168.31.177",
"candidate": {
"engineCommit": "757b5665ca01bceb2bf8afe1ae2fe2dfc9c52c5c",
"implementationIdentitySha256": "a60aed1a405c1dc4713fdf72a379dcd07bc493998ec92c0aa0573c21ee863fc8",
"description": "Keep the flat immutable quote base and merge a run-local lazy quote overlay through every DataSet quote API."
},
"revert": {
"engineCommit": "728ed795b0c3b9bac9b7e79cb90e469c106e3629",
"status": "deployed_and_healthy"
},
"measurements": {
"currentCloseOneYear": {
"acceptedHotMedianEngineSeconds": 0.722,
"candidateHotMedianEngineSeconds": 0.858,
"candidateHotMedianTotalSeconds": 1.56,
"engineRegressionPercent": 18.836565
},
"nextBarOpenFiveYear": {
"acceptedHotMedianEngineSeconds": 2.223,
"candidateHotMedianEngineSeconds": 3.319,
"candidateHotMedianTotalSeconds": 4.032,
"engineRegressionPercent": 49.302744
}
},
"correctness": {
"totalReturnEqual": true,
"tradeCountEqual": true,
"canonicalResultDigestEqual": true,
"resultStoreDigestEqual": true,
"terminalAuditEqual": true,
"candidateWorkspaceCoreTestsPassed": 442,
"revertWorkspaceCoreTestsPassed": 441,
"workspaceFailures": 0
},
"decision": {
"status": "rejected_and_removed",
"reason": "Public DataSet reads paid overlay branching and enlarged code paths even when no overlay existed; end-to-end regressions outweighed the avoided lazy-write clone.",
"replacementDirection": "Build immutable shared Base Panels before engine execution. Any supplemental intraday store must be private to the broker/loader and must not alter generic DataSet read APIs."
},
"supportingEvidence": [
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-run-overlay-rejection-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-run-overlay-rejection-20260907.json"
]
}
@@ -0,0 +1,108 @@
{
"schemaVersion": "fidc.engine-expression-scope-performance/v1",
"generatedAt": "2026-09-06T17:45:00+08:00",
"host": "192.168.31.177",
"profile": {
"artifact": "/srv/fidc/canonical/run/fidc-private/evidence/profiles/fidc-hot-engine-d324d284-20260906.data",
"event": "cpu-clock",
"frequencyHz": 499,
"samples": 3501,
"lostSamples": 0,
"scope": "system.slice/fidc-backtest-service-highmem177.service",
"observedSelfOverheadPercent": {
"chronoDelayedFormat": 6.31,
"chronoStrftimeIterator": 5.0,
"selectiveScopePushDynamic": 5.2,
"selectiveScopePushString": 3.48,
"selectiveScopePushInteger": 3.03,
"selectiveScopePushBoolean": 1.09,
"marketBySymbolId": 6.2,
"malloc": 4.74,
"free": 2.43
}
},
"change": {
"baselineEngineCommit": "630a7a1",
"candidateEngineCommit": "840473362d7032b80d551ba5935723ee823dffa1",
"serviceCommit": "bac07c208c652a717d312e332d44f90218cc1247",
"behavior": "Expression scope identifiers are passed directly to Rhai without an intermediate heap String. Trade, decision and execution date strings are formatted once per DayExpressionState and reused as ImmutableString values by every expression evaluation on that day."
},
"request": {
"sourceRunId": "btr_1788681250851_3170500_1",
"startDate": "2023-01-03",
"endDate": "2025-12-31",
"frequency": "1d",
"matchingType": "next_bar_open",
"initialCash": 10000000.0,
"benchmark": "000852.SH",
"frozenBundleReused": true
},
"baseline": {
"runIds": [
"btr_1788686878533_3170500_3",
"btr_1788686907702_3170500_4",
"btr_1788687153269_3170500_5"
],
"engineSeconds": [
6.582,
6.442,
6.352
],
"engineMedianSeconds": 6.442,
"totalSeconds": [
7.22,
7.04,
7.388
],
"totalMedianSeconds": 7.22
},
"candidate": {
"coldRun": {
"runId": "btr_1788687746484_3277755_0",
"totalSeconds": 13.369,
"dataSeconds": 7.827,
"engineSeconds": 4.925,
"classification": "runner_process_cold"
},
"hotRunIds": [
"btr_1788687763649_3277755_1",
"btr_1788687774308_3277755_2",
"btr_1788687798961_3277755_3",
"btr_1788687813292_3277755_4"
],
"hotEngineSeconds": [
5.021,
5.077,
5.18,
4.951
],
"hotEngineMedianSeconds": 5.049,
"hotTotalSeconds": [
5.659,
5.756,
5.836,
5.84
],
"hotTotalMedianSeconds": 5.796,
"engineMedianImprovementPercent": 21.624,
"totalMedianImprovementPercent": 19.723
},
"correctness": {
"totalReturn": -0.615167693429,
"tradeCount": 4961,
"canonicalResultSha256": "f9c26271d4f2096e51429b6106740f600757823789bf6447cd0c524f702f0836",
"resultStoreSha256": "56042bd25bd4261e41e8237c9f5ead580b782d897baae58c8e4436f331a1d91f",
"allFiveCandidateRunsEqualBaseline": true,
"returnDelta": 0.0,
"tradeCountDelta": 0
},
"validation": {
"cargoFmt": "passed",
"platformExpressionTests": "180 passed, 1 ignored",
"workspaceTests": "421 passed, 6 ignored plus all integration tests passed",
"runtimeUser": "boris",
"serviceHealth": "ok",
"buildManifest": "/srv/fidc/canonical/run/build/fidc-backtest-service.json",
"runnerBinarySha256": "909cb47ef20128b4990bf355d0c63e10d7d74aac128067fa19c971698c5d85bc"
}
}
@@ -0,0 +1,51 @@
{
"schemaVersion": "fidc-frozen-standard-rolling-lookbacks-rejection/v1",
"measuredAt": "2026-09-05T03:03:00+08:00",
"host": "192.168.31.177",
"candidateCommit": "32e4030",
"revertCommit": "05e67c73df0c002efec15711dd5ef066d817abec",
"restoredRunnerBinarySha256": "1071852a374620029398a967d485b827ed2defbb6ecbcc3f1ee6ffd3e2db76b6",
"candidate": {
"description": "replace per-stock rolling requirement-set lookups with strategy-level fixed standard lookback arrays",
"primaryHotEngineSeconds": [4.329, 4.455, 4.751, 4.822, 4.847],
"primaryHotMedianEngineSeconds": 4.751,
"secondaryEngineSeconds": [4.774, 5.473, 6.161, 5.491],
"hardwareRunId": "btr_1788548442183_3221468_10",
"hardwareRunEngineSeconds": 10.522,
"hardwareRunHostLoadAverage": 42.93,
"instructions": 26384933876,
"branches": 4682344116
},
"acceptedBaseline": {
"engineCommit": "75ab0c0",
"primaryHotMedianEngineSeconds": 4.729,
"instructions": 26049740736,
"branches": 4601262679
},
"observed": {
"primaryMedianRegressionPercent": 0.4652146331148285,
"instructionRegressionPercent": 1.2867427104054538,
"branchRegressionPercent": 1.7621562309418415
},
"businessContract": {
"primaryTotalReturn": 0.9219861819172002,
"primaryTradeCount": 26088,
"primaryCanonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"primaryResultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
"secondaryTotalReturn": 1.1342962298106998,
"secondaryTradeCount": 19404,
"secondaryCanonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
"secondaryResultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/frozen-standard-rolling-lookbacks-primary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/frozen-standard-rolling-lookbacks-secondary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/frozen-standard-rolling-lookbacks-perf-stat-20260905/perf-stat.csv"
],
"acceptance": {
"status": "rejected_and_removed",
"reason": "the compiler layout change increased retired instructions and branches while the primary end-to-end median did not improve"
}
}
@@ -0,0 +1,88 @@
{
"schemaVersion": "fidc-instrument-symbol-id-index-ab/v1",
"measuredAt": "2026-09-04T14:31:25Z",
"host": "192.168.31.177",
"engineCommit": "8ed22897eac506297c9ab4023140fbb6cfa0cc26",
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
"serviceBinarySha256": "24b53421e8724a29963e3254d9dea33c0a2259a81567caa853b98369df7cf704",
"runnerBinarySha256": "0d314809675544fed19ba0888ae22e0098f87da1672ac692fe38f3dbd3b55c70",
"implementation": {
"description": "build one immutable Instrument vector aligned to the existing symbol-id index and use it only at call sites that already own a validated symbol id",
"stringInstrumentApiPreserved": true,
"mutableStateShared": false,
"riskSemanticsChanged": false,
"executionSemanticsChanged": false
},
"componentBenchmark": {
"rows": 6000,
"iterations": 1000,
"hashMapSeconds": 0.09067713,
"denseVectorSeconds": 0.010526148,
"speedup": 8.614464664566752,
"checksum": 600000000,
"equal": true
},
"testGate": {
"total": 417,
"passed": 413,
"ignoredManualBenchmarks": 4,
"failed": 0
},
"backtestContract": {
"bundleId": "bt_bundle_5baf27b98346123e1a7b",
"bundleHash": "68571cc2c3082cacf1774e681d6ff82d1beddb4241cebc2179f9235001cd730f",
"startDate": "2021-08-23",
"endDate": "2026-08-28",
"frequency": "1d",
"matchingType": "next_bar_open",
"totalReturn": 0.9219861819172002,
"tradeCount": 26088,
"tradeDateCount": 1216,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54"
},
"before": {
"runtime": "5dec29a0d6ee",
"lowOverheadEngineSeconds": [7.363, 6.922, 6.917],
"medianEngineSeconds": 6.922
},
"after": {
"processColdRun": {
"runId": "btr_1788533379797_2361496_0",
"totalSeconds": 20.534,
"dataSeconds": 13.02,
"engineSeconds": 6.377
},
"hotRuns": [
{"runId": "btr_1788533414497_2361496_1", "totalSeconds": 7.182, "engineSeconds": 6.517},
{"runId": "btr_1788533424201_2361496_2", "totalSeconds": 7.164, "engineSeconds": 6.482},
{"runId": "btr_1788533436894_2361496_3", "totalSeconds": 7.289, "engineSeconds": 6.48},
{"runId": "btr_1788533447093_2361496_4", "totalSeconds": 7.367, "engineSeconds": 6.477}
],
"medianHotEngineSeconds": 6.481,
"medianEngineImprovementPercent": 6.37,
"lowOverheadProfileRun": {
"runId": "btr_1788533478319_2361496_5",
"totalSeconds": 6.917,
"engineSeconds": 6.251,
"sampleCount": 332,
"lostSamples": 0
},
"serviceMemoryCurrentBytes": 11426889728,
"serviceMemoryPeakBytes": 11434971136
},
"artifacts": [
{
"path": "/srv/fidc/canonical/run/fidc-private/evidence/instrument-symbol-id-profile-20260904/hot-five-year-perf.data",
"sha256": "a3d788f8e6ad799604b83019a4403fee509d6c955606f46bd721960e3d38433b"
},
{
"path": "/srv/fidc/canonical/run/fidc-private/evidence/instrument-symbol-id-profile-20260904/hot-five-year-perf-report.txt",
"sha256": "dbd3004b6ab2719e67b34ca4c6d98f908053f6f28f617bd1c1969552b0f14291"
},
{
"path": "/srv/fidc/canonical/run/fidc-private/evidence/instrument-symbol-id-profile-20260904/hot-five-year-run.json",
"sha256": "092276999525532005a815ab9d2588ee8865b4652c11367ca9ffe52b6a601557"
}
]
}
@@ -0,0 +1,95 @@
{
"schemaVersion": "fidc-interned-stock-state-symbols/v1",
"measuredAt": "2026-09-05T01:50:00+08:00",
"host": "192.168.31.177",
"engineCommit": "e5646ef80c9a5c6d2d3a1b9b6f330fbbc27aa4ab",
"runnerBinarySha256": "c2f6f0b8cc194a5e3f668bcc0d54b3712cf037267ccc6c04d5f850d8ccbd6314",
"implementation": {
"description": "intern each canonical symbol once in DataSet and clone Arc<str> into per-date StockExpressionState objects instead of allocating a String for every state",
"rhaiStringBoundaryPreserved": true,
"crossDateSharedSymbolTested": true,
"cachedFactorValues": false,
"cachedSelectionResults": false,
"pitSemanticsChanged": false
},
"primaryFiveYearContract": {
"startDate": "2021-08-23",
"endDate": "2026-08-28",
"frequency": "1d",
"matchingType": "next_bar_open",
"totalReturn": 0.9219861819172002,
"tradeCount": 26088,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
"lowContentionEngineSeconds": [5.505, 5.748, 5.67, 6.005, 6.497],
"medianEngineSeconds": 5.748,
"compactKeyBaselineMedianEngineSeconds": 5.734,
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"secondaryFiveYearContract": {
"totalReturn": 1.1342962298106998,
"tradeCount": 19404,
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
"engineSeconds": [5.648, 5.35, 5.003],
"medianEngineSeconds": 5.35,
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"hardwareCounters": {
"candidateRunId": "btr_1788544043073_3041540_6",
"candidateEngineSeconds": 8.227,
"candidateInstructions": 27997441984,
"candidateBranches": 4947191270,
"earlierSharedIndexBaselineInstructions": 28218847364,
"earlierSharedIndexBaselineBranches": 5024236708,
"observedInstructionReductionPercent": 0.7846044523842924,
"observedBranchReductionPercent": 1.533451728719079,
"attributionNote": "the earlier hardware baseline predates the compact cache-key change, so counters prove the combined structural reduction rather than symbol interning alone"
},
"profile": {
"runId": "btr_1788544253262_3041540_15",
"totalSeconds": 6.136,
"engineSeconds": 5.509,
"eventCycles": 15296376746,
"stockStatePercent": 16.25,
"preInternStockStatePercent": 23.86,
"mallocPercent": 4.22,
"numericVmPercent": 6.02,
"adjustedCloseMovingAveragePercent": 5.10
},
"excludedHighContentionBatch": {
"hostLoadAveragePeak": 69.23,
"engineSeconds": [7.247, 7.094, 7.481, 7.967, 37.981, 44.366],
"resultConsistent": true,
"performanceComparisonExcluded": true,
"reason": "independent tan child processes executed on backtest-reserved CPUs; no external process was modified"
},
"memory": {
"serviceCgroupCurrentBytes": 11485425664,
"serviceCgroupPeakBytes": 11488174080,
"processRssKiB": 11196756,
"processAnonymousKiB": 11180328
},
"testGate": {
"coreUnitTotal": 420,
"corePassed": 414,
"ignoredManualBenchmarks": 6,
"failed": 0,
"integrationSuitesPassed": true
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/interned-stock-symbol-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/interned-stock-symbol-low-contention-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/interned-stock-symbol-secondary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/interned-stock-symbol-perf-stat-20260905/perf-stat.csv",
"/srv/fidc/canonical/run/fidc-private/evidence/interned-stock-symbol-profile-20260905/perf.data",
"/srv/fidc/canonical/run/fidc-private/evidence/interned-stock-symbol-profile-20260905/perf-report.txt"
],
"acceptance": {
"status": "accepted_structural_allocation_reduction_no_regression",
"endToEndSpeedupClaimed": false,
"reason": "both five-year strategies preserve exact outputs, low-contention wall time is neutral, and the stock-state profile plus hardware counters show less allocation-related work"
}
}
@@ -0,0 +1,62 @@
{
"schemaVersion": "fidc-engine-optimization-rejection/v1",
"generatedDate": "2026-09-07",
"host": "192.168.31.177",
"candidate": {
"engineCommit": "f2de8b281a7f9537665d86895f8c51fb33386d9e",
"implementationIdentitySha256": "1ea0d2834901504e79d76d9df722f009a2ced3c2a02ddf247eae8722be135516",
"description": "Store execution quote dates, symbol maps and quote vectors as nested Arc values so a lazy write clones only one path."
},
"revert": {
"engineCommit": "68c186f649965118da63ff0f0dc2272005538a55",
"implementationIdentitySha256": "4f81eb707baeb76375a03eb8989cb043089b1a7c1d07a998570861eb7dc6fa95",
"description": "Restore the flat read-optimized execution quote index."
},
"measurements": {
"currentCloseOneYear": {
"candidateHotMedianTotalSeconds": 1.19,
"candidateHotMedianEngineSeconds": 0.652,
"revertHotMedianTotalSeconds": 1.183,
"revertHotMedianEngineSeconds": 0.722,
"candidateEngineImprovementPercent": 9.695291
},
"nextBarOpenFiveYear": {
"candidateHotMedianTotalSeconds": 2.896,
"candidateHotMedianEngineSeconds": 2.25,
"revertHotMedianTotalSeconds": 2.848,
"revertHotMedianEngineSeconds": 2.223,
"candidateEngineRegressionPercent": 1.214575
},
"staticCurrentClose": {
"candidateHotMedianEngineSeconds": 0.324,
"acceptedFlatIndexHotMedianEngineSeconds": 0.322
},
"fullMinute": {
"candidateHotMedianEngineSeconds": 0.225,
"acceptedFlatIndexHotMedianEngineSeconds": 0.226
}
},
"correctness": {
"totalReturnEqual": true,
"tradeCountEqual": true,
"canonicalResultDigestEqual": true,
"resultStoreDigestEqual": true,
"terminalAuditEqual": true,
"candidateWorkspaceCoreTestsPassed": 442,
"revertWorkspaceCoreTestsPassed": 441,
"workspaceFailures": 0
},
"decision": {
"status": "rejected_and_removed",
"reason": "Nested Arc indirection improves the lazy-write strategy but causes a repeatable next_bar_open regression and has no static/full-minute read benefit.",
"replacementDirection": "Keep the flat immutable base index and isolate lazy additions in a run-local overlay without changing the base iterator representation."
},
"supportingEvidence": [
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-layered-quote-index-rejection-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/static-pool-layered-quote-index-rejection-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/full-minute-layered-quote-index-rejection-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-layered-quote-index-rejection-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-after-layered-index-revert-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-after-layered-index-revert-20260907.json"
]
}
@@ -0,0 +1,56 @@
{
"schemaVersion": "fidc-lazy-expression-scope-rejection/v1",
"measuredAt": "2026-09-05T04:06:00+08:00",
"host": "192.168.31.177",
"candidateCommit": "1b44928",
"revertCommit": "229ca8332df050c6961f73bd1a17de0dab583cf0",
"restoredRunnerBinarySha256": "3e69af42e41321d31c69b552cf22d7033ce1ea8d94305a32e32461148cdbfc60",
"candidate": {
"description": "add closure-based lazy SelectiveExpressionScope bindings for dates, order/process state, stock strings, and position availability",
"primaryHotEngineSeconds": [3.838, 3.867, 4.017, 3.931, 3.866],
"primaryHotMedianEngineSeconds": 3.867,
"secondaryEngineSeconds": [3.664, 3.649, 3.693, 3.767],
"secondaryMedianEngineSeconds": 3.693
},
"acceptedBaseline": {
"primaryMedianEngineSeconds": 3.896,
"secondaryMedianEngineSeconds": 3.582
},
"observed": {
"primaryImprovementPercent": 0.744353182751538,
"secondaryRegressionPercent": 3.098827470686773
},
"rollbackBatchExcluded": {
"secondaryEngineSeconds": [5.343, 5.352, 6.996],
"hostLoadAverage": 28.38,
"reason": "managed factor workload changed phase after deployment; this batch is retained for correctness only"
},
"businessContract": {
"primaryTotalReturn": 0.9219861819172002,
"primaryTradeCount": 26088,
"primaryCanonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"primaryResultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
"secondaryTotalReturn": 1.1342962298106998,
"secondaryTradeCount": 19404,
"secondaryCanonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
"secondaryResultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"testGate": {
"coreUnitTotal": 423,
"corePassed": 417,
"ignoredManualBenchmarks": 6,
"failed": 0,
"integrationSuitesPassed": true
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/lazy-expression-scope-primary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/lazy-expression-scope-secondary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/lazy-expression-scope-rollback-secondary-20260905.json"
],
"acceptance": {
"status": "rejected_and_removed",
"reason": "closure-based lazy bindings marginally helped one strategy but regressed the second stable five-year contract, so the implementation was removed"
}
}
@@ -0,0 +1,95 @@
{
"schemaVersion": "fidc-engine-optimization-ab/v1",
"generatedDate": "2026-09-07",
"host": "192.168.31.177",
"change": {
"engineCommit": "df7a1ee382843b22d145718576d24e78fdd6c804",
"description": "Use binary partition points on the shared ascending market-cap symbol index before constructing per-stock expression state.",
"activation": "Only ascending market-cap rank with a market-cap selection field and finite inclusive bounds.",
"fallback": "Free-float, custom field, custom expression, descending rank and non-finite bounds keep the original full candidate path.",
"cachedBusinessResults": false
},
"deployment": {
"serviceCommit": "e4ed9467b9a4ca3ed56c64724623f0ba0d005b91",
"implementationIdentitySha256": "b38dc2bb32d46197eb2c48460757b7e4e1c1a3ea80dee1447a169d35af4e759e",
"serviceBinarySha256": "31b97e2ff8d9dca00c685ab7e70c239cb038ba467ed3ec669a237de7b8d0ba27",
"runnerBinarySha256": "932d531a222df936c82ca38c3ddad1f940c386e315c906cd3da4f428348114d8",
"serviceUser": "boris",
"sourceLakeRestarted": false,
"health": "ok"
},
"currentCloseOneYear": {
"before": {
"processHotMedianTotalSeconds": 1.238,
"processHotMedianEngineSeconds": 0.773
},
"after": {
"runs": 5,
"processHotMedianTotalSeconds": 1.154,
"processHotMedianEngineSeconds": 0.714,
"totalReturn": 0.3201517861398,
"tradeCount": 5351,
"canonicalResultDigest": "7204c6f41b8e39fbf1af7fc55cd601b80f3427a7aa058394ccd8b0b14ca48eed",
"resultStoreDigest": "0b4d24ed5ec2b27cc4135707b4c51c78eb2c3e35a20da8108610778c30c72c73",
"terminalAuditStatus": "clean"
},
"improvementPercent": {
"total": 6.785137,
"engine": 7.6326
}
},
"crossModeRegression": {
"staticCurrentClose": {
"runs": 6,
"processHotMedianTotalSeconds": 0.822,
"processHotMedianEngineSeconds": 0.322,
"totalReturn": 0.13228843240310018,
"tradeCount": 4445,
"canonicalResultDigest": "fdfa855295c0b55bdbe6f39952ead1515e844bf033ced974d3c3ddc037a5d0b1",
"resultStoreDigest": "697566645116c76ff837cd36f7f9bbd7ad3eb30510a5b95012fb730d5072d511"
},
"nextBarOpenFiveYear": {
"runs": 5,
"processHotMedianTotalSeconds": 2.811,
"processHotMedianEngineSeconds": 2.188,
"totalReturn": 0.9922618879291,
"tradeCount": 25827,
"canonicalResultDigest": "ac1d167cb1e1073e1d1ecb01e914f94d7560081c1d238e6b4418d86233250719",
"resultStoreDigest": "79570e0ae6b07badc1b693dc897dd1381647d259a4fe44ed3e50bc215e2fd088"
},
"fullMinute": {
"runs": 6,
"processHotMedianTotalSeconds": 0.598,
"processHotMedianEngineSeconds": 0.226,
"totalReturn": 0.03473222656500008,
"tradeCount": 156,
"canonicalResultDigest": "7dae3a618932b90d36e9968c027f08a94d69b8fc6a56b1ea0cc1a2cb771d85b8",
"resultStoreDigest": "bbbd7080b8fd7f6e8c3a8132499842bd0e4d644dbfdc3e8dbca7d2c0c1381e93"
}
},
"profile": {
"beforePath": "/srv/fidc/canonical/run/fidc-private/evidence/current-close-shared-quote-profile-20260907",
"afterPath": "/srv/fidc/canonical/run/fidc-private/evidence/current-close-market-cap-range-profile-20260907",
"beforeSamples": 408,
"afterSamples": 375,
"lostSamples": 0,
"stockStatePercentBefore": 6.8,
"stockStatePercentAfter": 3.02,
"marketCapSelectionPercentBefore": 2.91,
"marketCapSelectionPercentAfter": 1.7
},
"verification": {
"workspaceCoreTestsPassed": 441,
"workspaceCoreTestsIgnored": 6,
"workspaceFailures": 0,
"allCanonicalDigestsMatched": true,
"allResultStoreDigestsMatched": true,
"allTerminalAuditsClean": true
},
"supportingEvidence": [
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-market-cap-range-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/static-pool-market-cap-range-control-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-market-cap-range-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/full-minute-market-cap-range-control-20260907.json"
]
}
@@ -0,0 +1,467 @@
{
"schemaVersion": "fidc-backtest-benchmark/v5",
"label": "native-pit-intraday",
"generatedAtUnixSeconds": 1788790043,
"requestSha256": "9dd420ae606878b90ff660fcd4efe520e04a237ec758ed974b87b6d9a72cab73",
"requestOrigin": {
"kind": "request_file"
},
"baseline": null,
"baselineComparable": true,
"baselineComparison": null,
"bundleRefresh": null,
"baseUrl": "http://127.0.0.1:8081",
"runs": [
{
"slot": 0,
"cacheClass": "restart_or_cold",
"runId": "btr_1788790021780_1150210_0",
"status": "succeeded",
"queueWaitSeconds": 0.0,
"executionSeconds": 8.994,
"totalSeconds": 8.994,
"runnerSeconds": 7.904,
"bundleValidationSeconds": 0.166,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.001,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.031,
"riskFreeRateCacheHit": false,
"engineSeconds": 0.012,
"dataSeconds": 7.662,
"resultSeconds": 0.03,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.001,
"resultFinalization": {
"canonicalSeconds": 0.001,
"processEventStoreSeconds": 0.001,
"storePublishSeconds": 0.027,
"summarySeconds": 0.0
},
"dataPreparationTimings": {
"adjustmentValidationSeconds": 0.0,
"availableStockStDates": 0,
"benchmarkFetchSeconds": 0.344,
"candidatePlanBasePanelSeconds": 0.006,
"candidatePlanSelectionSeconds": 0.004,
"corporateActionSeconds": 0.605,
"datasetConstructSeconds": 0.0,
"externalFetchSeconds": 0.344,
"historyDateSeconds": 0.0,
"instrumentIndexBuildSeconds": 0.0,
"instrumentMetadataSeconds": 0.016,
"loopSeconds": 0.0,
"marketIndexBuildSeconds": 0.001,
"quotePlanSeconds": 0.935,
"riskSnapshotSeconds": 0.0,
"sourceQuerySeconds": 5.752,
"stockStLookupSeconds": 0.0,
"supplementalExecutionFetchSeconds": 0.0,
"totalSeconds": 7.659
},
"finalizationSeconds": 1.089,
"finalizationTimings": {
"artifactVerificationSeconds": 0.0,
"cacheRegistrationSeconds": 0.0,
"postgresClientAcquireSeconds": 0.216,
"postgresPersistSeconds": 0.872,
"preTerminalUpdateSeconds": 1.089,
"schemaCheckSeconds": 0.0,
"timingSource": "fidc-backtest-service-monotonic"
},
"totalReturn": 0.011211658838299776,
"tradeCount": 104,
"riskDecisionCount": 11,
"resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
"canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
"terminalAudit": {
"cashReceivableCount": 0,
"cashReceivableTotalAmount": 0.0,
"earliestDeferredCashDate": null,
"futuresOpenOrderCount": 0,
"lastExecutionDate": "2025-09-12",
"omittedOpenOrderCount": 0,
"openOrderSamples": [],
"pendingCashFlowCount": 0,
"pendingCashFlowNetAmount": 0.0,
"status": "clean",
"stockOpenOrderCount": 0
},
"terminalAuditVerified": true,
"terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9",
"implementation": {
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
"builtAt": "2026-09-07T22:06:18+08:00",
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
"schemaVersion": "fidc-backtest-implementation/v1",
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
"status": "verified"
},
"implementationVerified": true,
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"artifactManifest": {
"canonicalResult": {
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"schemaVersion": "fidc-canonical-backtest-result/v2",
"sections": {
"accountEvents": {
"rowCount": 104,
"sha256": "ea6bbbd55df3da0692dfd712f88ef6f8af510ada318641ede71884f5c5d14e7e"
},
"equityFacts": {
"rowCount": 5,
"sha256": "96004e235e4603c0a7d1a5629a2f0d2b364258d562e1d397cdd6925718a7d060"
},
"fillEvents": {
"rowCount": 104,
"sha256": "2c37f626b7de014f5638dc8edf33f3ca8d23126ab2e62f26f01fbcb33e8c4e48"
},
"holdingSnapshots": {
"rowCount": 112,
"sha256": "50abab8a304d2e2a7de344538aff604c8bf96ef00283330d107916cc2e234f67"
},
"orderEvents": {
"rowCount": 108,
"sha256": "f327f21151a5d7dbe53d2dcde9202be699f9672f758373eeb9c67985ca3826f5"
},
"riskAudits": {
"rowCount": 11,
"sha256": "428a8d7395b54ec5a9523eb745d5007c64b9d9867615e870d95ea9ea176f536e"
}
},
"sha256": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
"totalRows": 444
},
"processEventStore": {
"bytes": 44302,
"identitySha256": "1444075abea626e8d7d1b8c9c9cda984a2a687ff2b9465db5648e0f4525130e7",
"manifestBytes": 461,
"manifestSha256": "b43a818096d09648b809a46d12ad9eae39638d57ea52f7696906359036a80c52",
"schemaVersion": "fidc-backtest-process-event-manifest/v1",
"sections": {
"processEvents": {
"blockCount": 1,
"bytes": 43841,
"rowCount": 370,
"sha256": "8edb6dad95777e3eef5946acbbdfc4b9125df34a79ac716d8c3ff7835b552bf6"
}
},
"sha256": "661d84b7b430ac25fb5c959df8e7fb309133784d85a7524375cd803e29e52094",
"totalEvents": 370
},
"resultStore": {
"bytes": 148006,
"identitySha256": "484b7a4a8d5cc77c6222ce01f2fe452c890995a476579f209d1d4ac43885d60a",
"manifestBytes": 2999,
"manifestSha256": "a0a7072f176db79d781846cc124d2e4794c5084c48c525876863751f709c4e7f",
"schemaVersion": "fidc-backtest-fact-manifest/v1",
"sections": {
"accountEvents": {
"blockCount": 1,
"bytes": 13313,
"rowCount": 104,
"sha256": "ab2f936c9072f80d58487b62a6f64553d279582ddfe6af0299afe20447e83424"
},
"equityFacts": {
"blockCount": 1,
"bytes": 11009,
"rowCount": 5,
"sha256": "c40650f5b27cfff873194572ffe28a5412fe77730fe75492c7b5a575f8f5caae"
},
"fillEvents": {
"blockCount": 1,
"bytes": 40541,
"rowCount": 104,
"sha256": "80a3f1d3a9c23db7dec2dc70f029280c0844370be6d87703cd396a234068f708"
},
"holdingSnapshots": {
"blockCount": 1,
"bytes": 51338,
"rowCount": 112,
"sha256": "7684244dc8e7788f777309169be61b14fcb532abdd89fe93f0a7f2980a615ba9"
},
"orderEvents": {
"blockCount": 1,
"bytes": 25768,
"rowCount": 108,
"sha256": "31ff15d2cf79b1eb9c333d19d8fb400c9c5a52c36b0f662330ec39aa9d0f805c"
},
"riskAudits": {
"blockCount": 1,
"bytes": 3038,
"rowCount": 11,
"sha256": "5a7269219a21cf14c23d788a6a96ddf1d1ddf0ef1e3b57a8db4ae4b1da789aff"
}
},
"sha256": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
"totalEvents": 444
},
"schemaVersion": "fidc-backtest-artifacts/v4"
}
},
{
"slot": 1,
"cacheClass": "process_hot",
"runId": "btr_1788790036494_1150210_1",
"status": "succeeded",
"queueWaitSeconds": 0.0,
"executionSeconds": 0.596,
"totalSeconds": 0.596,
"runnerSeconds": 0.026,
"bundleValidationSeconds": 0.0,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.003,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.0,
"riskFreeRateCacheHit": true,
"engineSeconds": 0.012,
"dataSeconds": 0.007,
"resultSeconds": 0.002,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.001,
"resultFinalization": {
"canonicalSeconds": 0.001,
"processEventStoreSeconds": 0.001,
"storePublishSeconds": 0.0,
"summarySeconds": 0.0
},
"dataPreparationTimings": {
"adjustmentValidationSeconds": 0.0,
"availableStockStDates": 0,
"benchmarkFetchSeconds": 0.0,
"candidatePlanBasePanelSeconds": 0.0,
"candidatePlanSelectionSeconds": 0.0,
"corporateActionSeconds": 0.0,
"datasetConstructSeconds": 0.0,
"externalFetchSeconds": 0.0,
"historyDateSeconds": 0.0,
"instrumentIndexBuildSeconds": 0.0,
"instrumentMetadataSeconds": 0.0,
"loopSeconds": 0.0,
"marketIndexBuildSeconds": 0.0,
"quotePlanSeconds": 0.0,
"riskSnapshotSeconds": 0.0,
"sourceQuerySeconds": 0.0,
"stockStLookupSeconds": 0.0,
"supplementalExecutionFetchSeconds": 0.0,
"totalSeconds": 0.006
},
"finalizationSeconds": 0.569,
"finalizationTimings": {
"artifactVerificationSeconds": 0.001,
"cacheRegistrationSeconds": 0.0,
"postgresClientAcquireSeconds": 0.27,
"postgresPersistSeconds": 0.298,
"preTerminalUpdateSeconds": 0.569,
"schemaCheckSeconds": 0.0,
"timingSource": "fidc-backtest-service-monotonic"
},
"totalReturn": 0.011211658838299776,
"tradeCount": 104,
"riskDecisionCount": 11,
"resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
"canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
"terminalAudit": {
"cashReceivableCount": 0,
"cashReceivableTotalAmount": 0.0,
"earliestDeferredCashDate": null,
"futuresOpenOrderCount": 0,
"lastExecutionDate": "2025-09-12",
"omittedOpenOrderCount": 0,
"openOrderSamples": [],
"pendingCashFlowCount": 0,
"pendingCashFlowNetAmount": 0.0,
"status": "clean",
"stockOpenOrderCount": 0
},
"terminalAuditVerified": true,
"terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9",
"implementation": {
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
"builtAt": "2026-09-07T22:06:18+08:00",
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
"schemaVersion": "fidc-backtest-implementation/v1",
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
"status": "verified"
},
"implementationVerified": true,
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"artifactManifest": {
"canonicalResult": {
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"schemaVersion": "fidc-canonical-backtest-result/v2",
"sections": {
"accountEvents": {
"rowCount": 104,
"sha256": "ea6bbbd55df3da0692dfd712f88ef6f8af510ada318641ede71884f5c5d14e7e"
},
"equityFacts": {
"rowCount": 5,
"sha256": "96004e235e4603c0a7d1a5629a2f0d2b364258d562e1d397cdd6925718a7d060"
},
"fillEvents": {
"rowCount": 104,
"sha256": "2c37f626b7de014f5638dc8edf33f3ca8d23126ab2e62f26f01fbcb33e8c4e48"
},
"holdingSnapshots": {
"rowCount": 112,
"sha256": "50abab8a304d2e2a7de344538aff604c8bf96ef00283330d107916cc2e234f67"
},
"orderEvents": {
"rowCount": 108,
"sha256": "f327f21151a5d7dbe53d2dcde9202be699f9672f758373eeb9c67985ca3826f5"
},
"riskAudits": {
"rowCount": 11,
"sha256": "428a8d7395b54ec5a9523eb745d5007c64b9d9867615e870d95ea9ea176f536e"
}
},
"sha256": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
"totalRows": 444
},
"processEventStore": {
"bytes": 44302,
"identitySha256": "1444075abea626e8d7d1b8c9c9cda984a2a687ff2b9465db5648e0f4525130e7",
"manifestBytes": 461,
"manifestSha256": "b43a818096d09648b809a46d12ad9eae39638d57ea52f7696906359036a80c52",
"schemaVersion": "fidc-backtest-process-event-manifest/v1",
"sections": {
"processEvents": {
"blockCount": 1,
"bytes": 43841,
"rowCount": 370,
"sha256": "8edb6dad95777e3eef5946acbbdfc4b9125df34a79ac716d8c3ff7835b552bf6"
}
},
"sha256": "661d84b7b430ac25fb5c959df8e7fb309133784d85a7524375cd803e29e52094",
"totalEvents": 370
},
"resultStore": {
"bytes": 148006,
"identitySha256": "484b7a4a8d5cc77c6222ce01f2fe452c890995a476579f209d1d4ac43885d60a",
"manifestBytes": 2999,
"manifestSha256": "a0a7072f176db79d781846cc124d2e4794c5084c48c525876863751f709c4e7f",
"schemaVersion": "fidc-backtest-fact-manifest/v1",
"sections": {
"accountEvents": {
"blockCount": 1,
"bytes": 13313,
"rowCount": 104,
"sha256": "ab2f936c9072f80d58487b62a6f64553d279582ddfe6af0299afe20447e83424"
},
"equityFacts": {
"blockCount": 1,
"bytes": 11009,
"rowCount": 5,
"sha256": "c40650f5b27cfff873194572ffe28a5412fe77730fe75492c7b5a575f8f5caae"
},
"fillEvents": {
"blockCount": 1,
"bytes": 40541,
"rowCount": 104,
"sha256": "80a3f1d3a9c23db7dec2dc70f029280c0844370be6d87703cd396a234068f708"
},
"holdingSnapshots": {
"blockCount": 1,
"bytes": 51338,
"rowCount": 112,
"sha256": "7684244dc8e7788f777309169be61b14fcb532abdd89fe93f0a7f2980a615ba9"
},
"orderEvents": {
"blockCount": 1,
"bytes": 25768,
"rowCount": 108,
"sha256": "31ff15d2cf79b1eb9c333d19d8fb400c9c5a52c36b0f662330ec39aa9d0f805c"
},
"riskAudits": {
"blockCount": 1,
"bytes": 3038,
"rowCount": 11,
"sha256": "5a7269219a21cf14c23d788a6a96ddf1d1ddf0ef1e3b57a8db4ae4b1da789aff"
}
},
"sha256": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
"totalEvents": 444
},
"schemaVersion": "fidc-backtest-artifacts/v4"
}
}
],
"summary": {
"resultConsistent": true,
"resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4",
"canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
"totalReturn": 0.011211658838299776,
"tradeCount": 104,
"terminalAudit": {
"cashReceivableCount": 0,
"cashReceivableTotalAmount": 0.0,
"earliestDeferredCashDate": null,
"futuresOpenOrderCount": 0,
"lastExecutionDate": "2025-09-12",
"omittedOpenOrderCount": 0,
"openOrderSamples": [],
"pendingCashFlowCount": 0,
"pendingCashFlowNetAmount": 0.0,
"status": "clean",
"stockOpenOrderCount": 0
},
"terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9",
"implementation": {
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
"builtAt": "2026-09-07T22:06:18+08:00",
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
"schemaVersion": "fidc-backtest-implementation/v1",
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
"status": "verified"
},
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"restartOrCold": {
"queueWaitSeconds": 0.0,
"executionSeconds": 8.994,
"totalSeconds": 8.994,
"runnerSeconds": 7.904,
"bundleValidationSeconds": 0.166,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.001,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.031,
"riskFreeRateCacheHit": false,
"engineSeconds": 0.012,
"dataSeconds": 7.662,
"resultSeconds": 0.03,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.001,
"finalizationSeconds": 1.089
},
"processHotMedian": {
"queueWaitSeconds": 0.0,
"executionSeconds": 0.596,
"totalSeconds": 0.596,
"runnerSeconds": 0.026,
"bundleValidationSeconds": 0.0,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.003,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.0,
"riskFreeRateCacheHit": true,
"engineSeconds": 0.012,
"dataSeconds": 0.007,
"resultSeconds": 0.002,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.001,
"finalizationSeconds": 0.569
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,276 @@
{
"schemaVersion": "fidc-backtest-benchmark/v5",
"label": "native-pit-nextopen",
"generatedAtUnixSeconds": 1788790370,
"requestSha256": "7948497bfa966eb118e56438e7c261869a26ab70a62186b92f761719f85e91c7",
"requestOrigin": {
"kind": "request_file"
},
"baseline": null,
"baselineComparable": true,
"baselineComparison": null,
"bundleRefresh": null,
"baseUrl": "http://127.0.0.1:8081",
"runs": [
{
"slot": 0,
"cacheClass": "restart_or_cold",
"runId": "btr_1788790344805_1150210_2",
"status": "succeeded",
"queueWaitSeconds": 0.0,
"executionSeconds": 21.61,
"totalSeconds": 21.61,
"runnerSeconds": 21.294,
"bundleValidationSeconds": 0.004,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.003,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.017,
"riskFreeRateCacheHit": false,
"engineSeconds": 5.566,
"dataSeconds": 15.305,
"resultSeconds": 0.395,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.206,
"resultFinalization": {
"canonicalSeconds": 0.206,
"processEventStoreSeconds": 0.168,
"storePublishSeconds": 0.005,
"summarySeconds": 0.003
},
"dataPreparationTimings": {
"adjustmentValidationSeconds": 0.0,
"availableStockStDates": 0,
"benchmarkFetchSeconds": 0.023,
"candidatePlanBasePanelSeconds": 0.0,
"candidatePlanSelectionSeconds": 0.0,
"corporateActionSeconds": 0.017,
"datasetConstructSeconds": 2.943,
"externalFetchSeconds": 0.025,
"historyDateSeconds": 0.0,
"instrumentIndexBuildSeconds": 0.269,
"instrumentMetadataSeconds": 0.006,
"loopSeconds": 0.925,
"marketIndexBuildSeconds": 1.846,
"quotePlanSeconds": 0.0,
"riskSnapshotSeconds": 0.0,
"sourceQuerySeconds": 9.0,
"stockStLookupSeconds": 0.0,
"supplementalExecutionFetchSeconds": 0.0,
"totalSeconds": 15.3
},
"finalizationSeconds": 0.307,
"finalizationTimings": {
"artifactVerificationSeconds": 0.008,
"cacheRegistrationSeconds": 0.0,
"postgresClientAcquireSeconds": 0.0,
"postgresPersistSeconds": 0.299,
"preTerminalUpdateSeconds": 0.307,
"schemaCheckSeconds": 0.0,
"timingSource": "fidc-backtest-service-monotonic"
},
"totalReturn": 0.9857987122635,
"tradeCount": 25408,
"riskDecisionCount": 113,
"resultStoreDigest": "1eab5a5ffe59efeaba6b2048a33760aab31d42efa0fc10e35a4a7048db853235",
"canonicalResultDigest": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710",
"terminalAudit": {
"cashReceivableCount": 0,
"cashReceivableTotalAmount": 0.0,
"earliestDeferredCashDate": null,
"futuresOpenOrderCount": 0,
"lastExecutionDate": "2026-08-28",
"omittedOpenOrderCount": 0,
"openOrderSamples": [],
"pendingCashFlowCount": 0,
"pendingCashFlowNetAmount": 0.0,
"status": "clean",
"stockOpenOrderCount": 0
},
"terminalAuditVerified": true,
"terminalAuditSha256": "0e8503c2bfe9ecfd93bab3a95f79461830886eb693cfe6f477279908c0d95bd5",
"implementation": {
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
"builtAt": "2026-09-07T22:06:18+08:00",
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
"schemaVersion": "fidc-backtest-implementation/v1",
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
"status": "verified"
},
"implementationVerified": true,
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"artifactManifest": {
"canonicalResult": {
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"schemaVersion": "fidc-canonical-backtest-result/v2",
"sections": {
"accountEvents": {
"rowCount": 25572,
"sha256": "da7576c1cbade9b48f1a154d07e870f1472924f4e0054cba0ef06739585ce380"
},
"equityFacts": {
"rowCount": 1216,
"sha256": "13db0a6d484cdf871e4f1dc1a4bc8db1e809d2808b21cf4fcab3961e6ad9e6fe"
},
"fillEvents": {
"rowCount": 25408,
"sha256": "92e0d103bbb487af45d27b720cdc92ef398d16e11655a6d0bcff8046013335db"
},
"holdingSnapshots": {
"rowCount": 33164,
"sha256": "97e3d60230a167a960bd2f615adfa33a9171527b62f1a181e2d5af694090e492"
},
"orderEvents": {
"rowCount": 25541,
"sha256": "635fe79eab9b1db0c116da2860ded66c350c051f44751d36306d158777039744"
},
"riskAudits": {
"rowCount": 113,
"sha256": "17bc940bcac18b050235ae3f17195f12586d1e29cd7bc64b6189edf2e07d1406"
}
},
"sha256": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710",
"totalRows": 111014
},
"processEventStore": {
"bytes": 10115947,
"identitySha256": "e89d00ea1c2525c2ab1b57a29f2447047f2523083a953869ea2e25a488986565",
"manifestBytes": 9757,
"manifestSha256": "9bff5b97488c8804275ee65a717f01dca9ac68443b9766d4aab8d36189b1d8a1",
"schemaVersion": "fidc-backtest-process-event-manifest/v1",
"sections": {
"processEvents": {
"blockCount": 61,
"bytes": 10106190,
"rowCount": 83859,
"sha256": "f436a32403aaa74b3068a0d8c74bfb7c429523d3f78617234eec82a009ab3da2"
}
},
"sha256": "aa7488614a85f1527713332039b0fa212d1807f11b03e0e701480bf5d8ede1a4",
"totalEvents": 83859
},
"resultStore": {
"bytes": 35831197,
"identitySha256": "a238986d82354e802d2c73526d035021b38e1eed0a2b0b0f1eb6ce41ed4f321b",
"manifestBytes": 54237,
"manifestSha256": "0991f096db161fd16da00a7529ece54a8cf68643d88aeec8f4d3fedc6c740d03",
"schemaVersion": "fidc-backtest-fact-manifest/v1",
"sections": {
"accountEvents": {
"blockCount": 61,
"bytes": 3303444,
"rowCount": 25572,
"sha256": "276fd3edd7e60afbb05fa32c111db3e1df2025637887509b6353b24c7f127a48"
},
"equityFacts": {
"blockCount": 61,
"bytes": 3761920,
"rowCount": 1216,
"sha256": "42c22c70bad16b01d8f70fa80977028b2789381772e4eff7bb231701e18293a1"
},
"fillEvents": {
"blockCount": 61,
"bytes": 7731746,
"rowCount": 25408,
"sha256": "c59a4a1e70195bd8a2421941e11b7fbb138d658796ef4d42466c44505b8353d8"
},
"holdingSnapshots": {
"blockCount": 61,
"bytes": 15176388,
"rowCount": 33164,
"sha256": "6e40d3f1d43005dc4f0c492662c4fdf600ca96e9abe874db4b56b9bd51e89cd4"
},
"orderEvents": {
"blockCount": 61,
"bytes": 5775840,
"rowCount": 25541,
"sha256": "14fa27db9328c4c6337157af4fc1efc1c4bc9f239a4605bf92439b0f3f426200"
},
"riskAudits": {
"blockCount": 36,
"bytes": 27622,
"rowCount": 113,
"sha256": "2f6d9a01ec568ed13943b9590e21d73bf614ce2c777302764533d2da0168b2d5"
}
},
"sha256": "1eab5a5ffe59efeaba6b2048a33760aab31d42efa0fc10e35a4a7048db853235",
"totalEvents": 111014
},
"schemaVersion": "fidc-backtest-artifacts/v4"
}
}
],
"summary": {
"resultConsistent": true,
"resultStoreDigest": "1eab5a5ffe59efeaba6b2048a33760aab31d42efa0fc10e35a4a7048db853235",
"canonicalResultDigest": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710",
"totalReturn": 0.9857987122635,
"tradeCount": 25408,
"terminalAudit": {
"cashReceivableCount": 0,
"cashReceivableTotalAmount": 0.0,
"earliestDeferredCashDate": null,
"futuresOpenOrderCount": 0,
"lastExecutionDate": "2026-08-28",
"omittedOpenOrderCount": 0,
"openOrderSamples": [],
"pendingCashFlowCount": 0,
"pendingCashFlowNetAmount": 0.0,
"status": "clean",
"stockOpenOrderCount": 0
},
"terminalAuditSha256": "0e8503c2bfe9ecfd93bab3a95f79461830886eb693cfe6f477279908c0d95bd5",
"implementation": {
"buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539",
"builtAt": "2026-09-07T22:06:18+08:00",
"engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff",
"identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d",
"schemaVersion": "fidc-backtest-implementation/v1",
"serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a",
"serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad",
"status": "verified"
},
"implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33",
"restartOrCold": {
"queueWaitSeconds": 0.0,
"executionSeconds": 21.61,
"totalSeconds": 21.61,
"runnerSeconds": 21.294,
"bundleValidationSeconds": 0.004,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.003,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.017,
"riskFreeRateCacheHit": false,
"engineSeconds": 5.566,
"dataSeconds": 15.305,
"resultSeconds": 0.395,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.206,
"finalizationSeconds": 0.307
},
"processHotMedian": {
"queueWaitSeconds": 0.0,
"executionSeconds": 21.61,
"totalSeconds": 21.61,
"runnerSeconds": 21.294,
"bundleValidationSeconds": 0.004,
"sourceEpochSeconds": 0.0,
"strategyContractSeconds": 0.003,
"terminalCalendarSeconds": 0.0,
"riskFreeRateSeconds": 0.017,
"riskFreeRateCacheHit": false,
"engineSeconds": 5.566,
"dataSeconds": 15.305,
"resultSeconds": 0.395,
"unattributedSeconds": 0.0,
"canonicalSeconds": 0.206,
"finalizationSeconds": 0.307
}
}
}
@@ -0,0 +1,424 @@
{
"strategy_id": "benchmark-native-factor-overlay",
"strategy_version_id": "goal-five-year-semantics-v1",
"user_id": "boris",
"runtime": {
"start_date": "2021-08-23",
"end_date": "2026-08-28",
"frequency": "1d",
"source_table": "strategy_factory_source_lake.daily_source_rows_v1",
"signal_symbol": "000852.SH",
"benchmark_symbol": "000852.SH",
"initial_cash": 10000000.0,
"backtestDataBundleId": "bt_bundle_b44e03990c76064f54a9",
"backtestDataBundleHash": "d7c1461131edaecb5981e207852782d92e636dbfee9fd7c44063605d96eb2b4f"
},
"execution": {
"matchingType": "next_bar_open",
"rebalanceCashMode": "same_point_net",
"slippageModel": "price_ratio",
"slippageValue": 0.0001,
"commissionRate": 0.0001,
"minimumCommission": 5.0,
"stampTaxRateBeforeChange": 0.001,
"stampTaxRateAfterChange": 0.0005,
"stampTaxChangeDate": "2023-08-28",
"volumeLimit": true,
"liquidityLimit": false,
"volumePercent": 0.25,
"riskPolicy": {
"allowMarketOrders": true,
"blacklistEnabled": false,
"blacklistedSymbols": [],
"commissionRate": 0.0001,
"forbidSameDayRebuyAfterSell": true,
"liquidityLimitEnabled": false,
"liveTradingEnabled": false,
"minimumCommission": 5.0,
"rejectBjseBuy": false,
"rejectBjseSelection": false,
"rejectInactiveBuy": true,
"rejectInactiveSelection": false,
"rejectInactiveSell": true,
"rejectKcbBuy": true,
"rejectKcbSelection": false,
"rejectLowerLimitSelection": false,
"rejectLowerLimitSell": true,
"rejectNewListingBuy": true,
"rejectNewListingSelection": false,
"rejectOneYuanBuy": true,
"rejectOneYuanSelection": false,
"rejectPausedBuy": true,
"rejectPausedSelection": false,
"rejectPausedSell": true,
"rejectStBuy": true,
"rejectStSelection": false,
"rejectStarStBuy": true,
"rejectStarStSelection": false,
"rejectUpperLimitBuy": true,
"rejectUpperLimitSelection": false,
"respectAllowBuySell": true,
"stampTaxChangeDate": "2023-08-28",
"stampTaxRateAfterChange": 0.0005,
"stampTaxRateBeforeChange": 0.001,
"volumeLimitEnabled": true,
"volumePercent": 0.25
}
},
"strategy_source": {
"source_type": "platform-strategy",
"language": "engine-script",
"parser": "omniquant-engine-script-v2",
"source_code": "strategy(\"xiaoshizhi_1_06_dynamic_small_cap_csi2000_signal_day_exposure\") {\n market(\"CN_A\");\n benchmark(\"000300.SH\");\n signal(\"932000.CSI\");\n\n let stocknum = 30;\n let candidate_pool_size = 50;\n let position_denominator_extra = 1;\n let signal_close_t = rolling_mean_current(\"signal_close\", 1);\n let signal_ma10_t = rolling_mean_current(\"signal_close\", 10);\n let signal_ma30_t = rolling_mean_current(\"signal_close\", 30);\n let signal_vol20_t = rolling_return_stddev_current(\"signal_close\", 20);\n let signal_high60_t = rolling_max_current(\"signal_close\", 60);\n let signal_drawdown60_t = 1.0 - safe_div(signal_close_t, signal_high60_t);\n let signal_range_t = safe_div(clamp(signal_close_t, 2000.0, 3000.0) - 2000.0, 1000.0);\n let market_cap_lower_t = 12.0 + signal_range_t * 5.0;\n let market_cap_upper_t = 40.0 + signal_range_t * 5.0;\n let base_exposure_t = signal_ma10_t > signal_ma30_t ? 1.0 : 0.3;\n let volatility_exposure_t = signal_vol20_t >= 0.025 ? 0.3 : 1.0;\n let drawdown_exposure_t = signal_drawdown60_t >= 0.08 ? 0.2 : 1.0;\n let final_exposure_t =\n signal_close_t > 0.0 &&\n signal_ma10_t > 0.0 &&\n signal_ma30_t > 0.0 &&\n signal_high60_t > 0.0\n ? min(min(base_exposure_t, volatility_exposure_t), drawdown_exposure_t)\n : 0.0;\n\n rebalance.every_days(1).at([\"15:00\"]);\n\n selection.market_cap_band(\n field=\"market_cap\",\n lower=market_cap_lower_t,\n upper=market_cap_upper_t\n );\n\n filter.stock_expr(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1);\n\n ordering.rank_by(\"market_cap\", \"asc\");\n selection.candidate_limit(50);\n selection.limit(stocknum);\n\n allocation.buy_scale(30.0 / 31.0);\n execution.strict_value_budget(true)\n\n trading.hold_until_exit(true);\n trading.max_holding_days(90);\n trading.daily_top_up(true);\n trading.daily_position_target_adjust(true);\n trading.target_portfolio_daily(true);\n trading.rebalance_existing_positions(true);\n trading.retry_empty_rebalance(true);\n trading.release_slot_on_exit_signal(true);\n\n risk.stop_loss(0.08);\n risk.take_profit(0.16);\n risk.reference_price_mode(\"signal_day_post_adjusted_close\");\n risk.index_exposure(final_exposure_t);\n\n risk.policy(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=false, 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=false, blacklisted_symbols=[], allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=false, volume_percent=0.25, commission_rate=0.0001, minimum_commission=5.0, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\");\n\n execution.matching_type(\"next_bar_open\");\n execution.slippage(\"price_ratio\", 0.0001);\n execution.rebalance_cash_mode(\"same_point_net\");\n}"
},
"strategy_spec": {
"benchmark": {
"fallbackInstrumentId": "000852.SH",
"instrumentId": "000852.SH"
},
"engineConfig": {
"benchmarkSymbol": "000852.SH",
"commissionRate": 0.0001,
"dividendReinvestment": false,
"dynamicRange": {
"baseCapFloor": 7,
"baseIndexLevel": 2000,
"capSpan": 10,
"xs": 0.008
},
"frequency": "1d",
"indexThrottle": {
"defensiveExposure": 0.5,
"fullExposure": 1,
"longDays": 130,
"rsiRate": 1.0001,
"shortDays": 1
},
"liquidityLimit": false,
"matchingType": "next_bar_open",
"minimumCommission": 5.0,
"rankLimit": 30,
"rebalanceCashMode": "same_point_net",
"rebalanceSchedule": {
"frequency": "daily",
"time": "15:00"
},
"refreshRate": 1,
"riskPolicy": {
"allowMarketOrders": true,
"blacklistEnabled": false,
"blacklistedSymbols": [],
"commissionRate": 0.0001,
"forbidSameDayRebuyAfterSell": true,
"liquidityLimitEnabled": false,
"liveTradingEnabled": false,
"minimumCommission": 5.0,
"rejectBjseBuy": false,
"rejectBjseSelection": false,
"rejectInactiveBuy": true,
"rejectInactiveSelection": false,
"rejectInactiveSell": true,
"rejectKcbBuy": true,
"rejectKcbSelection": false,
"rejectLowerLimitSelection": false,
"rejectLowerLimitSell": true,
"rejectNewListingBuy": true,
"rejectNewListingSelection": false,
"rejectOneYuanBuy": true,
"rejectOneYuanSelection": false,
"rejectPausedBuy": true,
"rejectPausedSelection": false,
"rejectPausedSell": true,
"rejectStBuy": true,
"rejectStSelection": false,
"rejectStarStBuy": true,
"rejectStarStSelection": false,
"rejectUpperLimitBuy": true,
"rejectUpperLimitSelection": false,
"respectAllowBuySell": true,
"stampTaxChangeDate": "2023-08-28",
"stampTaxRateAfterChange": 0.0005,
"stampTaxRateBeforeChange": 0.001,
"volumeLimitEnabled": true,
"volumePercent": 0.25
},
"rsiRate": 1.0001,
"signalSymbol": "000852.SH",
"skipWindows": [],
"slippageModel": "price_ratio",
"slippageValue": 0.0001,
"stampTaxChangeDate": "2023-08-28",
"stampTaxRateAfterChange": 0.0005,
"stampTaxRateBeforeChange": 0.001,
"stockMaFilter": {
"longDays": 30,
"midDays": 10,
"rsiRate": 1.0001,
"shortDays": 5,
"volumeLongDays": 100,
"volumeShortDays": 5
},
"stopLossMultiplier": 0.08,
"strictValueBudget": true,
"takeProfitMultiplier": 0.16,
"templateId": "xiaoshizhi_1_06_dynamic_small_cap_csi2000_signal_day_exposure",
"volumeLimit": true,
"volumePercent": 0.25
},
"execution": {
"commissionRate": 0.0001,
"executionGranularity": "daily_or_minute_bar",
"extractor": "omniquant-engine-script-v2",
"frequency": "1d",
"liquidityLimit": false,
"matchingType": "next_bar_open",
"minimumCommission": 5.0,
"priceSource": "current_bar_close_or_next_bar_open_or_minute_bar",
"rebalanceCashMode": "same_point_net",
"riskPolicy": {
"allowMarketOrders": true,
"blacklistEnabled": false,
"blacklistedSymbols": [],
"commissionRate": 0.0001,
"forbidSameDayRebuyAfterSell": true,
"liquidityLimitEnabled": false,
"liveTradingEnabled": false,
"minimumCommission": 5.0,
"rejectBjseBuy": false,
"rejectBjseSelection": false,
"rejectInactiveBuy": true,
"rejectInactiveSelection": false,
"rejectInactiveSell": true,
"rejectKcbBuy": true,
"rejectKcbSelection": false,
"rejectLowerLimitSelection": false,
"rejectLowerLimitSell": true,
"rejectNewListingBuy": true,
"rejectNewListingSelection": false,
"rejectOneYuanBuy": true,
"rejectOneYuanSelection": false,
"rejectPausedBuy": true,
"rejectPausedSelection": false,
"rejectPausedSell": true,
"rejectStBuy": true,
"rejectStSelection": false,
"rejectStarStBuy": true,
"rejectStarStSelection": false,
"rejectUpperLimitBuy": true,
"rejectUpperLimitSelection": false,
"respectAllowBuySell": true,
"stampTaxChangeDate": "2023-08-28",
"stampTaxRateAfterChange": 0.0005,
"stampTaxRateBeforeChange": 0.001,
"volumeLimitEnabled": true,
"volumePercent": 0.25
},
"selectionGranularity": "strategy_factory_source_lake.daily_source_rows_v1",
"slippageModel": "price_ratio",
"slippageValue": 0.0001,
"sourceKind": "platform-strategy",
"sourceLanguage": "engine-script",
"stampTaxChangeDate": "2023-08-28",
"stampTaxRateAfterChange": 0.0005,
"stampTaxRateBeforeChange": 0.001,
"strictValueBudget": true,
"volumeLimit": true,
"volumePercent": 0.25
},
"factorRefs": [
"market_cap",
"ths_up_days_stock"
],
"market": "CN_A",
"metadata": {
"backtestDataBundle": {
"sourceTable": "strategy_factory_source_lake.daily_source_rows_v1",
"backtestDataBundleId": "bt_bundle_b44e03990c76064f54a9",
"backtestDataBundleHash": "d7c1461131edaecb5981e207852782d92e636dbfee9fd7c44063605d96eb2b4f"
},
"backtestDataBundleHash": "d7c1461131edaecb5981e207852782d92e636dbfee9fd7c44063605d96eb2b4f",
"backtestDataBundleId": "bt_bundle_b44e03990c76064f54a9",
"sourceTable": "strategy_factory_source_lake.daily_source_rows_v1"
},
"rebalance": {
"dailyApproximation": "日线回测按 matching_type 撮合;分钟线回测按交易时刻分钟价格撮合",
"frequencyDays": 1,
"schedule": {
"frequency": "daily",
"time": "15:00"
},
"tradeTimes": [
"15:00"
]
},
"risk": {
"indexThrottleExpr": "final_exposure_t",
"stopLossExpr": "0.08",
"stopTakeReferencePriceMode": "signal_day_post_adjusted_close",
"takeProfitExpr": "0.16"
},
"runtimeExpressions": {
"allocation": {
"buyScaleExpr": "30.0 / 31.0"
},
"ordering": {
"rankBy": "market_cap",
"rankExpr": "",
"rankOrder": "asc"
},
"prelude": "let stocknum = 30;\nlet candidate_pool_size = 50;\nlet position_denominator_extra = 1;\nlet signal_close_t = rolling_mean_current(\"signal_close\", 1);\nlet signal_ma10_t = rolling_mean_current(\"signal_close\", 10);\nlet signal_ma30_t = rolling_mean_current(\"signal_close\", 30);\nlet signal_vol20_t = rolling_return_stddev_current(\"signal_close\", 20);\nlet signal_high60_t = rolling_max_current(\"signal_close\", 60);\nlet signal_drawdown60_t = 1.0 - safe_div(signal_close_t, signal_high60_t);\nlet signal_range_t = safe_div(clamp(signal_close_t, 2000.0, 3000.0) - 2000.0, 1000.0);\nlet market_cap_lower_t = 12.0 + signal_range_t * 5.0;\nlet market_cap_upper_t = 40.0 + signal_range_t * 5.0;\nlet base_exposure_t = signal_ma10_t > signal_ma30_t ? 1.0 : 0.3;\nlet volatility_exposure_t = signal_vol20_t >= 0.025 ? 0.3 : 1.0;\nlet drawdown_exposure_t = signal_drawdown60_t >= 0.08 ? 0.2 : 1.0;\nlet final_exposure_t = signal_close_t > 0.0 && signal_ma10_t > 0.0 && signal_ma30_t > 0.0 && signal_high60_t > 0.0 ? min(min(base_exposure_t, volatility_exposure_t), drawdown_exposure_t) : 0.0;\nlet warmup_probe = rolling_sum(\"amount\", 125);",
"risk": {
"exposureExpr": "final_exposure_t",
"stopLossExpr": "0.08",
"stopTakeReferencePriceMode": "signal_day_post_adjusted_close",
"takeProfitExpr": "0.16"
},
"schedule": {
"frequency": "daily",
"time": "15:00"
},
"selection": {
"candidateLimitExpr": "50",
"limitExpr": "stocknum",
"marketCapField": "market_cap",
"marketCapLowerExpr": "market_cap_lower_t",
"marketCapUpperExpr": "market_cap_upper_t",
"stockFilterExpr": "(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1)"
},
"trading": {
"actions": [],
"dailyPositionTargetAdjust": true,
"dailyTopUp": true,
"holdUntilExit": true,
"maxHoldingDays": 90,
"rebalanceExistingPositions": true,
"releaseSlotOnExitSignal": true,
"retryEmptyRebalance": true,
"rotationEnabled": true,
"stage": "on_day",
"subscriptionGuardRequired": false,
"targetPortfolioDaily": true
}
},
"seasonality": {
"skipWindows": []
},
"selectors": [
{
"field": "market_cap",
"lowerExpr": "market_cap_lower_t",
"mapping": "market_cap -> strategy_factory_source_lake.runtime_fields.market_cap",
"type": "dynamicRange",
"upperExpr": "market_cap_upper_t"
},
{
"expr": "(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1)",
"type": "filter"
},
{
"limitExpr": "stocknum",
"orderBy": [
"market_cap asc"
],
"type": "rank"
}
],
"signalSymbol": "000852.SH",
"sourceCode": "strategy(\"xiaoshizhi_1_06_dynamic_small_cap_csi2000_signal_day_exposure\") {\n market(\"CN_A\");\n benchmark(\"000300.SH\");\n signal(\"932000.CSI\");\n\n let stocknum = 30;\n let candidate_pool_size = 50;\n let position_denominator_extra = 1;\n let signal_close_t = rolling_mean_current(\"signal_close\", 1);\n let signal_ma10_t = rolling_mean_current(\"signal_close\", 10);\n let signal_ma30_t = rolling_mean_current(\"signal_close\", 30);\n let signal_vol20_t = rolling_return_stddev_current(\"signal_close\", 20);\n let signal_high60_t = rolling_max_current(\"signal_close\", 60);\n let signal_drawdown60_t = 1.0 - safe_div(signal_close_t, signal_high60_t);\n let signal_range_t = safe_div(clamp(signal_close_t, 2000.0, 3000.0) - 2000.0, 1000.0);\n let market_cap_lower_t = 12.0 + signal_range_t * 5.0;\n let market_cap_upper_t = 40.0 + signal_range_t * 5.0;\n let base_exposure_t = signal_ma10_t > signal_ma30_t ? 1.0 : 0.3;\n let volatility_exposure_t = signal_vol20_t >= 0.025 ? 0.3 : 1.0;\n let drawdown_exposure_t = signal_drawdown60_t >= 0.08 ? 0.2 : 1.0;\n let final_exposure_t =\n signal_close_t > 0.0 &&\n signal_ma10_t > 0.0 &&\n signal_ma30_t > 0.0 &&\n signal_high60_t > 0.0\n ? min(min(base_exposure_t, volatility_exposure_t), drawdown_exposure_t)\n : 0.0;\n\n rebalance.every_days(1).at([\"15:00\"]);\n\n selection.market_cap_band(\n field=\"market_cap\",\n lower=market_cap_lower_t,\n upper=market_cap_upper_t\n );\n\n filter.stock_expr(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1);\n\n ordering.rank_by(\"market_cap\", \"asc\");\n selection.candidate_limit(50);\n selection.limit(stocknum);\n\n allocation.buy_scale(30.0 / 31.0);\n execution.strict_value_budget(true)\n\n trading.hold_until_exit(true);\n trading.max_holding_days(90);\n trading.daily_top_up(true);\n trading.daily_position_target_adjust(true);\n trading.target_portfolio_daily(true);\n trading.rebalance_existing_positions(true);\n trading.retry_empty_rebalance(true);\n trading.release_slot_on_exit_signal(true);\n\n risk.stop_loss(0.08);\n risk.take_profit(0.16);\n risk.reference_price_mode(\"signal_day_post_adjusted_close\");\n risk.index_exposure(final_exposure_t);\n\n risk.policy(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=false, 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=false, blacklisted_symbols=[], allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=false, volume_percent=0.25, commission_rate=0.0001, minimum_commission=5.0, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\");\n\n execution.matching_type(\"next_bar_open\");\n execution.slippage(\"price_ratio\", 0.0001);\n execution.rebalance_cash_mode(\"same_point_net\");\n}",
"strategyId": "warmup-expression-contract-acceptance",
"universe": {
"exclude": [],
"implementationNotes": [
"ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量和费用由 riskPolicy / RiskLimits 统一执行",
"上市日期与退市日期取自 instrument 结构化字段,不再使用股票名称做 ST/退市判断",
"盘中 current_price / last_price 由策略交易时刻批量 tick 查询驱动"
]
},
"version": "1.0.0",
"stockPoolFactorContract": {
"schemaVersion": 1,
"entryLogic": "all",
"exitLogic": "any",
"conditions": [
{
"factorRef": "up_days_stock",
"label": "连涨天数",
"role": "selection",
"registryRole": "selection_feature",
"roleRegistrySha256": "1d0b307c168feda08d5fbe20f0e88964230553f8ceb2017b66aec48dbd5a5b57",
"roleEvidence": {
"role": "selection_feature",
"polarity": "trend_persistence_positive",
"signalShape": "state",
"holdingStates": [
"flat"
],
"requiredConfirmations": [],
"cooldownTradingDays": 0,
"windowTradingDays": 1,
"recommendedParameters": {
"inputUnit": "days",
"minimum": 0
}
},
"operator": ">=",
"threshold": 1,
"semantic": {
"ref": "up_days_stock",
"label": "连涨天数",
"status": "available",
"queryable": true,
"source": "strategy-factory-source-lake:indicator",
"schema": "strategy-factory.value-semantics/v1",
"valueType": "integer",
"semanticType": "count",
"comparisonGroup": "count",
"storageUnit": "days",
"inputUnit": "days",
"inputScale": 1.0,
"allowedOperators": [
">",
">=",
"<",
"<=",
"==",
"!=",
"between",
"in"
],
"nullable": true,
"declared": true,
"metadataStatus": "declared",
"semanticProvenance": "explicit_manifest",
"businessSemanticDeclared": true,
"minimum": 0,
"backtestBinding": {
"field": "ths_up_days_stock",
"sourceDataset": "indicators_up_days_stock"
},
"tradingRoles": [
{
"role": "selection_feature",
"polarity": "trend_persistence_positive",
"signalShape": "state",
"holdingStates": [
"flat"
],
"requiredConfirmations": [],
"cooldownTradingDays": 0,
"windowTradingDays": 1,
"recommendedParameters": {
"inputUnit": "days",
"minimum": 0
}
}
],
"tradingRoleTradable": true,
"tradingRoleEvidenceStatus": "source_lake_registered_indicator",
"tradingRoleRegistrySha256": "1d0b307c168feda08d5fbe20f0e88964230553f8ceb2017b66aec48dbd5a5b57"
}
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"date": "2026-09-07",
"host": "192.168.31.177",
"identity": "boris",
"implementationCommit": "a02ac6e",
"valueRegressionCommit": "cb97aa1",
"scope": "Native daily indicator fields explicitly bound in stockPoolFactorContract; other factor fields and pricing are unchanged.",
"targetedTests": {"passed": 3, "failed": 0},
"fullLibraryTestsBeforeAdditionalValueCase": {"passed": 447, "ignored": 6, "failed": 0},
"provenCases": [
"09:30, 10:30 and 14:30 resolve to the preceding trading date",
"15:00 resolves to the completed decision day",
"active intraday datetime applies when no explicit execution time exists",
"next-open retains the completed decision day",
"no previous trading date does not fall back to the current day",
"stock state with prior value 2 and current value 999 reads 2 intraday and 999 at close",
"unbound factor value remains unchanged"
],
"backtestServiceDeployed": true,
"paperLiveRuntimeDeployed": true,
"paperLiveDeploymentEvidence": "/Users/boris/WorkSpace/fidc-trading-platform/docs/evidence/trading-engine-revision-deployment-20260907.json",
"realBacktestAcceptanceComplete": false,
"scopedBacktestEvidence": {
"intraday": {
"range": "2025-09-08..2025-09-12",
"time": "09:30",
"runIds": ["btr_1788790021780_1150210_0", "btr_1788790036494_1150210_1"],
"seconds": [8.994, 0.596],
"tradeCount": 104,
"riskDecisionCount": 11,
"canonical": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a",
"identical": true,
"persistedFactorBindingVerified": true,
"rawParquetAudit": {
"buyFills": 60,
"priorPassCurrentFailExamples": 21,
"existingPositionTopUpsBelowCurrentSelectionThreshold": 23,
"retainedTargetReentryBelowCurrentSelectionThreshold": {"symbol": "600276.SH", "date": "2025-09-12", "priorExit": "2025-09-11 stop_loss_exit", "configuration": "reenterExitedTargets=true", "reason": "model_target_portfolio_daily"},
"note": "Selection-only conditions are not an execution-time buy veto. Position adjustment and explicit retained-target reentry must be audited separately from fresh candidate selection."
}
},
"nextOpen": {
"range": "2021-08-23..2026-08-28",
"runId": "btr_1788790344805_1150210_2",
"seconds": 21.610,
"tradeCount": 25408,
"canonical": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710",
"matchesPreFixBaseline": true
},
"terminalAudits": "clean",
"rawEvidenceDirectory": "native-daily-factor-replays-20260907"
},
"limitations": [
"This is not a generic per-field publication-timestamp model for all factor datasets.",
"Raw dynamic fields used without a stock-pool native binding need separate availability-contract review.",
"Broader factor/PIT and actual trading acceptance remain required; these replays use isolated API research fixtures. Browser draft handoff is separately recorded in OmniQuant documentation."
]
}
@@ -0,0 +1,76 @@
{
"schemaVersion": "fidc-noalloc-instrument-board-rules/v1",
"measuredAt": "2026-09-05T03:48:00+08:00",
"host": "192.168.31.177",
"engineCommit": "cfb19b5783cb446099cb3e4aff70cc39beec2e88",
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
"runnerBinarySha256": "3e69af42e41321d31c69b552cf22d7033ce1ea8d94305a32e32461148cdbfc60",
"implementation": {
"description": "evaluate KSH and BJSE order quantity rules with borrowed case-insensitive comparisons instead of allocating normalized board strings",
"rulesChanged": false,
"cacheAdded": false,
"pitSemanticsChanged": false,
"coveredBoards": ["KSH", "BJS", "BJ", "BJSE", "default"]
},
"primaryFiveYearContract": {
"totalReturn": 0.9219861819172002,
"tradeCount": 26088,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
"processCold": {
"totalSeconds": 17.395,
"dataSeconds": 12.879,
"engineSeconds": 3.873
},
"processHotEngineSeconds": [3.861, 3.812, 3.896, 3.978, 3.924],
"processHotMedianEngineSeconds": 3.896,
"snapshotSourceBaselineMedianEngineSeconds": 3.91,
"observedMedianImprovementPercent": 0.3580562659846607,
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"secondaryFiveYearContract": {
"totalReturn": 1.1342962298106998,
"tradeCount": 19404,
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
"engineSeconds": [3.519, 3.501, 3.582, 3.597],
"medianEngineSeconds": 3.582,
"snapshotSourceBaselineMedianEngineSeconds": 3.676,
"observedMedianImprovementPercent": 2.5571273122959823,
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"profile": {
"runId": "btr_1788551290580_3858934_10",
"engineSeconds": 4.283,
"minimumOrderQuantityPercent": 0.21,
"orderStepSizePercent": 0.11,
"snapshotSourceBaselineMinimumOrderQuantityPercent": 1.28,
"minimumOrderQuantityRelativeReductionPercent": 83.59375
},
"memory": {
"serviceCgroupCurrentBytes": 11477078016,
"serviceCgroupPeakBytes": 11478847488,
"processRssKiB": 11195964,
"processAnonymousKiB": 11179928,
"cacheMemoryAddedBytes": 0
},
"testGate": {
"coreUnitTotal": 422,
"corePassed": 416,
"ignoredManualBenchmarks": 6,
"failed": 0,
"integrationSuitesPassed": true
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/noalloc-instrument-board-primary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/noalloc-instrument-board-secondary-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/noalloc-instrument-board-profile-20260905/perf.data",
"/srv/fidc/canonical/run/fidc-private/evidence/noalloc-instrument-board-profile-20260905/perf-report.txt"
],
"acceptance": {
"status": "accepted_generic_no_allocation_board_rules",
"reason": "both five-year contracts preserve exact outputs, the primary does not regress, the secondary improves, the targeted profile hotspot falls, and no cache memory is added"
}
}
@@ -0,0 +1,74 @@
{
"schemaVersion": "fidc-numeric-vm-binding-generation-rejection/v1",
"measuredAt": "2026-09-06T05:36:00+08:00",
"host": "192.168.31.177",
"baseline": {
"engineCommit": "5f08978",
"primaryFiveYearHotMedianEngineSeconds": 2.629,
"primaryFiveYearTotalReturn": 0.9219861819172002,
"primaryFiveYearTradeCount": 26088,
"primaryCanonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"primaryResultStoreSha256": "9ccf0c0fc6f5d72e974381ad4cd09a80241d7de99f2649f2b01736f7c80dc2c7",
"profileInstructions": 16600366059,
"profileBranches": 2592214949
},
"compileTimeIdentifierBinding": {
"engineCommit": "2135a5b",
"implementation": "map every numeric VM identifier to a typed runtime enum during expression plan compilation",
"primaryFiveYearHotEngineSeconds": [2.608, 2.624, 2.651, 2.619, 2.781],
"primaryFiveYearHotMedianEngineSeconds": 2.624,
"observedMedianImprovementPercent": 0.190186,
"profileInstructions": 16360894880,
"instructionReductionPercent": 1.442566,
"secondaryFiveYearResultConsistent": true,
"secondaryFiveYearPerformanceExcluded": true,
"secondaryFiveYearExclusionReason": "the symbolic campaign entered a high-memory-bandwidth phase between the primary and secondary batches",
"netCodeLinesAdded": 562,
"retained": false
},
"generationStampedScratchSlots": {
"engineCommit": "5122c73",
"implementation": "invalidate numeric VM variable and local slots with a generation counter instead of clearing Option arrays for each evaluation",
"localReleaseBenchmark": {
"baselineVmNanosecondsPerEvaluation": 86.992,
"candidateSamples": [83.284, 94.166, 86.275, 84.138, 85.453],
"candidateMedianNanosecondsPerEvaluation": 85.453,
"componentImprovementPercent": 1.769136,
"comparisonStrength": "weak because the baseline contains one sample"
},
"primaryFiveYearHotEngineSeconds": [2.634, 2.636, 2.608, 2.642, 2.710],
"primaryFiveYearHotMedianEngineSeconds": 2.636,
"observedMedianRegressionPercent": 0.266261,
"profileInstructions": 16521146700,
"instructionReductionPercent": 0.477215,
"netCodeLinesAdded": 39,
"retained": false
},
"hostLoad": {
"symbolicWorkersObserved": 3,
"symbolicWorkerCpuPercentApproximate": [720, 718, 698],
"symbolicWorkerRssBytesApproximate": [54479982592, 53353455616, 53941170176],
"wallTimeComparisonsAcrossPhasesExcluded": true
},
"testGate": {
"workspacePassedBeforeFirstCandidateRejection": 539,
"workspacePassedForGenerationCandidate": 540,
"failed": 0,
"ignoredManualBenchmarks": 8
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/goal-primary-five-year-20260906.json",
"/srv/fidc/canonical/run/fidc-private/evidence/identifier-binding-primary-five-year-20260906.json",
"/srv/fidc/canonical/run/fidc-private/evidence/identifier-binding-secondary-five-year-20260906.json",
"/srv/fidc/canonical/run/fidc-private/evidence/identifier-binding-candidate-perf-stat-20260906.csv",
"/srv/fidc/canonical/run/fidc-private/evidence/identifier-binding-rollback-primary-five-year-20260906.json",
"/srv/fidc/canonical/run/fidc-private/evidence/identifier-binding-rollback-perf-stat-20260906.csv",
"/srv/fidc/canonical/run/fidc-private/evidence/vm-generation-primary-five-year-20260906.json",
"/srv/fidc/canonical/run/fidc-private/evidence/vm-generation-candidate-perf-stat-20260906.csv"
],
"decision": {
"status": "rejected_and_removed",
"reason": "both candidates preserved exact business results but failed to produce a material, stable end-to-end improvement; the typed binding added disproportionate code and the generation slots slightly regressed the five-year median",
"nextTarget": "profile and specialize the numeric VM instruction dispatch or runtime helper execution without changing expression, PIT, or lazy short-circuit semantics"
}
}
@@ -0,0 +1,24 @@
{
"schemaVersion": "fidc-numeric-vm-generation-scratch-rejection/v1",
"measuredAt": "2026-09-05T04:34:00+08:00",
"host": "local-macos",
"candidateCommitted": false,
"candidateDeployed": false,
"candidate": {
"description": "replace per-evaluation Option slot clearing with value arrays and u64 generation stamps",
"iterations": 2000000,
"vmNanosecondsPerEvaluation": 86.405,
"rhaiNanosecondsPerEvaluation": 364.356
},
"baseline": {
"iterations": 2000000,
"vmNanosecondsPerEvaluation": 86.384,
"rhaiNanosecondsPerEvaluation": 374.938
},
"observedVmRegressionPercent": 0.024310531,
"targetedTestsPassed": 7,
"acceptance": {
"status": "rejected_and_removed_before_commit",
"reason": "generation checks exactly offset slot initialization savings in the existing representative VM benchmark"
}
}
@@ -0,0 +1,91 @@
{
"schemaVersion": "fidc-post-hotpath-full-minute-regression/v1",
"measuredAt": "2026-09-05T04:12:00+08:00",
"host": "192.168.31.177",
"engineCommit": "1f8a0fd",
"runtimeCodeCommit": "cfb19b5783cb446099cb3e4aff70cc39beec2e88",
"runnerBinarySha256": "3e69af42e41321d31c69b552cf22d7033ce1ea8d94305a32e32461148cdbfc60",
"contract": {
"startDate": "2025-01-02",
"endDate": "2025-11-17",
"frequency": "1m",
"matchingType": "minute_last",
"scheduleTime": "10:18:00",
"slippageModel": "price_ratio",
"slippageValue": 0.001,
"commissionRate": 0.0001,
"minimumCommission": 5.0,
"stampTaxRate": 0.0005,
"volumeLimit": false,
"configuredVolumePercentInactive": 0.25
},
"bundle": {
"oldBundleRejectedAsStale": true,
"oldBundleId": "bt_bundle_d3109a7220681b850f31",
"newBundleId": "bt_bundle_daadb1059454b30a9a6d",
"newBundleHash": "14024f3774efb800575c7bd47b6a594b7b7fd7731493a1785c119ee7533378d2",
"newDataEpoch": "strategy-factory-source-lake:scope-v1:3556e7dbadbdc94176d23a92acc78c84c50e2ec2a6d16b9dfd25978b1a98b2ae"
},
"result": {
"totalReturn": 0.03473222656500008,
"tradeCount": 156,
"buyTradeCount": 79,
"sellTradeCount": 77,
"canonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
"resultStoreSha256": "04d804ee02ad7bc9b3649b4a2b4ddd902a4242ef38e00edeed8058860e9a6997",
"oldCanonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
"canonicalEqualAcrossDataEpoch": true,
"terminalAuditStatus": "clean",
"warnings": []
},
"performance": {
"coldTotalSeconds": 106.497,
"coldDataSeconds": 37.21,
"coldEngineSeconds": 65.519,
"coldRunContended": true,
"hotTotalSeconds": 0.584,
"hotDataSeconds": 0.004,
"hotEngineSeconds": 0.162,
"hotResultSeconds": 0.005,
"hotFinalizationSeconds": 0.397
},
"whiteBoxAudit": {
"status": "ok",
"sourceRowsFormat": "arrow",
"queriedSymbols": 1,
"requiredPairs": 156,
"queriedDailyBars": 156,
"queriedMinuteBars": 156,
"slippagePriceChecks": 156,
"missingExecutionTimestamps": 0,
"executionTimestamp": "each trade date at 10:18:00",
"failureCounts": {},
"warningCounts": {},
"firstBuy": {
"symbol": "000001.SZ",
"rawMinuteClose": 11.55,
"fillPrice": 11.56155,
"quantity": 43200,
"grossAmount": 499458.96,
"commission": 49.945896,
"stampTax": 0.0
},
"lastSell": {
"symbol": "000001.SZ",
"rawMinuteClose": 11.77,
"fillPrice": 11.75823,
"quantity": 400,
"grossAmount": 4703.292,
"commission": 5.0,
"stampTax": 2.351646
}
},
"remoteArtifacts": [
"/srv/fidc/canonical/run/fidc-private/evidence/post-daily-hotpath-full-minute-current-bundle-20260905.json",
"/srv/fidc/canonical/run/fidc-private/evidence/post-daily-hotpath-full-minute-audit-20260905/audit.json"
],
"acceptance": {
"status": "passed",
"reason": "the current Source Lake generation reproduces the historical canonical result and every fill passes minute timestamp, raw-price, slippage, fee, tax, and terminal-state audit"
}
}
@@ -0,0 +1,80 @@
{
"schemaVersion": "fidc-rank-expression-presence/v1",
"measuredAt": "2026-09-05T12:50:00+08:00",
"host": "192.168.31.177",
"engineCommit": "42999ffa2cb6967315dc4c97220561c0611cda22",
"featureCommit": "c225d8484f0493a473b68c11389513d5167ba4cc",
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
"runnerBinarySha256": "3060dd5afe720bccd1d2f959f6af04af6325766f8c6829cd80e5af9785ec2346",
"implementation": {
"description": "freeze rank expression presence at strategy construction and remove repeated rank_expr trim checks in selection dispatch",
"strategyCodeChangeRecompilesRequirement": true,
"rankValuesCached": false,
"selectionResultsCached": false,
"pitSemanticsChanged": false
},
"primaryFiveYearContract": {
"totalReturn": 0.9219861819172002,
"tradeCount": 26088,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
"previousAcceptedEngineMedianSeconds": 2.611,
"engineSeconds": [2.439, 2.447, 2.435, 2.442, 2.441, 2.432],
"processHotMedianEngineSeconds": 2.441,
"observedImprovementPercent": 6.510915,
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"secondaryFiveYearContract": {
"totalReturn": 1.1342962298106998,
"tradeCount": 19404,
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
"previousAcceptedEngineMedianSeconds": 2.346,
"engineSeconds": [2.191, 2.165, 2.249, 2.17, 2.159, 2.18],
"processHotMedianEngineSeconds": 2.17,
"observedImprovementPercent": 7.502131,
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"genericRankFiveYearContract": {
"rankBy": "free_float_cap",
"totalReturn": 0.7140315244542004,
"tradeCount": 21876,
"canonicalSha256": "84367993911b42437939aad88f29379d007af6f5f171667ff7ad6677dd488fd2",
"resultStoreSha256": "e2c7401bb9950da6fdfc0b516428061651e3e5b874a5f80c4b03ab8a81e90191",
"lowLoadEngineSeconds": [3.529, 3.472, 3.514, 3.465, 3.455, 3.459],
"processHotMedianEngineSeconds": 3.465,
"sameLoadCausalComparison": "not_claimed",
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"fullMinuteContract": {
"scheduleTime": "10:18",
"matchingType": "minute_last",
"slippageModel": "price_ratio",
"slippageValue": 0.001,
"totalReturn": 0.03473222656500008,
"tradeCount": 156,
"canonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
"resultStoreSha256": "04d804ee02ad7bc9b3649b4a2b4ddd902a4242ef38e00edeed8058860e9a6997",
"engineSeconds": [0.157, 0.16],
"processHotMedianEngineSeconds": 0.16,
"resultConsistent": true,
"terminalAuditStatus": "clean"
},
"testGate": {
"workspaceTotal": 544,
"passed": 536,
"ignoredManualBenchmarks": 8,
"failed": 0
},
"memory": {
"persistentCacheAddedBytes": 0,
"currentServiceCgroupBytesAfterRestart": 23715840
},
"acceptance": {
"status": "accepted_compile_time_rank_presence",
"reason": "both independent five-year ordered contracts improved in the low-load runs, generic rank stayed business-identical without a causal wall-time claim, and full-minute execution stayed identical"
}
}
@@ -0,0 +1,33 @@
{
"schemaVersion": "fidc-rolling-boundary-reuse-rejection/v1",
"measuredAt": "2026-09-05T12:40:00+08:00",
"host": "192.168.31.177",
"candidateCommit": "6b5d576",
"rollbackCommit": "b55ac0bf814641ec09f61f4ec10d52b311ee8aab",
"finalRemovalCommit": "b55ac0bf814641ec09f61f4ec10d52b311ee8aab",
"implementation": {
"description": "share one adjusted-close base factor and one valid-volume count across batched rolling windows",
"businessResultsChanged": false,
"retainedInProduction": false
},
"primaryFiveYearContract": {
"candidateEngineSeconds": [2.6, 2.557, 2.564, 2.603, 2.574, 2.614],
"candidateProcessHotMedianEngineSeconds": 2.574,
"acceptedBaselineEngineSeconds": 2.441,
"observedRegressionPercent": 5.448587,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"tradeCount": 26088
},
"secondaryFiveYearContract": {
"candidateEngineSeconds": [2.381, 2.323, 2.344, 2.377, 2.353, 2.348],
"candidateProcessHotMedianEngineSeconds": 2.348,
"acceptedBaselineEngineSeconds": 2.17,
"observedRegressionPercent": 8.202765,
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
"tradeCount": 19404
},
"acceptance": {
"status": "rejected_cross_strategy_regression",
"reason": "both real five-year controls became slower despite exact result parity; candidate code and production binary were removed"
}
}
@@ -0,0 +1,45 @@
{
"schemaVersion": "fidc-selection-band-precheck-rejection/v1",
"measuredAt": "2026-09-05T12:55:00+08:00",
"host": "192.168.31.177",
"candidateCommit": "e060af380ebfce6d9e802bb6e9677c11835fc0d0",
"removalCommit": "42999ffa2cb6967315dc4c97220561c0611cda22",
"implementation": {
"description": "evaluate factor-backed market-cap band bounds before constructing StockExpressionState",
"genericRankBenefitObserved": true,
"orderedControlRegressionObserved": true,
"retainedInProduction": false
},
"genericRankFiveYearContract": {
"rankBy": "free_float_cap",
"candidateProcessHotMedianEngineSeconds": 2.835,
"acceptedBaselineProcessHotMedianEngineSeconds": 3.465,
"observedImprovementPercent": 18.181818,
"canonicalSha256": "84367993911b42437939aad88f29379d007af6f5f171667ff7ad6677dd488fd2",
"tradeCount": 21876
},
"primaryFiveYearContract": {
"candidateProcessHotMedianEngineSeconds": 2.565,
"acceptedBaselineProcessHotMedianEngineSeconds": 2.441,
"observedRegressionPercent": 5.079148,
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
"tradeCount": 26088
},
"secondaryFiveYearContract": {
"candidateProcessHotMedianEngineSeconds": 2.3465,
"acceptedBaselineProcessHotMedianEngineSeconds": 2.17,
"observedRegressionPercent": 8.133641,
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
"tradeCount": 19404
},
"fullMinuteRollbackContract": {
"tradeCount": 156,
"canonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
"resultStoreSha256": "04d804ee02ad7bc9b3649b4a2b4ddd902a4242ef38e00edeed8058860e9a6997",
"terminalAuditStatus": "clean"
},
"acceptance": {
"status": "rejected_cross_strategy_regression",
"reason": "single generic-rank improvement cannot justify regressions in both standard ordered controls; candidate was removed from local, remote and deployed code"
}
}
@@ -0,0 +1,89 @@
{
"schemaVersion": "fidc-engine-optimization-ab/v1",
"generatedDate": "2026-09-07",
"host": "192.168.31.177",
"change": {
"engineCommit": "f45b3a71fa78ecb95b0fa80687130ae7385549e8",
"description": "Do not clone the shared multi-date execution quote map when a run reaches the end of one execution date.",
"dataContract": "The prepared-data cache remains immutable and shared. A uniquely owned run-local quote map still removes completed dates.",
"cachedBusinessResults": false
},
"deployment": {
"serviceCommit": "11cfe1b8d09c1da4807400f3d903199ea6f1e711",
"implementationIdentitySha256": "7dbc839acb26fed98fc84a0bc221c316bf370046780c6ae75a79171fd52e5535",
"serviceBinarySha256": "bc9385ef340b8e0b3d0d17a9251e159806107443865361271ee0ccd8dbe1a768",
"runnerBinarySha256": "55c321f39518b5821e35ee1ec5320e35f94590be3aeeeac0827df07670e623fc",
"serviceUser": "boris",
"sourceLakeRestarted": false,
"health": "ok"
},
"currentCloseOneYear": {
"before": {
"processHotMedianTotalSeconds": 1.548,
"processHotMedianEngineSeconds": 1.09
},
"after": {
"runs": 5,
"processHotMedianTotalSeconds": 1.238,
"processHotMedianEngineSeconds": 0.773,
"totalReturn": 0.3201517861398,
"tradeCount": 5351,
"canonicalResultDigest": "7204c6f41b8e39fbf1af7fc55cd601b80f3427a7aa058394ccd8b0b14ca48eed",
"resultStoreDigest": "0b4d24ed5ec2b27cc4135707b4c51c78eb2c3e35a20da8108610778c30c72c73",
"terminalAuditStatus": "clean"
},
"improvementPercent": {
"total": 20.02584,
"engine": 29.082569
}
},
"crossModeRegression": {
"staticCurrentClose": {
"processHotTotalSeconds": 0.866,
"processHotEngineSeconds": 0.326,
"totalReturn": 0.13228843240310018,
"tradeCount": 4445,
"canonicalResultDigest": "fdfa855295c0b55bdbe6f39952ead1515e844bf033ced974d3c3ddc037a5d0b1",
"resultStoreDigest": "697566645116c76ff837cd36f7f9bbd7ad3eb30510a5b95012fb730d5072d511"
},
"nextBarOpenFiveYear": {
"processHotTotalSeconds": 3.212,
"processHotEngineSeconds": 2.597,
"totalReturn": 0.9922618879291,
"tradeCount": 25827,
"canonicalResultDigest": "ac1d167cb1e1073e1d1ecb01e914f94d7560081c1d238e6b4418d86233250719",
"resultStoreDigest": "79570e0ae6b07badc1b693dc897dd1381647d259a4fe44ed3e50bc215e2fd088"
},
"fullMinute": {
"processHotTotalSeconds": 0.973,
"processHotEngineSeconds": 0.231,
"totalReturn": 0.03473222656500008,
"tradeCount": 156,
"canonicalResultDigest": "7dae3a618932b90d36e9968c027f08a94d69b8fc6a56b1ea0cc1a2cb771d85b8",
"resultStoreDigest": "bbbd7080b8fd7f6e8c3a8132499842bd0e4d644dbfdc3e8dbca7d2c0c1381e93"
}
},
"profile": {
"beforePath": "/srv/fidc/canonical/run/fidc-private/evidence/current-close-post-preplan-profile-20260907",
"afterPath": "/srv/fidc/canonical/run/fidc-private/evidence/current-close-shared-quote-profile-20260907",
"beforeSamples": 564,
"afterSamples": 408,
"lostSamples": 0,
"beforeFinding": "Arc::make_mut was reached from remove_execution_quotes_on_date and cloned the shared quote map.",
"afterFinding": "The remove_execution_quotes_on_date call chain is absent; release_execution_quotes_on_date accounts for 0.27 percent of sampled cycles."
},
"verification": {
"workspaceCoreTestsPassed": 440,
"workspaceCoreTestsIgnored": 6,
"workspaceFailures": 0,
"allCanonicalDigestsMatched": true,
"allResultStoreDigestsMatched": true,
"allTerminalAuditsClean": true
},
"supportingEvidence": [
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/current-close-shared-quote-release-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/static-pool-shared-quote-release-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/next-open-shared-quote-release-20260907.json",
"/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/full-minute-shared-quote-release-20260907.json"
]
}

Some files were not shown because too many files have changed in this diff Show More