Compare commits

...

148 Commits

Author SHA1 Message Date
boris 3c70a9273b 记录模拟委托异常恢复的Linux配套验收 2026-09-15 01:39:10 +08:00
boris 695fdee4b8 保留模拟器失败调用前的委托与执行状态 2026-09-15 01:28:38 +08:00
boris 4c6147e2ee 记录人工零仓位再投修复的Linux验收 2026-09-15 00:36:09 +08:00
boris 818552bc96 让人工零仓位和零权重约束红利再投 2026-09-15 00:23:09 +08:00
boris 534ab42906 记录换股保护继承的配套Linux验收 2026-09-14 23:48:27 +08:00
boris ba4b77fd74 让实际换股持仓继承原策略保护期限 2026-09-14 23:38:43 +08:00
boris ad76bdb6ae 记录换股契约与批次回滚Linux验收 2026-09-14 23:06:41 +08:00
boris 59a0c95aae 严格校验换股证据并原子提交公司行为批次 2026-09-14 22:57:53 +08:00
boris d4e7cdd5b5 记录账务再投来源与流式完整性Linux验收 2026-09-14 22:39:21 +08:00
boris 984f9d308d 明确红利账务再投来源并修复流式遗漏与校正时钟 2026-09-14 22:30:05 +08:00
boris fbc4233dcd 记录手工公司行为校正Linux配套验收 2026-09-14 21:24:46 +08:00
boris 05f1cbbe00 修复迟到成交跨公司行为的经济账本校正 2026-09-14 21:14:20 +08:00
boris ef9cc39882 记录迟到成交与持有保护Linux验收 2026-09-14 19:27:10 +08:00
boris b4c68be29b 补验锁定期间手工卖出与送转后的自动退出 2026-09-14 19:17:59 +08:00
boris e9c9ecbd48 修复迟到成交和换股批次的FIFO与持有期 2026-09-14 19:15:01 +08:00
boris b2eaaa0d26 记录开盘和跨日时钟Linux联合回归证据 2026-09-14 18:53:29 +08:00
boris 13c89e8d59 修复开盘与跨日ETF执行时钟及资金阻断原因 2026-09-14 18:42:25 +08:00
boris 232e9ae154 保留手工逐日进度并在交付校验失败时终止 2026-09-14 17:30:00 +08:00
boris f8955bfb18 接入独立手工仓位时间线并保留原策略配置 2026-09-14 15:48:44 +08:00
boris fb8192a286 修正盘后意图保留及手工观察的阶段时序 2026-09-14 12:27:30 +08:00
boris 7f0c6a008a 将手工观察接入执行时钟并保留来源与账本语义 2026-09-14 11:41:15 +08:00
boris 93de28d369 docs(perf): record combined risk and series storage Backtest release 2026-09-14 11:14:59 +08:00
boris 665653c3fe 完善手工回放的最终费用和真实观察时间合同 2026-09-14 10:15:03 +08:00
boris a29c434be9 docs(perf): reconcile storage acceptance with current business main 2026-09-14 10:04:00 +08:00
boris 4c96d0c31f Merge remote-tracking branch 'origin/main' 2026-09-14 09:15:58 +08:00
boris 5e11f3da22 建立手工成交观察合同与原子回放游标 2026-09-14 07:30:19 +08:00
boris 8e7ae69b0b 归档回报与最新目标的配套发布及生产回测证据 2026-09-14 06:25:35 +08:00
boris 81acc54228 修复回报上下文与盘前意图并在提交前采用最新完整目标 2026-09-14 06:06:57 +08:00
boris 600808b171 归档日内时钟配套发布与九次历史执行验收 2026-09-14 04:26:14 +08:00
boris d2f1b64af1 记录时钟候选验证与磁盘保护后的正式缓存清理 2026-09-14 04:06:18 +08:00
boris 9a54156df9 docs(data): record DayOpen correctness and exact-column memory acceptance 2026-09-14 04:00:41 +08:00
boris 237ee15a51 修复日内时钟提前记账并按原订单续执行算法单 2026-09-14 03:52:52 +08:00
boris 996b909608 perf(data): retain exact repeated and reference-matched series columns 2026-09-14 03:31:28 +08:00
boris c62ae1206f fix(data): use day-open values for day-open rolling averages 2026-09-14 03:18:56 +08:00
boris 3a3091a2cf docs(perf): record small selection CPU savings and full shared-input parity 2026-09-14 03:05:55 +08:00
boris d2aa16a2f0 perf(risk): avoid per-symbol selection checks when the frozen policy has none 2026-09-14 02:18:24 +08:00
boris 0576cf9b6d 记录日内晚窗口提前影响早间持仓的隔离反例 2026-09-14 02:08:20 +08:00
boris 636e0dfd05 记录卖出回报续买修复的真实回放与配套发布 2026-09-14 00:10:58 +08:00
boris c98bcc3eb2 修复股票池卖单回报后未继续执行买入阶段 2026-09-13 23:43:26 +08:00
boris 53af3a6a85 perf(data): support exact reservation for known numeric field additions 2026-09-13 22:12:40 +08:00
boris 70c6f7e90b fix(data): expose actual snapshot row counts without cloning data 2026-09-13 17:11:06 +08:00
boris 0ed6752a73 perf(engine): deduplicate daily factor names before allocating sorted output 2026-09-13 16:47:16 +08:00
boris 3e8cc63b9a Revert "perf(engine): share immutable daily factor schemas and numeric buffers"
This reverts commit 5d0823c060bfd2a42a3f86a381e874004ab7f6af.
2026-09-13 14:16:07 +08:00
boris be171683c9 Revert "test(engine): retain static schema names across shared factor rows"
This reverts commit ce0dc0a106f0a98230bb9c428537ec086b968273.
2026-09-13 14:16:07 +08:00
boris 0a6fab9038 Revert "perf(engine): keep empty numeric maps on a direct lookup path"
This reverts commit a63dd94045f3a4b95dbfc917d5d8afa5c22f1897.
2026-09-13 14:16:07 +08:00
boris e8abf43cd4 perf(engine): keep empty numeric maps on a direct lookup path 2026-09-13 13:49:20 +08:00
boris 2286bfa757 test(engine): retain static schema names across shared factor rows 2026-09-13 13:07:52 +08:00
boris 93809eea1b perf(engine): share immutable daily factor schemas and numeric buffers 2026-09-13 13:02:17 +08:00
boris f7f0ff2951 Merge remote-tracking branch 'origin/main' 2026-09-13 11:37:38 +08:00
boris effa0c6456 test(engine): validate quote demand across different account capital 2026-09-13 11:21:09 +08:00
boris b1ca2dfada fix(engine): resolve decision quote scope from the actual run context 2026-09-13 11:18:39 +08:00
boris d15abc18ae test(engine): reproduce account-sensitive quote scope bypass 2026-09-13 11:07:57 +08:00
boris 9370dfe6e9 Merge remote-tracking branch 'origin/main' 2026-09-13 11:06:22 +08:00
boris b19108558f 统一仓位调整时点并保留策略择时与退出意图 2026-09-13 11:05:12 +08:00
boris fe05384f80 test(data): compare compact names by value and allocation class 2026-09-13 09:57:47 +08:00
boris 0ff90c4329 refactor(data): use explicit string views for compact numeric names 2026-09-13 09:55:16 +08:00
boris c85daae608 perf(data): inline numeric factor keys and preserve borrowed static names 2026-09-13 09:53:06 +08:00
boris f73513e2d4 Revert "perf(data): sort compact market-cap keys without revisiting wide snapshots"
This reverts commit 4e953b6e98.
2026-09-13 07:33:05 +08:00
boris 4e953b6e98 perf(data): sort compact market-cap keys without revisiting wide snapshots 2026-09-13 07:12:42 +08:00
boris b232847e40 docs(strategy): record feature-date gap reproduction and replay acceptance 2026-09-13 06:34:22 +08:00
boris e3b3929578 test(strategy): supply declared historical prices in next-open fixture 2026-09-13 06:09:49 +08:00
boris 20e73d567b fix(strategy): require exact feature-date market snapshots 2026-09-13 06:06:18 +08:00
boris cf4498668b test(strategy): qualify the expected missing-market error 2026-09-13 06:03:54 +08:00
boris 3f39943ee4 test(strategy): reject execution-day fallback for missing decision-day market 2026-09-13 05:59:40 +08:00
boris 5c65e65c6f perf(data): parallelize bounded daily symbol indices 2026-09-13 03:25:37 +08:00
boris f3c70ea566 docs(perf): record expression context CPU and real HTTP parity evidence 2026-09-13 01:13:44 +08:00
boris 07b7b181b6 perf(strategy): retain compact factor values in expression contexts 2026-09-13 00:50:07 +08:00
boris fe7243bbc3 perf(data): normalize owned daily bundles in parallel with stable errors 2026-09-12 19:30:28 +08:00
boris 875e31f71f fix(stock-pool): separate cumulative condition facts from quote capacity 2026-09-12 18:50:12 +08:00
boris 61bd14d001 fix(backtest): inspect the authoritative strategy inside runner bundles 2026-09-12 16:55:16 +08:00
boris 531df39911 chore(test): remove obsolete tree-map import after compact snapshot merge 2026-09-12 16:29:56 +08:00
boris e912e2a6f5 Merge remote-tracking branch 'origin/main' 2026-09-12 16:26:06 +08:00
boris b1f2fcb85c fix(stock-pool): scan decoded native conditions instead of serialized source echoes 2026-09-12 16:23:18 +08:00
boris 828690a51e perf(data): compact immutable snapshots with current execution rules 2026-09-12 16:02:24 +08:00
boris ffd23b9920 fix(stock-pool): preserve exit roles and freeze relative reduction bases 2026-09-12 15:54:33 +08:00
boris 4ac9ee5058 fix(stock-pool): keep full stops stronger than simultaneous reductions 2026-09-12 14:21:33 +08:00
boris 848c1a514a fix(stock-pool): separate exit ownership and ordinary quote dependencies 2026-09-12 12:18:26 +08:00
boris 099759ae67 fix(stock-pool): execute verified ETF daily fallbacks with frozen next-open targets 2026-09-12 10:47:47 +08:00
boris d646ca455d merge: integrate causal capacity model with current order clocks and intent planning 2026-09-12 08:52:37 +08:00
boris 3b2a97fa84 fix(backtest): keep next-open limit remainders active on the execution day 2026-09-12 08:02:41 +08:00
boris 2f2258f208 feat(stock-pool): add explicit index-mapped market-cap bands 2026-09-12 07:29:10 +08:00
boris 32a34fadd6 fix(backtest): preserve order origin clocks and emit only state transitions 2026-09-12 07:00:47 +08:00
boris 7ac87a90c4 test: keep snapshot price-risk fixtures in their explicit daily model 2026-09-12 06:51:33 +08:00
boris 5949d4cc69 refactor: remove retired daily quota parameters from target diagnostics 2026-09-12 06:48:07 +08:00
boris 24cb4805a7 fix: keep decision estimates independent and use timed capacity in fill fixtures 2026-09-12 06:46:49 +08:00
boris 053f880e34 test: declare historical audit assumptions in non-liquidity fixtures 2026-09-12 06:39:34 +08:00
boris 4d3a9e0e5b fix: separate historical session capacity audits from execution sizing 2026-09-12 06:32:10 +08:00
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
126 changed files with 39950 additions and 3361 deletions
Generated
+1338 -12
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,6 +1,7 @@
[workspace]
members = [
"crates/fidc-core",
"crates/fidc-signal-client",
]
resolver = "2"
@@ -11,6 +12,7 @@ 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"] }
+2
View File
@@ -1,3 +1,5 @@
<p><img src="assets/brand/fiPandaWithTitle.png" alt="FiPanda" width="144" height="144"></p>
# fidc-backtest-engine
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
+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"
}
]
}
+2
View File
@@ -6,6 +6,8 @@ license.workspace = true
authors.workspace = true
[dependencies]
compact_str = { version = "=0.10.0", features = ["serde"] }
rust_decimal.workspace = true
ahash.workspace = true
chrono.workspace = true
indexmap.workspace = true
@@ -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
@@ -0,0 +1,778 @@
use super::*;
fn time(minute: u32) -> NaiveTime {
NaiveTime::from_hms_opt(10, minute, 0).unwrap()
}
fn data(quotes: &[(u32, f64, u32)]) -> DataSet {
data_with_snapshot(quotes, limit_test_snapshot())
}
fn data_with_snapshot(quotes: &[(u32, f64, u32)], snapshot: DailyMarketSnapshot) -> DataSet {
DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![snapshot],
vec![],
vec![limit_test_candidate(true, true)],
vec![limit_test_benchmark()],
vec![],
quotes
.iter()
.map(|&(minute, price, volume)| {
let mut quote = limit_test_quote(price, price, price);
quote.timestamp = quote.date.and_time(time(minute));
quote.volume_delta = u64::from(volume);
quote.amount_delta = price * f64::from(volume);
quote.bid1_volume = u64::from(volume / 100);
quote.ask1_volume = u64::from(volume / 100);
quote
})
.collect(),
)
.unwrap()
}
fn broker() -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
BrokerSimulator::new(
ChinaAShareCostModel::default()
.with_commission_rate(0.0003)
.with_minimum_commission(5.),
ChinaEquityRuleHooks,
)
.with_matching_type(MatchingType::MinuteLast)
.with_execution_price_field(PriceField::Last)
.with_intraday_execution_start_time(time(0))
.with_volume_limit(true)
.with_volume_percent(0.25)
.with_liquidity_limit(false)
.with_inactive_limit(false)
.with_strict_value_budget(true)
}
fn intent(style: AlgoOrderStyle, value: f64) -> StrategyDecision {
StrategyDecision {
order_intents: vec![OrderIntent::AlgoValue {
symbol: "000001.SZ".into(),
value,
style,
start_time: Some(time(0)),
end_time: Some(time(10)),
reason: "clock-algorithm".into(),
}],
..Default::default()
}
}
fn step(
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
portfolio: &mut PortfolioState,
data: &DataSet,
minute: u32,
decision: &StrategyDecision,
) -> BrokerExecutionReport {
broker
.execute_between(
limit_test_snapshot().date,
portfolio,
data,
decision,
Some(time(minute)),
Some(time(minute)),
)
.unwrap()
}
#[test]
fn twap_clock_preserves_quantity_prices_fees_budget_and_parent_order() {
let data = data(&[
(0, 10., 4_000),
(2, 10.1, 4_000),
(5, 10.2, 4_000),
(10, 10.3, 4_000),
]);
let decision = intent(AlgoOrderStyle::Twap, 10_000.);
let mut synchronous_account = PortfolioState::new(20_000.);
let reference = broker()
.execute(
limit_test_snapshot().date,
&mut synchronous_account,
&data,
&decision,
)
.unwrap();
let broker = broker();
let mut account = PortfolioState::new(20_000.);
let mut fills = Vec::new();
let mut events = Vec::new();
let empty = StrategyDecision::default();
for minute in [0, 2, 5, 10] {
let batch = step(
&broker,
&mut account,
&data,
minute,
if minute == 0 { &decision } else { &empty },
);
assert!(
batch
.fill_events
.iter()
.all(|fill| fill.execution_timestamp.unwrap().time() <= time(minute))
);
fills.extend(batch.fill_events);
events.extend(batch.order_events);
}
let canonical = |rows: &[crate::events::FillEvent]| {
rows.iter()
.map(|fill| {
(
fill.quantity,
fill.price.to_bits(),
fill.commission.to_bits(),
fill.stamp_tax.to_bits(),
fill.transfer_fee.to_bits(),
fill.execution_timestamp,
fill.order_id,
)
})
.collect::<Vec<_>>()
};
assert_eq!(canonical(&fills), canonical(&reference.fill_events));
assert_eq!(account.cash(), synchronous_account.cash());
assert_eq!(fills.iter().map(|fill| fill.quantity).sum::<u32>(), 900);
assert_eq!(fills.iter().map(|fill| fill.commission).sum::<f64>(), 5.);
assert!(fills.iter().map(|fill| -fill.net_cash_flow).sum::<f64>() <= 10_000.);
assert!(events.iter().all(|event| event.order_id == Some(1)));
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
assert!(broker.open_order_views().is_empty());
}
#[test]
fn partial_algorithm_cancel_releases_reservation_and_never_executes_the_remainder() {
let data = data(&[
(0, 10., 4_000),
(2, 10., 4_000),
(5, 10., 4_000),
(10, 10., 4_000),
]);
let broker = broker();
let mut account = PortfolioState::new(20_000.);
step(
&broker,
&mut account,
&data,
0,
&intent(AlgoOrderStyle::Twap, 10_000.),
);
assert_eq!(broker.open_order_views()[0].reserved_cash, Some(10_000.));
let partial = step(
&broker,
&mut account,
&data,
2,
&StrategyDecision::default(),
);
assert_eq!(
partial
.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>(),
100
);
let working = broker.open_order_views();
assert_eq!(working[0].order_id, 1);
assert_eq!(working[0].filled_quantity, 100);
assert_eq!(
working[0].reserved_cash,
Some(10_000. + partial.fill_events[0].net_cash_flow)
);
let cancel = step(
&broker,
&mut account,
&data,
3,
&StrategyDecision {
order_intents: vec![OrderIntent::CancelAll {
reason: "explicit-user-cancel".into(),
}],
..Default::default()
},
);
assert!(cancel.fill_events.is_empty());
assert_eq!(
cancel.order_events.last().unwrap().status,
OrderStatus::Canceled
);
assert_eq!(cancel.order_events.last().unwrap().filled_quantity, 100);
assert!(broker.open_order_views().is_empty());
assert!(
step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default()
)
.fill_events
.is_empty()
);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
}
#[test]
fn algorithm_expiry_without_a_quote_does_not_reuse_old_liquidity() {
let data = data(&[(0, 10., 4_000), (2, 10., 4_000)]);
let broker = broker();
let mut account = PortfolioState::new(20_000.);
step(
&broker,
&mut account,
&data,
0,
&intent(AlgoOrderStyle::Twap, 10_000.),
);
step(
&broker,
&mut account,
&data,
2,
&StrategyDecision::default(),
);
assert_eq!(
broker.next_day_order_expiry(limit_test_snapshot().date),
Some(time(10))
);
let terminal = step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default(),
);
assert!(terminal.fill_events.is_empty());
assert_eq!(
terminal.order_events.last().unwrap().status,
OrderStatus::Expired
);
assert_eq!(terminal.order_events.last().unwrap().filled_quantity, 100);
assert!(
terminal
.process_events
.iter()
.any(|event| event.detail.contains("Expired")),
"{:?}",
terminal.process_events
);
assert!(broker.open_order_views().is_empty());
}
#[test]
fn separate_buy_cannot_spend_the_working_algorithms_cash_budget() {
let data = data(&[
(0, 10., 4_000),
(1, 10., 4_000),
(2, 10., 4_000),
(10, 10., 4_000),
]);
let broker = broker();
let mut account = PortfolioState::new(11_000.);
step(
&broker,
&mut account,
&data,
0,
&intent(AlgoOrderStyle::Twap, 10_000.),
);
let other = step(
&broker,
&mut account,
&data,
1,
&StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: "000001.SZ".into(),
quantity: 1_000,
reason: "separate-buy".into(),
}],
..Default::default()
},
);
assert!(
other.fill_events.is_empty(),
"cash reserved for order 1 was spent: {:?}",
other.fill_events
);
let final_batch = step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default(),
);
assert!(
final_batch
.fill_events
.iter()
.all(|fill| fill.order_id == Some(1))
);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 900);
assert!(account.cash() >= 1_000.);
}
#[test]
fn changing_the_later_daily_close_does_not_resize_an_algorithm_submitted_now() {
let quotes = [(0, 10., 4_000), (2, 10.1, 4_000), (10, 10.2, 4_000)];
let mut changed = limit_test_snapshot();
changed.close = 100.;
changed.last_price = 100.;
let run = |data: DataSet| {
let broker = broker();
let mut account = PortfolioState::new(20_000.);
let initial = step(
&broker,
&mut account,
&data,
0,
&intent(AlgoOrderStyle::Twap, 10_000.),
);
assert!(initial.fill_events.is_empty());
let quantity = broker.open_order_views()[0].requested_quantity;
let final_batch = step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default(),
);
(
quantity,
final_batch
.fill_events
.iter()
.map(|fill| {
(
fill.quantity,
fill.price.to_bits(),
fill.net_cash_flow.to_bits(),
)
})
.collect::<Vec<_>>(),
)
};
assert_eq!(
run(data(&quotes)),
run(data_with_snapshot(&quotes, changed))
);
}
#[test]
fn vwap_clock_preserves_cash_costs_and_does_not_spend_future_volume() {
let data = data(&[
(0, 10., 400),
(2, 10., 800),
(5, 10., 1_200),
(10, 10., 4_000),
]);
let decision = intent(AlgoOrderStyle::Vwap, 10_000.);
let mut synchronous_account = PortfolioState::new(20_000.);
let reference = broker()
.execute(
limit_test_snapshot().date,
&mut synchronous_account,
&data,
&decision,
)
.unwrap();
let broker = broker();
let mut account = PortfolioState::new(20_000.);
let empty = StrategyDecision::default();
let mut filled = 0;
let mut commission = 0.;
for (minute, expected) in [(0, 100), (2, 300), (5, 600), (10, 900)] {
let batch = step(
&broker,
&mut account,
&data,
minute,
if minute == 0 { &decision } else { &empty },
);
filled += batch
.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>();
commission += batch
.fill_events
.iter()
.map(|fill| fill.commission)
.sum::<f64>();
assert_eq!(filled, expected);
assert!(batch.fill_events.iter().all(|fill| fill.order_id == Some(1)
&& fill.execution_timestamp.unwrap().time() <= time(minute)));
}
assert_eq!(account.cash(), synchronous_account.cash());
assert_eq!(
commission,
reference
.fill_events
.iter()
.map(|fill| fill.commission)
.sum::<f64>()
);
assert!(broker.open_order_views().is_empty());
}
#[test]
fn global_vwap_matching_keeps_the_same_working_order_between_clock_ticks() {
let data = data(&[(0, 10., 400), (2, 10., 400), (10, 10., 4_000)]);
let broker = broker().with_matching_type(MatchingType::Vwap);
let mut account = PortfolioState::new(20_000.);
let first = step(
&broker,
&mut account,
&data,
0,
&StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: "000001.SZ".into(),
quantity: 900,
reason: "configured-vwap".into(),
}],
..Default::default()
},
);
assert_eq!(
first
.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>(),
100
);
assert_eq!(
broker.open_order_views().len(),
1,
"{:?}",
first.order_events
);
let second = step(
&broker,
&mut account,
&data,
2,
&StrategyDecision::default(),
);
assert_eq!(second.fill_events[0].quantity, 100);
assert_eq!(second.fill_events[0].order_id, Some(1));
let final_batch = step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default(),
);
assert_eq!(final_batch.fill_events[0].quantity, 700);
assert_eq!(final_batch.fill_events[0].order_id, Some(1));
assert!(broker.open_order_views().is_empty());
}
#[test]
fn algorithm_sell_honors_t_plus_one_and_keeps_original_quantity_after_partial_fills() {
let data = data(&[(0, 10., 400), (2, 10., 800), (10, 10., 4_000)]);
let date = limit_test_snapshot().date;
for acquired_today in [false, true] {
let broker = broker();
let mut account = PortfolioState::new(20_000.);
account.position_mut("000001.SZ").buy(
if acquired_today {
date
} else {
date.pred_opt().unwrap()
},
1_000,
10.,
);
let decision = intent(AlgoOrderStyle::Vwap, -10_000.);
let mut fills = Vec::new();
let mut events = Vec::new();
let empty = StrategyDecision::default();
for minute in [0, 2, 10] {
let batch = step(
&broker,
&mut account,
&data,
minute,
if minute == 0 { &decision } else { &empty },
);
fills.extend(batch.fill_events);
events.extend(batch.order_events);
}
assert_eq!(
fills.iter().map(|fill| fill.quantity).sum::<u32>(),
if acquired_today { 0 } else { 1_000 }
);
assert!(events.iter().all(|event| event.order_id == Some(1)));
if !acquired_today {
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
assert_eq!(events.last().unwrap().requested_quantity, 1_000);
assert_eq!(events.last().unwrap().filled_quantity, 1_000);
}
assert!(broker.open_order_views().is_empty());
}
}
#[test]
fn an_explicit_ioc_or_fok_does_not_become_a_persistent_algorithm() {
let data = data(&[(0, 10., 400), (2, 10., 4_000), (10, 10., 4_000)]);
for tif in [
OrderTimeInForce::Ioc,
OrderTimeInForce::Fok,
OrderTimeInForce::Day,
OrderTimeInForce::Gtc,
] {
let broker = broker();
let mut account = PortfolioState::new(20_000.);
let mut decision = intent(AlgoOrderStyle::Vwap, 10_000.);
if !decision.order_intents[0].supports_time_in_force(tif) {
decision.order_intents = decision
.order_intents
.into_iter()
.map(|intent| intent.with_time_in_force(tif))
.collect();
let error = broker
.execute_between(
limit_test_snapshot().date,
&mut account,
&data,
&decision,
Some(time(0)),
Some(time(0)),
)
.unwrap_err();
assert!(
error
.to_string()
.contains("is not supported for this order intent")
);
assert_eq!(account.cash(), 20_000.);
assert!(broker.open_order_views().is_empty());
continue;
}
decision.order_intents = decision
.order_intents
.into_iter()
.map(|intent| intent.with_time_in_force(tif))
.collect();
let first = step(&broker, &mut account, &data, 0, &decision);
let persists = matches!(tif, OrderTimeInForce::Day | OrderTimeInForce::Gtc);
assert_eq!(
!broker.open_order_views().is_empty(),
persists,
"{tif:?}: {:?}",
first.order_events
);
if !persists {
assert!(
step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default()
)
.fill_events
.is_empty()
);
}
}
}
#[test]
fn two_working_algorithms_reserve_only_real_cash_without_starving_the_first() {
let data = data(&[(0, 10., 40_000), (10, 10., 40_000)]);
let broker = broker();
let mut account = PortfolioState::new(15_000.);
let mut decision = intent(AlgoOrderStyle::Twap, 10_000.);
decision
.order_intents
.extend(intent(AlgoOrderStyle::Twap, 10_000.).order_intents);
step(&broker, &mut account, &data, 0, &decision);
assert_eq!(
broker
.open_order_views()
.iter()
.map(|order| order.reserved_cash.unwrap())
.collect::<Vec<_>>(),
vec![10_000., 5_000.]
);
let report = step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default(),
);
assert_eq!(
report
.fill_events
.iter()
.map(|fill| (fill.order_id, fill.quantity))
.collect::<Vec<_>>(),
vec![(Some(1), 900), (Some(2), 500)]
);
assert!(account.cash() >= 0.);
assert!(broker.open_order_views().is_empty());
}
#[test]
fn a_clock_slice_does_not_turn_window_twap_into_an_unlimited_instant_order() {
let data = data(&[(0, 10., 100), (2, 10., 100), (10, 10.1, 100)]);
let broker = broker()
.with_volume_limit(false)
.with_liquidity_limit(false);
let mut account = PortfolioState::new(20_000.);
step(
&broker,
&mut account,
&data,
0,
&intent(AlgoOrderStyle::Twap, 10_000.),
);
let first = step(
&broker,
&mut account,
&data,
2,
&StrategyDecision::default(),
);
let last = step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default(),
);
assert_eq!(
first
.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>(),
100
);
assert_eq!(
last.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>(),
100
);
assert_eq!(
last.order_events.last().unwrap().status,
OrderStatus::Expired
);
assert_eq!(last.order_events.last().unwrap().filled_quantity, 200);
assert!(broker.open_order_views().is_empty());
}
#[test]
fn non_matching_controls_amend_or_cancel_without_filling_a_crossing_quote() {
let data = data(&[(0, 10., 4_000), (2, 9.4, 4_000)]);
let broker = broker();
let mut account = PortfolioState::new(20_000.);
step(
&broker,
&mut account,
&data,
0,
&StrategyDecision {
order_intents: vec![
OrderIntent::LimitShares {
symbol: "000001.SZ".into(),
quantity: 100,
limit_price: 9.5,
reason: "resting".into(),
}
.with_time_in_force(OrderTimeInForce::Gtc),
],
..Default::default()
},
);
assert_eq!(broker.open_order_views().len(), 1);
let modify = broker
.execute_controls_without_matching(
limit_test_snapshot().date,
limit_test_snapshot().date,
&mut account,
&data,
&StrategyDecision {
order_intents: vec![OrderIntent::ModifyOrder {
order_id: 1,
new_total_quantity: Some(200),
new_limit_price: Some(9.3),
reason: "pre-open-amend".into(),
}],
..Default::default()
},
Some(time(2)),
)
.unwrap();
assert!(modify.fill_events.is_empty());
assert_eq!(broker.open_order_views()[0].limit_price, 9.3);
assert_eq!(broker.open_order_views()[0].requested_quantity, 200);
let cancel = broker
.execute_controls_without_matching(
limit_test_snapshot().date,
limit_test_snapshot().date,
&mut account,
&data,
&StrategyDecision {
order_intents: vec![OrderIntent::CancelAll {
reason: "pre-open-cancel".into(),
}],
..Default::default()
},
Some(time(2)),
)
.unwrap();
assert!(cancel.fill_events.is_empty());
assert_eq!(
cancel.order_events.last().unwrap().status,
OrderStatus::Canceled
);
assert_eq!(account.cash(), 20_000.);
assert!(broker.open_order_views().is_empty());
}
#[test]
fn control_only_phase_cannot_be_used_to_submit_an_order_or_leave_matching_disabled() {
let data = data(&[(0, 10., 4_000)]);
let broker = broker();
let mut account = PortfolioState::new(20_000.);
let submit = StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: "000001.SZ".into(),
quantity: 100,
reason: "normal-order".into(),
}],
..Default::default()
};
assert!(
broker
.execute_controls_without_matching(
limit_test_snapshot().date,
limit_test_snapshot().date,
&mut account,
&data,
&submit,
Some(time(0))
)
.is_err()
);
assert_eq!(account.cash(), 20_000.);
assert_eq!(
step(&broker, &mut account, &data, 0, &submit).fill_events[0].quantity,
100
);
}
@@ -0,0 +1,515 @@
// Kept inside broker::tests to inspect internal accepted-order identity as
// well as the public report. These are simulator states, never GT requests.
fn recovery_test_data(missing_previous: Option<usize>, intraday: bool) -> DataSet {
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut instruments = Vec::new();
let mut rows = Vec::new();
let mut candidates = Vec::new();
let mut quotes = Vec::new();
for index in 1..=2 {
let symbol = format!("{index:06}.SZ");
let mut instrument = limit_test_instrument();
instrument.symbol = symbol.clone();
instruments.push(instrument);
for day in [previous, date] {
if day == previous && missing_previous == Some(index) {
continue;
}
let mut row = dated_limit_test_snapshot(day);
row.symbol = symbol.clone().into();
rows.push(row);
let mut candidate = dated_limit_test_candidate(day, false, false, true, true);
candidate.symbol = symbol.clone().into();
candidates.push(candidate);
}
if intraday {
let mut quote = limit_test_quote(10., 10., 10.);
quote.symbol = symbol;
quote.date = date;
quote.timestamp = date.and_hms_opt(9, 33, 0).unwrap();
quotes.push(quote);
}
}
DataSet::from_components_with_actions_and_quotes(
instruments,
rows,
vec![],
candidates,
vec![
dated_limit_test_benchmark(previous),
dated_limit_test_benchmark(date),
],
vec![],
quotes,
)
.unwrap()
}
fn recovery_test_broker(
intraday: bool,
first_side: OrderSide,
) -> (
BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
PortfolioState,
) {
let mut broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(if intraday {
MatchingType::MinuteLast
} else {
MatchingType::CurrentBarClose
})
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(
super::DynamicSlippageConfig::new(0., 0., 0.1),
));
if intraday {
broker =
broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 33, 0).unwrap());
}
let mut first = test_open_order(1);
first.filled_quantity = 100;
first.remaining_quantity = 100;
first.commission_remaining = Some(0.);
first.side = first_side;
let mut second = test_open_order(2);
second.symbol = "000002.SZ".into();
broker.upsert_open_order(first);
broker.upsert_open_order(second);
broker.next_order_id.set(3);
let mut account = PortfolioState::new(9000.);
account.position_mut("000001.SZ").buy(
chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
if first_side == OrderSide::Buy {
100
} else {
200
},
10.,
);
account.begin_trading_day();
(broker, account)
}
fn recovery_report_value(report: &BrokerExecutionReport) -> serde_json::Value {
serde_json::json!({"orders":report.order_events,"fills":report.fill_events,
"positions":report.position_events,"accounts":report.account_events,
"events":report.process_events,"diagnostics":report.diagnostics})
}
#[test]
fn failed_resting_order_batch_keeps_accepted_orders_and_unpublished_financial_state() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
for intraday in [false, true] {
for first_side in [OrderSide::Buy, OrderSide::Sell] {
for missing in [1, 2] {
let (broker, mut account) = recovery_test_broker(intraday, first_side);
let orders = format!("{:?}", broker.open_orders.borrow());
let ledger = account.financial_replay_identity();
let error = broker
.execute(
date,
&mut account,
&recovery_test_data(Some(missing), intraday),
&StrategyDecision::default(),
)
.unwrap_err();
assert!(
error
.to_string()
.contains("historical_slippage_calibration_missing")
);
assert_eq!(
format!("{:?}", broker.open_orders.borrow()),
orders,
"intraday={intraday} first={first_side:?} missing={missing}"
);
assert_eq!(account.financial_replay_identity(), ledger);
assert!(broker.same_day_sold_symbols.borrow().is_empty());
let recovered = broker
.execute(
date,
&mut account,
&recovery_test_data(None, intraday),
&StrategyDecision::default(),
)
.unwrap();
let (clean, mut clean_account) = recovery_test_broker(intraday, first_side);
let reference = clean
.execute(
date,
&mut clean_account,
&recovery_test_data(None, intraday),
&StrategyDecision::default(),
)
.unwrap();
assert_eq!(
recovery_report_value(&recovered),
recovery_report_value(&reference)
);
assert_eq!(
account.financial_replay_identity(),
clean_account.financial_replay_identity()
);
assert!(broker.open_order_views().is_empty());
assert_eq!(recovered.fill_events.len(), 2);
}
}
}
}
#[test]
fn failed_new_batch_does_not_erase_prior_success_or_double_charge_on_retry() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let (broker, mut account) = recovery_test_broker(true, OrderSide::Buy);
let good = recovery_test_data(None, true);
let prior = broker
.execute(date, &mut account, &good, &StrategyDecision::default())
.unwrap();
assert_eq!(prior.fill_events.len(), 2);
let initial = account.financial_replay_identity();
let id = broker.next_order_id.get();
let decision = StrategyDecision {
order_intents: vec![
OrderIntent::Shares {
symbol: "000001.SZ".into(),
quantity: 100,
reason: "next-batch-a".into(),
},
OrderIntent::Shares {
symbol: "000002.SZ".into(),
quantity: 100,
reason: "next-batch-b".into(),
},
],
..Default::default()
};
// A later quote lets this batch execute independently of the prior fills.
let mut parts = good.snapshot_components();
for quote in &mut parts.execution_quotes {
quote.timestamp += chrono::Duration::minutes(1);
}
broker
.runtime_execution_clock
.set(Some(NaiveTime::from_hms_opt(9, 34, 0).unwrap()));
let restored = DataSet::from_components_with_actions_and_quotes(
parts.instruments.clone(),
parts.market.clone(),
parts.factors.clone(),
parts.candidates.clone(),
parts.benchmarks.clone(),
vec![],
parts.execution_quotes.clone(),
)
.unwrap();
parts
.market
.retain(|row| !(row.symbol.as_str() == "000002.SZ" && row.date < date));
let broken = DataSet::from_components_with_actions_and_quotes(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
vec![],
parts.execution_quotes,
)
.unwrap();
assert!(
broker
.execute(date, &mut account, &broken, &decision)
.is_err()
);
assert_eq!(account.financial_replay_identity(), initial);
assert_eq!(broker.next_order_id.get(), id);
assert!(broker.open_orders.borrow().is_empty());
let result = broker
.execute(date, &mut account, &restored, &decision)
.unwrap();
assert_eq!(result.fill_events.len(), 2);
assert_eq!(result.fill_events[0].order_id, Some(id));
assert_eq!(result.fill_events[1].order_id, Some(id + 1));
assert_eq!(account.position("000001.SZ").unwrap().quantity, 300);
assert_eq!(account.position("000002.SZ").unwrap().quantity, 300);
assert_eq!(
prior.fill_events.len(),
2,
"previously returned report remains intact"
);
}
#[test]
fn unwinding_an_unpublished_simulator_transaction_restores_its_state() {
let (broker, mut account) = recovery_test_broker(false, OrderSide::Sell);
let initial = account.financial_replay_identity();
let orders = format!("{:?}", broker.open_orders.borrow());
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = broker.execution_transaction(&mut account, true, None, |account| {
account.apply_cash_delta(500.).unwrap();
broker.open_orders.borrow_mut().clear();
panic!("isolated simulator callback unwind");
});
}));
assert!(result.is_err());
assert_eq!(account.financial_replay_identity(), initial);
assert_eq!(format!("{:?}", broker.open_orders.borrow()), orders);
assert_eq!(broker.execution_transaction_depth.get(), 0);
}
#[test]
fn deferred_etf_batch_failure_keeps_both_targets_and_prior_generation_progress() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut parts = recovery_test_data(None, false).snapshot_components();
for instrument in &mut parts.instruments {
instrument.board = "ETF".into();
}
let good = DataSet::from_components_with_actions_and_quotes(
parts.instruments.clone(),
parts.market.clone(),
parts.factors.clone(),
parts.candidates.clone(),
parts.benchmarks.clone(),
vec![],
vec![],
)
.unwrap();
parts
.market
.retain(|row| !(row.date == date && row.symbol.as_str() == "000002.SZ"));
let bad = DataSet::from_components_with_actions_and_quotes(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
vec![],
vec![],
)
.unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(false)
.with_liquidity_limit(false);
let members = std::sync::Arc::new(
(1..=2)
.map(|index| crate::stock_pool_execution::StockPoolMemberSpec {
symbol: format!("{index:06}.SZ"),
requested_order: index,
recommendation_reason: String::new(),
target_weight_bps: None,
stop_loss: None,
take_profit: None,
})
.collect::<Vec<_>>(),
);
broker
.deferred_etf_targets
.borrow_mut()
.replace_generation("pool", "latest");
for index in 1..=2 {
broker
.deferred_etf_targets
.borrow_mut()
.upsert(crate::etf_execution::DeferredEtfTarget {
pool_id: "pool".into(),
generation: "latest".into(),
symbol: format!("{index:06}.SZ"),
signal_date: previous,
signal_at: previous.and_hms_opt(13, 0, 0).unwrap(),
execute_on: Some(date),
target_value: 1000.into(),
target_weight_bps: 5000,
side: crate::stock_pool_execution::OrderSide::Buy,
max_positions: 2,
rule: Default::default(),
members: std::sync::Arc::clone(&members),
reason: "deferred recovery fixture".into(),
});
}
let queue = format!("{:?}", broker.deferred_etf_targets.borrow());
let mut account = PortfolioState::new(10000.);
let state = account.stock_pool_execution_state("pool");
assert!(
broker
.execute_deferred_etf_targets(date, &mut account, &bad)
.is_err()
);
assert_eq!(account.cash(), 10000.);
assert!(account.positions().is_empty());
assert_eq!(account.stock_pool_execution_state("pool"), state);
assert_eq!(format!("{:?}", broker.deferred_etf_targets.borrow()), queue);
assert_eq!(broker.next_order_id.get(), 1);
assert_eq!(broker.execution_transaction_depth.get(), 0);
let result = broker
.execute_deferred_etf_targets(date, &mut account, &good)
.unwrap();
assert_eq!(result.fill_events.len(), 2, "{result:?}");
assert_eq!(broker.pending_etf_target_count(), 0);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
assert_eq!(account.position("000002.SZ").unwrap().quantity, 100);
}
#[test]
fn public_callback_unwind_does_not_leak_order_context_or_authoritative_prior_state() {
struct PanicRules;
impl crate::rules::EquityRuleHooks for PanicRules {
fn can_buy(
&self,
_: chrono::NaiveDate,
_: &DailyMarketSnapshot,
_: &CandidateEligibility,
_: PriceField,
) -> crate::rules::RuleCheck {
panic!("isolated rule callback panic")
}
fn can_sell(
&self,
_: chrono::NaiveDate,
_: &DailyMarketSnapshot,
_: &CandidateEligibility,
_: &crate::portfolio::Position,
_: PriceField,
) -> crate::rules::RuleCheck {
unreachable!()
}
}
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let prior = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), PanicRules)
.with_volume_limit(false)
.with_liquidity_limit(false);
broker.runtime_decision_date.set(Some(prior));
broker
.runtime_buy_denials
.borrow_mut()
.insert("unrelated".into(), "prior".into());
let mut account = PortfolioState::new(10000.);
let decision = StrategyDecision {
buy_denials: BTreeMap::from([("another".into(), "temporary".into())]),
order_intents: vec![
OrderIntent::LimitShares {
symbol: "000001.SZ".into(),
quantity: 100,
limit_price: 10.,
reason: "panic fixture".into(),
}
.with_time_in_force(OrderTimeInForce::Gtc),
],
..Default::default()
};
assert!(
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = broker.execute(
date,
&mut account,
&recovery_test_data(None, false),
&decision,
);
}))
.is_err()
);
assert_eq!(account.cash(), 10000.);
assert!(account.positions().is_empty());
assert!(broker.open_order_views().is_empty());
assert_eq!(broker.runtime_decision_date.get(), Some(prior));
assert_eq!(
*broker.runtime_buy_denials.borrow(),
BTreeMap::from([("unrelated".into(), "prior".into())])
);
assert_eq!(broker.runtime_time_in_force.get(), None);
assert_eq!(broker.runtime_target_position_limit.get(), None);
assert_eq!(broker.execution_transaction_depth.get(), 0);
}
#[test]
fn simulator_transaction_profile_preserves_successful_output() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut instruments = Vec::new();
let mut market = Vec::new();
let mut candidates = Vec::new();
for index in 1..=30 {
let symbol = format!("{index:06}.SZ");
let mut instrument = limit_test_instrument();
instrument.symbol = symbol.clone();
instruments.push(instrument);
for day in [previous, date] {
let mut row = dated_limit_test_snapshot(day);
row.symbol = symbol.clone().into();
market.push(row);
let mut row = dated_limit_test_candidate(day, false, false, true, true);
row.symbol = symbol.clone().into();
candidates.push(row);
}
}
let data = DataSet::from_components(
instruments,
market,
vec![],
candidates,
vec![
dated_limit_test_benchmark(previous),
dated_limit_test_benchmark(date),
],
)
.unwrap();
let mut reference = None;
let mut samples = Vec::new();
for protected in [false, true, true, false] {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose)
.with_volume_limit(false)
.with_liquidity_limit(false);
// Private comparison only: no runtime option can disable protection.
if !protected {
broker.execution_transaction_depth.set(1);
}
let mut account = PortfolioState::new(10_000_000.);
for index in 1..=30 {
for _ in 0..20 {
account
.position_mut(&format!("{index:06}.SZ"))
.buy(previous, 100, 10.);
}
}
account.begin_trading_day();
let mut orders = Vec::new();
let mut fills = Vec::new();
let start = std::time::Instant::now();
for index in 0..500 {
let report = broker
.execute(
date,
&mut account,
&data,
&StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: format!("{:06}.SZ", index % 30 + 1),
quantity: 100,
reason: "transaction profile".into(),
}],
..Default::default()
},
)
.unwrap();
orders.extend(report.order_events);
fills.extend(report.fill_events);
}
samples.push(
serde_json::json!({"protected":protected,"microseconds":start.elapsed().as_micros()}),
);
assert_eq!(fills.len(), 500);
let outcome = serde_json::json!({"orders":orders,"fills":fills,"ledger":account.financial_replay_identity()});
if let Some(reference) = &reference {
assert_eq!(&outcome, reference);
} else {
reference = Some(outcome);
}
}
println!(
"simulator_transaction_profile={}",
serde_json::json!({"securities":30,"initial_lots_per_security":20,"calls":500,"samples":samples,
"scope":"isolated broker only; not Source or full backtest throughput"})
);
}
+856
View File
@@ -0,0 +1,856 @@
//! 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};
use chrono::Timelike;
#[derive(Debug, Clone)]
pub(super) struct DeferredStockPoolExecution {
date: NaiveDate,
contract: Box<pool::FrozenStockPoolIntent>,
buy_only: bool,
symbols: BTreeSet<String>,
initial_holdings: BTreeSet<String>,
}
impl<C, R> BrokerSimulator<C, R> {
pub(crate) fn pending_stock_pool_symbols(&self) -> BTreeSet<String> {
self.deferred_stock_pools.borrow().values().flat_map(|pending| pending.symbols.iter().cloned()).collect()
}
pub(crate) fn has_pending_stock_pool_execution(&self) -> bool {
!self.deferred_stock_pools.borrow().is_empty()
}
pub(crate) fn finish_stock_pool_session(&self, date: NaiveDate, report: &mut BrokerExecutionReport) {
self.deferred_stock_pools.borrow_mut().retain(|_, pending| {
if pending.date <= date {
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
false
} else { true }
});
}
}
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 etf_activity(report:&mut BrokerExecutionReport,date:NaiveDate,symbol:&str,side:pool::OrderSide,detail:String) {
report.process_events.push(ProcessEvent {date,kind:ProcessEventKind::EtfExecutionFallback,order_id:None,
symbol:Some(symbol.into()),side:Some(if side==pool::OrderSide::Buy {OrderSide::Buy} else {OrderSide::Sell}),detail});
}
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()
}
#[cfg(test)]
mod successor_protection_tests {
use super::*;
use crate::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection};
fn day(n: u32) -> NaiveDate { NaiveDate::from_ymd_opt(2026, 9, n).unwrap() }
#[test]
fn deferred_etf_open_rechecks_inherited_locks_and_cooldown_before_any_order() {
let old = "159915.SZ";
let new = "159999.SZ";
let data = DataSet::from_components(
[old, new].into_iter().map(|symbol| crate::Instrument {
symbol: symbol.into(), name: "isolated ETF fixture".into(), board: "ETF".into(), round_lot: 100,
listed_at: Some(day(1)), delisted_at: None, status: "active".into(),
}).collect(), vec![crate::DailyMarketSnapshot {
date: day(15), symbol: new.into(), timestamp: None, day_open: 5., open: 5., high: 5., low: 5.,
close: 5., last_price: 5., bid1: 5., ask1: 5., prev_close: 5., volume: 100000,
minute_volume: 0, bid1_volume: 100000, ask1_volume: 100000, trading_phase: None,
paused: false, upper_limit: 5.5, lower_limit: 4.5, price_tick: 0.001,
}], vec![], vec![crate::CandidateEligibility {
date: day(15), symbol: new.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,
}], [11,14,15].into_iter().map(|n| crate::BenchmarkSnapshot {
date: day(n), benchmark: "000300.SH".into(), open: 100., close: 100., prev_close: 100., volume: 10000,
}).collect()).unwrap();
for mode in ["lock", "cooldown", "expired"] {
let broker = BrokerSimulator::new(crate::ChinaAShareCostModel::default(), crate::ChinaEquityRuleHooks)
.with_volume_limit(false).with_liquidity_limit(false);
let mut portfolio = PortfolioState::new(10000.);
portfolio.position_mut(old).buy(day(11), 200, 10.);
portfolio.position_mut(old).sell(100, 10.).unwrap();
broker.mark_same_day_sold(day(11), old);
portfolio.apply_successor_conversion(old, new, 2., 0.).unwrap();
let policy = AutomaticTradeProtection {
sell_cooldown_days: if mode == "cooldown" { 3 } else { 0 },
locks: if mode != "cooldown" { vec![AutomaticTradeLock {
symbol: old.into(), start_date: day(11), end_date: Some(day(if mode == "expired" {14} else {15})),
}] } else { vec![] }, ..Default::default()
};
let rule = pool::StockPoolExecutionRule { automatic_trade_protection: policy, ..Default::default() };
broker.deferred_etf_targets.borrow_mut().replace_generation("pool", "latest");
broker.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
pool_id: "pool".into(), generation: "latest".into(), symbol: new.into(),
signal_date: day(14), signal_at: day(14).and_hms_opt(13,0,0).unwrap(), execute_on: Some(day(15)),
target_value: 5000.into(), target_weight_bps: 10000, side: pool::OrderSide::Buy, max_positions: 1,
rule: std::sync::Arc::new(rule), members: std::sync::Arc::new(vec![pool::StockPoolMemberSpec {
symbol: new.into(), requested_order: 0, recommendation_reason: String::new(),
target_weight_bps: None, stop_loss: None, take_profit: None,
}]), reason: "isolated deferred ETF target".into(),
});
let report = broker.execute_deferred_etf_targets(day(15), &mut portfolio, &data).unwrap();
if mode == "expired" {
assert_eq!(report.fill_events.len(), 1, "{report:?}");
assert_eq!(portfolio.position(new).unwrap().quantity, 1000);
} else {
assert!(report.order_events.is_empty(), "{mode}: {report:?}");
assert!(report.fill_events.is_empty());
assert_eq!(portfolio.position(new).unwrap().quantity, 200);
assert!(report.diagnostics.iter().any(|text| text.contains(if mode == "lock" {"automatic_trade_locked"} else {"sell_fill_cooldown"})));
}
}
}
}
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
fn pool_automatic_permission(&self, symbol: &str, date: NaiveDate,
policy: &crate::holding_policy::AutomaticTradeProtection,
portfolio: &PortfolioState, data: &DataSet)
-> Result<crate::holding_policy::AutomaticTradePermission, BacktestError> {
let position = portfolio.position(symbol).filter(|position| position.quantity > 0);
let sold = self.same_day_sold_symbols.borrow().iter().rev()
.find(|(day, symbols)| **day <= date && (symbols.contains(symbol)
|| portfolio.corporate_predecessors(symbol).any(|previous| symbols.contains(previous))))
.map(|(day, _)| *day);
let evidence = HoldingLifecycleEvidence {
has_position: position.is_some(), opened_date: position.and_then(|position| position.opened_date()),
last_buy_date: position.and_then(|position| position.last_buy_date()), last_sell_date: sold,
};
policy.evaluate_with_predecessors(symbol, date, &evidence, data.calendar(),
portfolio.corporate_predecessors(symbol)).map_err(BacktestError::Execution)
}
pub(super) fn resume_stock_pool_executions(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet,
session: &mut BrokerExecutionSession, report: &mut BrokerExecutionReport) -> Result<(), BacktestError> {
let clock = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time);
let mut expired = Vec::new();
for (id, pending) in self.deferred_stock_pools.borrow().iter() {
let end = NaiveTime::parse_from_str(&pending.contract.rule.window_end, "%H:%M")
.map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?;
if pending.date != date || clock.is_some_and(|clock| clock >= end) { expired.push(id.clone()); }
}
for id in expired {
if let Some(pending) = self.deferred_stock_pools.borrow_mut().remove(&id) {
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
}
}
if self.has_open_orders() || clock.is_none() { return Ok(()); }
let pending = std::mem::take(&mut *self.deferred_stock_pools.borrow_mut());
for (id, pending) in pending {
let now = clock.expect("clock checked above");
let start = NaiveTime::parse_from_str(&pending.contract.rule.window_start, "%H:%M")
.map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?;
if now < start || !pool::stock_pool_is_trading_minute(now.hour() * 60 + now.minute()) {
self.deferred_stock_pools.borrow_mut().insert(id, pending);
continue;
}
let prior_followup = self.runtime_stock_pool_followup.replace(true);
let prior_decision = self.runtime_decision_date.replace(Some(pending.contract.signal_date));
let prior_created = self.runtime_order_created_date.replace(Some(date));
let order_start = report.order_events.len();
let fill_start = report.fill_events.len();
report.diagnostics.push(format!("stock_pool_resume_after_order_reports generation={} clock={} cash={}",pending.contract.generation,clock.unwrap(),portfolio.cash()));
let result = self.process_stock_pool_contract_phase(date, portfolio, data, &pending.contract,
&mut session.intraday_turnover, &mut session.execution_cursors, &mut session.global_execution_cursor,
&mut session.commission_state, report, pending.buy_only, Some(&pending.initial_holdings));
self.runtime_stock_pool_followup.set(prior_followup);
self.runtime_decision_date.set(prior_decision);
self.runtime_order_created_date.set(prior_created);
result?;
Self::annotate_report_range(report, order_start, fill_start, pending.contract.signal_date, date, date);
}
Ok(())
}
fn pool_quote_inputs(
&self,
date: NaiveDate,
data: &DataSet,
symbols: &BTreeSet<String>,
execution_clock: Option<NaiveDateTime>,
cumulative_conditions: bool,
) -> Result<(Vec<pool::MarketSnapshot>, Vec<String>), BacktestError> {
let mut unavailable = Vec::new();
let quotes = 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 fallback = self.pool_etf_fallback_reference(date, data, symbol, execution_clock)?;
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if let Some(reference) = fallback {
let calibration = self.slippage_calibration(data, snapshot)?;
(reference.price, snapshot.prev_close, None, None, None, None,
self.quote_execution_price(snapshot, OrderSide::Buy, reference.price, None, calibration.as_ref())?,
self.quote_execution_price(snapshot, OrderSide::Sell, reference.price, None, calibration.as_ref())?)
} else 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_for_algo_request(None),
)
.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_for_algo_request(None),
)
.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(),
)?;
let totals = if cumulative_conditions {
match data.execution_session_totals(symbol, clock) {
Ok(totals) => Some(totals),
Err(reason) => { unavailable.push(reason); None }
}
} else { None };
(
quote.last_price,
snapshot.prev_close,
totals.map(|total| total.0),
totals.map(|total| total.1),
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;
let totals = if cumulative_conditions && !completed {
let at = execution_clock.unwrap_or_else(|| date.and_hms_opt(9,30,0).unwrap());
match data.execution_session_totals(symbol, at) {
Ok(totals) => Some(totals),
Err(reason) => { unavailable.push(reason); None }
}
} else { None };
let amount = if completed && cumulative_conditions {
data.factor(date, symbol).and_then(|row| row.extra_factors.get("amount")).copied()
.map(|value| decimal(value, "amount")).transpose()?
} else { totals.map(|total| total.1) };
(
price,
snapshot.prev_close,
if completed { Some(Decimal::from(snapshot.volume)) } else { totals.map(|total| total.0) },
amount,
None,
None,
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,
turnover: amount,
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::<Result<Vec<_>, BacktestError>>()?;
Ok((quotes, unavailable))
}
fn pool_etf_fallback_reference(&self, date: NaiveDate, data: &DataSet, symbol: &str, clock: Option<NaiveDateTime>) -> Result<Option<crate::etf_execution::EtfFallbackReference>, BacktestError> {
if !self.matching_type_uses_intraday_quotes() || !self.has_verified_etf_minute_absence(date, symbol) {
return Ok(None);
}
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time)
.ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: execution clock missing".into()))?;
let at = clock.unwrap_or(date.and_time(time)).max(date.and_time(time));
crate::etf_execution::reference(data, symbol, at).map(Some)
}
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> {
self.process_stock_pool_contract_phase(date, portfolio, data, contract, intraday_turnover,
execution_cursors, global_execution_cursor, commission_state, report, false, None)
}
fn process_stock_pool_contract_phase(
&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, buy_only: bool,
initial_holdings: Option<&BTreeSet<String>>,
) -> 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 before_positions = initial_holdings.cloned().unwrap_or_else(|| portfolio.positions().keys().cloned().collect());
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)?;
let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation);
if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); }
if self.has_open_orders() {
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
date, contract: Box::new(contract.clone()), buy_only, symbols: scope, initial_holdings: before_positions,
});
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());
}
}
// All delayed symbols in a generation share immutable configuration.
// Do not duplicate an N-member pool N times in a large mixed pool.
let mut deferred_configuration = None;
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
if buy_only && side == pool::OrderSide::Sell { continue; }
if side == pool::OrderSide::Buy && self.has_open_orders()
&& self.effective_rebalance_cash_mode() == RebalanceCashMode::SellThenBuy {
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
date, contract: Box::new(contract.clone()), buy_only: true, symbols: quote_scope.clone(), initial_holdings: before_positions.clone(),
});
report.diagnostics.push(format!("stock_pool_waiting_for_sell_reports generation={} no_buy_order_created=true",contract.generation));
break;
}
let mut fallback_references = BTreeMap::new();
for symbol in &quote_scope {
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
let condition = if side == pool::OrderSide::Buy { &contract.rule.buy_condition } else { &contract.rule.sell_condition };
if !condition.trim().is_empty() {
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: intraday condition evidence unavailable symbol={symbol} side={side:?}; daily reference is not a minute or tick signal")));
}
fallback_references.insert(symbol.clone(), reference);
}
}
let (quotes, unavailable) = self.pool_quote_inputs(date, data, &quote_scope, *global_execution_cursor,
crate::stock_pool_quote_facts::requires_session_totals(&contract.rule))?;
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.position_action_bases = execution_state.position_action_bases_for(&contract.generation);
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 permission = self.pool_automatic_permission(symbol, date,
&contract.rule.automatic_trade_protection, portfolio, data)?;
constraints
.automatic_permissions
.insert(symbol.clone(), permission);
}
}
if side == pool::OrderSide::Buy {
for (symbol, reference) in &fallback_references {
if !reference.immediate {
// The sell leg was queued, not filled. Keep its real
// holdings/slots and do not finance buys with proceeds
// from the following session.
constraints.automatic_permissions.entry(symbol.clone()).or_default()
.sell_denial.get_or_insert("etf_daily_open_deferred");
}
}
}
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(|error| BacktestError::Execution(if !unavailable.is_empty()
&& (error.contains("requires volume") || error.contains("requires amount")) {
format!("{error}; {}", unavailable.join("; "))
} else { error }))?;
report.diagnostics.extend(unavailable.into_iter().map(|reason| format!("stock_pool_quote_fact_unavailable {reason}")));
let mut updated = execution_state
.record_plan(contract.signal_date, &contract.generation, &plan)
.map_err(BacktestError::Execution)?;
for (symbol, reference) in &fallback_references {
if !reference.immediate && let Some(entry) = updated.entries.get_mut(symbol) {
// The signal only fixes money, not shares at a stale close.
entry.completion_quantity = None;
}
}
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 let Some(reference) = fallback_references.get(&row.symbol) {
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time).expect("fallback clock validated");
let at = global_execution_cursor.unwrap_or(date.and_time(time)).max(date.and_time(time));
if !reference.immediate {
report.diagnostics.push(format!("etf_daily_open_fallback:deferred symbol={} signal_at={at} reference_date={} reference_price={} target_value={} execute_on={:?}", row.symbol, reference.reference_date, reference.price, row.target_value, reference.execute_on));
let deferred = deferred_configuration.get_or_insert_with(|| (
std::sync::Arc::new(contract.rule.clone()), std::sync::Arc::new(members.clone()),
));
let opening_date=reference.execute_on.map(|day|day.to_string()).unwrap_or_else(||"回测区间外(后续日历未加载)".into());
etf_activity(report,date,&row.symbol,side,format!("ETF 顺延执行:信号 {at},参考 {} 收盘 {},目标金额 {},下一正式开盘日 {opening_date};未生成成交。",reference.reference_date,reference.price,row.target_value));
self.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
pool_id:contract.pool_id.clone(), generation:contract.generation.clone(), symbol:row.symbol.clone(),
signal_date:contract.signal_date, signal_at:at, execute_on:reference.execute_on,
target_value:row.target_value, target_weight_bps:row.target_weight_bps, side,
max_positions, rule:std::sync::Arc::clone(&deferred.0), members:std::sync::Arc::clone(&deferred.1),
reason:row.source_intent.clone().unwrap_or_else(||"stock_pool_target".into()),
});
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 fallback_reason = fallback_references.contains_key(&row.symbol).then(|| format!("{}: etf_daily_open_fallback signal_date={} execution_date={date}", row.source_intent.as_deref().unwrap_or("stock_pool_target"), contract.signal_date));
let reason = fallback_reason.as_deref().unwrap_or_else(|| row.source_intent.as_deref().unwrap_or("stock_pool_target"));
let first_fill = report.fill_events.len();
if fallback_references.contains_key(&row.symbol) {
report.diagnostics.push(format!("etf_daily_open_fallback:opening symbol={} signal_date={} execution_date={date}", row.symbol, contract.signal_date));
etf_activity(report,date,&row.symbol,side,format!("ETF 日线开盘回退:信号日 {},执行日 {date},使用正式日线开盘价;不是分钟成交行情。",contract.signal_date));
}
let mut execute = || 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,
)
};
if fallback_references.contains_key(&row.symbol) {
self.with_etf_daily_open(execute)?;
for fill in &mut report.fill_events[first_fill..] {
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
}
} else { execute()?; }
}
}
Ok(())
}
pub(crate) fn pending_etf_target_count(&self) -> usize {
self.deferred_etf_targets.borrow().len()
}
/// Called at the opening clock, after settlement/corporate actions and
/// auction callbacks. It never sends a stock order or replays a strategy.
pub(crate) fn execute_deferred_etf_targets(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
self.execution_transaction(portfolio, !self.has_open_orders() && self.pending_etf_target_count() > 0, None,
|portfolio| self.execute_deferred_etf_targets_inner(date, portfolio, data))
}
fn execute_deferred_etf_targets_inner(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
if self.has_open_orders() {
if self.pending_etf_target_count() > 0 {
report.diagnostics.push("etf_daily_open_fallback:waiting_for_active_orders".into());
}
return Ok(report);
}
let due = self.deferred_etf_targets.borrow_mut().take_due(date);
let dates = data.calendar().iter().collect::<Vec<_>>();
for target in due {
let instrument = data.instrument(&target.symbol).ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: instrument identity missing at execution".into()))?;
if !instrument.is_exchange_traded_fund() { return Err(BacktestError::Execution("etf_daily_open_fallback: instrument identity changed".into())); }
if let Some(reason) = instrument.dated_market_absence_reason(date) {
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} date={date} reason={reason}", target.symbol));
continue;
}
let snapshot = data.market(date, &target.symbol).ok_or_else(|| BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_missing symbol={} date={date}", target.symbol)))?;
if !snapshot.open.is_finite() || snapshot.open <= 0.0 {
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_invalid symbol={} date={date}", target.symbol)));
}
let position = portfolio.position(&target.symbol).filter(|p| p.quantity > 0);
let before_quantity = position.map_or(0, |p| p.quantity);
let permission = self.pool_automatic_permission(&target.symbol, date,
&target.rule.automatic_trade_protection, portfolio, data)?;
let denial = if target.side == pool::OrderSide::Buy {
permission.buy_denial.or(permission.max_holding_exit.then_some("max_holding_exit_pending"))
} else { permission.sell_denial };
if let Some(denial) = denial {
report.diagnostics.push(format!("etf_daily_open_fallback:protected symbol={} date={date} reason={denial}", target.symbol));
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标受持有保护限制:{denial};未提交委托。"));
continue;
}
if target.side == pool::OrderSide::Buy && before_quantity == 0 && Self::positive_position_count(portfolio) >= target.max_positions {
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} reason=occupied_position_slots", target.symbol));
continue;
}
let value = target.target_value.to_f64().ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: target value out of range".into()))?;
let current_value = snapshot.open * f64::from(before_quantity);
let satisfied = (target.side == pool::OrderSide::Buy && value <= current_value)
|| (target.side == pool::OrderSide::Sell && value >= current_value);
let reason = format!("{}: etf_daily_open_fallback signal_at={} execution_at={} target_value={}", target.reason, target.signal_at, date.and_time(crate::etf_execution::opening_time()), target.target_value);
let mut sub = BrokerExecutionReport::default();
if !satisfied {
let (_, limit) = pool::resolve_stock_pool_order_price(&target.rule, &target.symbol, decimal(snapshot.open, "etf_open")?, target.side, decimal(snapshot.price_tick, "etf_tick")?).map_err(BacktestError::Execution)?;
let intent = match limit {
Some(limit) => OrderIntent::LimitTargetValue { symbol:target.symbol.clone(), target_value:value, limit_price:limit.to_f64().ok_or_else(|| BacktestError::Execution("ETF limit out of range".into()))?, reason:reason.clone() },
None => OrderIntent::TargetValue { symbol:target.symbol.clone(), target_value:value, reason:reason.clone() },
};
let old_time = self.runtime_intraday_start_time.replace(Some(crate::etf_execution::opening_time()));
let old_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin { created_date:Some(target.signal_at.date()), submission_time:Some(target.signal_at.time()), accepted_date:date }));
let outcome = self.with_etf_daily_open(|| self.execute_with_event_dates(date, target.signal_date, target.signal_at.date(), portfolio, data, &StrategyDecision {
order_intents:vec![OrderIntent::WithTimeInForce { intent:Box::new(intent), time_in_force:OrderTimeInForce::Day }], ..Default::default()
}));
self.runtime_intraday_start_time.set(old_time);
self.runtime_resting_order_origin.set(old_origin);
sub = outcome?;
}
// The actual open determines the full requested shares. A clipped
// or rejected execution must not be recorded as completed entry.
let order = sub.order_events.iter().rev().find(|order| order.symbol == target.symbol);
let goal_quantity = order.map_or(before_quantity, |order| match order.side {
OrderSide::Buy => before_quantity.saturating_add(order.requested_quantity),
OrderSide::Sell => before_quantity.saturating_sub(order.requested_quantity),
});
let status = if satisfied || (order.is_none() && !self.has_open_orders()) { "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED" } else { "READY" };
let positions = pool_positions(portfolio, date)?;
let state = portfolio.stock_pool_execution_state(&target.pool_id)
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?
.record_targets(target.signal_date, &target.generation, [crate::stock_pool_state::StockPoolGoalObservation {
symbol:&target.symbol, target_weight_bps:target.target_weight_bps, target_value:target.target_value,
current_quantity:before_quantity.into(), target_quantity:goal_quantity.into(), status,
}]).map_err(BacktestError::Execution)?
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?;
portfolio.set_stock_pool_execution_state(&target.pool_id, state).map_err(BacktestError::Execution)?;
for fill in &mut sub.fill_events {
fill.decision_date.get_or_insert(target.signal_date);
fill.order_created_date.get_or_insert(target.signal_at.date());
fill.execution_date.get_or_insert(date);
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
}
for order in &mut sub.order_events {
order.decision_date.get_or_insert(target.signal_date);
order.order_created_date.get_or_insert(target.signal_at.date());
order.execution_date.get_or_insert(date);
}
report.diagnostics.push(reason);
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标开盘处理:原信号 {},本次 {date} 09:30,冻结目标金额 {},持仓 {before_quantity}{};按本日开盘价、资金与风控重新定量。",target.signal_at,target.target_value,portfolio.position(&target.symbol).map_or(0,|position|position.quantity)));
report.order_events.extend(sub.order_events);
report.fill_events.extend(sub.fill_events);
report.position_events.extend(sub.position_events);
report.account_events.extend(sub.account_events);
report.process_events.extend(sub.process_events);
report.diagnostics.extend(sub.diagnostics);
}
Ok(report)
}
}
@@ -0,0 +1,684 @@
fn pool_batch_data() -> DataSet {
pool_batch_data_with(|_| true)
}
fn pool_batch_data_with(change: impl Fn(&mut IntradayExecutionQuote) -> bool) -> DataSet {
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
let instruments = symbols
.iter()
.map(|symbol| Instrument {
symbol: (*symbol).into(),
..limit_test_instrument()
})
.collect();
let snapshots = symbols
.iter()
.map(|symbol| DailyMarketSnapshot {
symbol: (*symbol).into(),
..limit_test_snapshot()
})
.collect();
let candidates = symbols
.iter()
.map(|symbol| CandidateEligibility {
symbol: (*symbol).into(),
..limit_test_candidate(true, true)
})
.collect();
let mut quotes = Vec::new();
for minute in [30, 31, 32, 33, 34, 36] {
for symbol in symbols {
let price = if symbol == "000001.SZ" && minute > 30 {
10.5
} else {
10.0
};
let mut quote = limit_test_quote(price, price, price);
quote.symbol = symbol.into();
quote.timestamp = quote.date.and_hms_opt(9, minute, 0).unwrap();
quote.volume_delta = 200;
quote.bid1_volume = 200;
quote.ask1_volume = 200;
quote.amount_delta = price * 200.0;
if change(&mut quote) {
quotes.push(quote);
}
}
}
DataSet::from_components_with_actions_and_quotes(
instruments,
snapshots,
Vec::new(),
candidates,
vec![limit_test_benchmark()],
Vec::new(),
quotes,
)
.unwrap()
.with_additional_trading_dates([chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()])
}
fn pool_batch_decision(symbol: &str, generation: &str, end: &str) -> StrategyDecision {
use crate::stock_pool_execution as pool;
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let symbols = vec![symbol.to_owned()];
let rule = pool::StockPoolExecutionRule {
pricing_mode: pool::POOL_PRICE_FIXED_LIMIT.into(),
fixed_prices: [
("000001.SZ".into(), rust_decimal::Decimal::new(104, 1)),
("000002.SZ".into(), 10.into()),
("000003.SZ".into(), 10.into()),
]
.into(),
window_end: end.into(),
..Default::default()
};
StrategyDecision {
order_intents: vec![OrderIntent::StockPool {
contract: Box::new(pool::FrozenStockPoolIntent {
pool_id: "batch-test".into(),
signal_date: signal,
frozen_equity: 2000.into(),
selection: pool::StockPoolSelection {
trade_date: signal,
requested_symbols: symbols.clone(),
normal_trading_symbols: symbols.clone(),
risk_eligible_symbols: symbols.clone(),
final_symbols: symbols,
exclusion_reasons: Default::default(),
inherited_from_generation: None,
explicit_empty: false,
generation: Some(generation.into()),
},
members: vec![pool::StockPoolMemberSpec {
symbol: symbol.into(),
recommendation_reason: String::new(),
requested_order: 0,
target_weight_bps: None,
stop_loss: None,
take_profit: None,
}],
rule,
constraints: pool::StockPoolDecisionConstraints {
target_holding_count: Some(1),
..Default::default()
},
invest_ratio_bps: 10000,
reserve_cash: 0.into(),
out_of_pool_policy: "reduce_to_zero_when_sellable".into(),
generation: generation.into(),
}),
}],
..Default::default()
}
}
fn pool_batch_broker(partial: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
let cost = ChinaAShareCostModel::from_trading_constraints(
crate::risk_control::TradingConstraintConfig {
commission_rate: 0.0,
minimum_commission: 0.0,
stamp_tax_rate_before_change: 0.0,
stamp_tax_rate_after_change: 0.0,
transfer_fee_rate: 0.0,
..Default::default()
},
);
let broker =
BrokerSimulator::new_with_execution_price(cost, ChinaEquityRuleHooks, PriceField::Open)
.with_matching_type(if partial {
MatchingType::MinuteLast
} else {
MatchingType::NextBarOpen
})
.with_volume_limit(partial)
.with_volume_percent(0.5)
.with_liquidity_limit(false)
.with_inactive_limit(false);
if partial {
broker
.with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap())
} else {
broker
}
}
fn pool_batch_account() -> PortfolioState {
let mut account = PortfolioState::new(0.0);
account.position_mut("000001.SZ").buy(
chrono::NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(),
200,
10.0,
);
account
}
fn pool_batch_tick(
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
account: &mut PortfolioState,
data: &DataSet,
minute: u32,
decision: &StrategyDecision,
) -> BrokerExecutionReport {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
broker
.runtime_intraday_start_time
.set(Some(chrono::NaiveTime::from_hms_opt(9, minute, 0).unwrap()));
broker
.runtime_intraday_end_time
.set(Some(chrono::NaiveTime::from_hms_opt(9, minute, 0).unwrap()));
broker.execute(date, account, data, decision).unwrap()
}
#[test]
fn stock_pool_pending_sell_continues_buy_after_actual_fill_without_strategy_rerun() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(false);
let mut account = pool_batch_account();
let initial = broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "first", "09:35"),
)
.unwrap();
assert!(initial.fill_events.is_empty());
assert_eq!(broker.open_order_views().len(), 1);
assert_eq!(broker.open_order_views()[0].side, OrderSide::Sell);
let done = pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
assert!(
done.fill_events
.iter()
.any(|fill| fill.symbol == "000001.SZ" && fill.side == OrderSide::Sell)
);
assert_eq!(
account.position("000002.SZ").map(|p| p.quantity),
Some(200),
"sell proceeds must trigger the retained buy phase: {:?}",
done.diagnostics
);
assert!(
account
.position("000001.SZ")
.is_none_or(|p| p.quantity == 0)
);
let repeated = pool_batch_tick(
&broker,
&mut account,
&data,
32,
&StrategyDecision::default(),
);
assert!(repeated.order_events.is_empty() && repeated.fill_events.is_empty());
}
#[test]
fn stock_pool_partial_sell_waits_for_the_whole_batch_and_never_reissues_buys() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(true);
let mut account = pool_batch_account();
broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "partial", "09:35"),
)
.unwrap();
let first = pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
assert!(account.position("000002.SZ").is_none());
assert!(
first
.order_events
.iter()
.all(|event| event.side == OrderSide::Sell)
);
let second = pool_batch_tick(
&broker,
&mut account,
&data,
32,
&StrategyDecision::default(),
);
let third = pool_batch_tick(
&broker,
&mut account,
&data,
33,
&StrategyDecision::default(),
);
assert_eq!(account.position("000002.SZ").unwrap().quantity, 200);
let ids = second
.order_events
.iter()
.chain(&third.order_events)
.filter(|event| event.side == OrderSide::Buy)
.filter_map(|event| event.order_id)
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
ids.len(),
1,
"one buy intention; partial reports must keep its ID"
);
assert!(
pool_batch_tick(
&broker,
&mut account,
&data,
34,
&StrategyDecision::default()
)
.order_events
.is_empty()
);
}
#[test]
fn stock_pool_delayed_sell_does_not_start_buys_after_the_configured_window() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(true);
let mut account = pool_batch_account();
broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "expired", "09:32"),
)
.unwrap();
pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
let last = pool_batch_tick(
&broker,
&mut account,
&data,
32,
&StrategyDecision::default(),
);
assert!(account.position("000002.SZ").is_none());
assert!(
last.order_events
.iter()
.all(|event| event.side == OrderSide::Sell)
);
assert!(
last.diagnostics
.iter()
.any(|event| event.contains("unsubmitted_phase_expired"))
);
assert!(!broker.has_pending_stock_pool_execution());
}
#[test]
fn stock_pool_new_signal_supersedes_the_unsubmitted_buy_phase() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(true);
let mut account = pool_batch_account();
broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "old", "09:35"),
)
.unwrap();
pool_batch_tick(
&broker,
&mut account,
&data,
31,
&pool_batch_decision("000003.SZ", "new", "09:35"),
);
pool_batch_tick(
&broker,
&mut account,
&data,
32,
&StrategyDecision::default(),
);
pool_batch_tick(
&broker,
&mut account,
&data,
33,
&StrategyDecision::default(),
);
assert!(account.position("000002.SZ").is_none());
assert_eq!(account.position("000003.SZ").unwrap().quantity, 200);
assert!(!broker.has_pending_stock_pool_execution());
}
#[test]
fn stock_pool_after_sell_uses_fresh_quotes_and_actual_submission_clock() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data_with(|quote| {
if quote.symbol == "000002.SZ" {
quote.last_price = 10.2;
quote.bid1 = 10.2;
quote.ask1 = 10.2;
quote.amount_delta = 2040.0;
}
true
});
let broker = pool_batch_broker(false);
let mut account = pool_batch_account();
let mut decision = pool_batch_decision("000002.SZ", "fresh", "09:35");
if let OrderIntent::StockPool { contract } = &mut decision.order_intents[0] {
contract.rule.pricing_mode = crate::stock_pool_execution::POOL_PRICE_FORMULA_LIMIT.into();
contract.rule.sell_offset_bps = 400;
}
broker
.execute_with_event_dates(date, signal, signal, &mut account, &data, &decision)
.unwrap();
let result = pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
assert_eq!(
account.position("000002.SZ").unwrap().quantity,
100,
"2000/10.2 rounds to one 100-share lot, not 200 at stale open"
);
let fill = result
.fill_events
.iter()
.find(|fill| fill.symbol == "000002.SZ")
.unwrap();
assert_eq!(fill.price, 10.2);
assert_eq!(
fill.execution_start_timestamp,
Some(date.and_hms_opt(9, 31, 0).unwrap())
);
let event = result
.order_events
.iter()
.find(|event| event.side == OrderSide::Buy)
.unwrap();
assert_eq!(event.decision_date, Some(signal));
assert_eq!(event.order_created_date, Some(date));
}
#[test]
fn stock_pool_after_sell_rejects_missing_quote_instead_of_reusing_daily_open() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data_with(|quote| quote.symbol != "000002.SZ");
let broker = pool_batch_broker(false);
let mut account = pool_batch_account();
broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "missing", "09:35"),
)
.unwrap();
broker
.runtime_intraday_start_time
.set(Some(chrono::NaiveTime::from_hms_opt(9, 31, 0).unwrap()));
broker
.runtime_intraday_end_time
.set(Some(chrono::NaiveTime::from_hms_opt(9, 31, 0).unwrap()));
let error = broker
.execute(date, &mut account, &data, &StrategyDecision::default())
.unwrap_err();
assert!(
error
.to_string()
.contains("stock_pool_execution_quote_missing:000002.SZ"),
"{error}"
);
assert!(account.position("000002.SZ").is_none());
}
#[test]
fn stock_pool_delayed_take_profit_does_not_rebuy_the_same_generation_exit() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(false);
let mut account = PortfolioState::new(0.0);
account.position_mut("000001.SZ").buy(
chrono::NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(),
200,
9.0,
);
let mut decision = pool_batch_decision("000002.SZ", "take-profit", "09:35");
if let OrderIntent::StockPool { contract } = &mut decision.order_intents[0] {
let symbols = vec!["000001.SZ".to_owned(), "000002.SZ".to_owned()];
contract.selection.requested_symbols = symbols.clone();
contract.selection.normal_trading_symbols = symbols.clone();
contract.selection.risk_eligible_symbols = symbols.clone();
contract.selection.final_symbols = symbols;
contract.constraints.target_holding_count = Some(2);
contract.members.insert(
0,
crate::stock_pool_execution::StockPoolMemberSpec {
symbol: "000001.SZ".into(),
recommendation_reason: String::new(),
requested_order: 0,
target_weight_bps: None,
stop_loss: None,
take_profit: Some(rust_decimal::Decimal::new(5, 2)),
},
);
contract.members[1].requested_order = 1;
}
broker
.execute_with_event_dates(date, signal, signal, &mut account, &data, &decision)
.unwrap();
let result = pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
assert!(
account
.position("000001.SZ")
.is_none_or(|p| p.quantity == 0)
);
assert_eq!(account.position("000002.SZ").unwrap().quantity, 200);
assert!(
!result
.order_events
.iter()
.any(|event| event.symbol == "000001.SZ" && event.side == OrderSide::Buy)
);
}
#[test]
fn stock_pool_pending_phase_cannot_cross_the_execution_session() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(false);
let mut account = pool_batch_account();
let mut report = broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "end", "09:35"),
)
.unwrap();
assert!(broker.has_pending_stock_pool_execution());
broker.finish_stock_pool_session(date, &mut report);
assert!(!broker.has_pending_stock_pool_execution());
assert!(
report
.diagnostics
.iter()
.any(|event| event.contains("unsubmitted_phase_expired"))
);
assert_eq!(
broker.open_order_views().len(),
1,
"session cleanup preserves broker order history and remainder"
);
}
#[test]
fn stock_pool_engine_drives_the_pending_buy_without_a_minute_strategy_callback() {
use crate::{BacktestConfig, BacktestEngine, BacktestError, Strategy, StrategyContext};
struct DailyPool;
impl Strategy for DailyPool {
fn name(&self) -> &str {
"daily-pool-batch"
}
fn requires_minute_callbacks(&self) -> bool {
false
}
fn schedule_rules(&self) -> Vec<crate::ScheduleRule> {
vec![
crate::ScheduleRule::daily("open", crate::ScheduleStage::OnDay)
.with_time_rule(crate::ScheduleTimeRule::physical_time(9, 30)),
]
}
fn on_scheduled(
&mut self,
ctx: &StrategyContext<'_>,
_: &crate::ScheduleRule,
) -> Result<StrategyDecision, BacktestError> {
if ctx.execution_date.day() == 2 {
Ok(StrategyDecision {
order_intents: vec![OrderIntent::LimitTargetShares {
symbol: "000001.SZ".into(),
target_quantity: 200,
limit_price: 10.0,
reason: "initial-entry".into(),
}],
..Default::default()
})
} else {
Ok(pool_batch_decision("000002.SZ", "rotation", "09:35"))
}
}
fn on_minute(
&mut self,
_: &StrategyContext<'_>,
_: &IntradayExecutionQuote,
) -> Result<StrategyDecision, BacktestError> {
panic!("this daily strategy must not be rerun to continue a pending batch")
}
}
use chrono::Datelike;
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let last = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let base = pool_batch_data();
let mut market = Vec::new();
let mut factors = Vec::new();
let mut candidates = Vec::new();
let mut benchmarks = Vec::new();
let mut quotes = Vec::new();
for date in [first, last] {
for symbol in ["000001.SZ", "000002.SZ", "000003.SZ"] {
let mut row = base.market(first, symbol).unwrap().clone();
row.date = date;
market.push(row);
let mut row = base.candidate(first, symbol).unwrap().clone();
row.date = date;
candidates.push(row);
factors.push(crate::data::DailyFactorSnapshot {
date,
symbol: symbol.into(),
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(),
});
for original in base.execution_quotes_on(first, symbol) {
let mut quote = original.clone();
quote.date = date;
quote.timestamp = date.and_time(original.timestamp.time());
quotes.push(quote);
}
}
let mut row = limit_test_benchmark();
row.date = date;
benchmarks.push(row);
}
let data = DataSet::from_components_with_actions_and_quotes(
base.instruments().values().cloned().collect(),
market,
factors,
candidates,
benchmarks,
Vec::new(),
quotes,
)
.unwrap()
.with_additional_trading_dates([chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()]);
let config = BacktestConfig {
initial_cash: 2000.0,
benchmark_code: "000852.SH".into(),
start_date: Some(first),
end_date: Some(last),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
};
let result = BacktestEngine::new(data, DailyPool, pool_batch_broker(false), config)
.run()
.unwrap();
assert_eq!(
result.fills.len(),
3,
"initial buy, delayed sell, resumed buy: orders={:?} equity={:?}",
result.order_events,
result.equity_curve
);
assert_eq!(result.fills[2].symbol, "000002.SZ");
assert_eq!(result.fills[2].quantity, 200);
assert_eq!(
result.fills[2].execution_timestamp,
Some(last.and_hms_opt(9, 31, 0).unwrap())
);
assert_eq!(result.holdings_summary.len(), 1);
}
+868
View File
@@ -0,0 +1,868 @@
use crate::{
AccountEvent, BacktestError, BrokerExecutionReport, CashReceivable, DataSet, FillEvent,
OrderSide, PortfolioState, PositionEvent, PriceField, ProcessEvent, ProcessEventKind,
};
use chrono::{NaiveDate, TimeZone};
pub(crate) fn validate_action<'a>(
action: &'a crate::CorporateAction,
data: &DataSet,
) -> Result<Option<(&'a str, f64, f64)>, String> {
let terms = action.validated_successor_terms()?;
crate::finite_serialization::validate(action).map_err(|error| {
format!(
"corporate_action_invalid_number: symbol={} action_date={} detail={error}",
action.symbol, action.date
)
})?;
if let Some((successor, _, _)) = terms {
for (symbol, role) in [(&*action.symbol, "source"), (successor, "successor")] {
if data.instrument(symbol).is_none() {
return Err(format!(
"corporate_action_{role}_instrument_missing: symbol={symbol} action_date={} source_symbol={}",
action.date, action.symbol
));
}
}
}
Ok(terms)
}
/// One corporate-action calculation for normal processing and audited replay.
pub(crate) fn apply(
date: NaiveDate,
data: &DataSet,
portfolio: &mut PortfolioState,
notes: &mut Vec<String>,
cash_dividends_enabled: bool,
cash_dividend_adjusts_cost_basis: bool,
) -> Result<BrokerExecutionReport, BacktestError> {
let actions = data.corporate_actions_on(date);
for action in actions {
validate_action(action, data).map_err(BacktestError::Execution)?;
}
if !actions.iter().any(|action| {
action.has_effect()
&& portfolio
.position(&action.symbol)
.is_some_and(|position| position.quantity > 0)
}) {
return Ok(BrokerExecutionReport::default());
}
// An entire settlement batch is a single ledger update. A later invalid
// cash leg must not leave an earlier split, receivable, target unit, or
// note applied to the observed account.
let mut next = portfolio.clone();
let mut recorded = Vec::new();
let report = apply_inner(
date,
data,
&mut next,
&mut recorded,
cash_dividends_enabled,
cash_dividend_adjusts_cost_basis,
)?;
*portfolio = next;
notes.extend(recorded);
Ok(report)
}
fn apply_inner(
date: NaiveDate,
data: &DataSet,
portfolio: &mut PortfolioState,
notes: &mut Vec<String>,
cash_dividends_enabled: bool,
cash_dividend_adjusts_cost_basis: bool,
) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
for action in data.corporate_actions_on(date) {
if !action.has_effect() {
continue;
}
let Some(existing_position) = portfolio.position(&action.symbol) else {
continue;
};
if existing_position.quantity == 0 {
continue;
}
if cash_dividends_enabled && action.share_cash.abs() > f64::EPSILON {
let cash_before = portfolio.cash();
let (cash_delta, quantity_after, average_cost) = {
let position = portfolio
.position_mut_if_exists(&action.symbol)
.expect("position exists for dividend action");
let cash_delta = if cash_dividend_adjusts_cost_basis {
position.apply_cash_dividend(action.share_cash)
} else {
position.apply_cash_dividend_preserve_cost_basis(action.share_cash)
};
(cash_delta, position.quantity, position.average_cost)
};
if cash_delta.abs() > f64::EPSILON {
let payable_date = action.payable_date.unwrap_or(date);
portfolio.add_cash_receivable(CashReceivable {
symbol: action.symbol.clone(),
ex_date: date,
payable_date,
amount: cash_delta,
reason: format!("cash_dividend {:.6}", action.share_cash),
});
let note = format!(
"cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}",
action.symbol, action.share_cash, quantity_after, payable_date, cash_delta
);
notes.push(note.clone());
report.account_events.push(AccountEvent {
date,
cash_before,
cash_after: portfolio.cash(),
total_equity: portfolio.total_equity(),
note,
});
report.position_events.push(PositionEvent {
date,
symbol: action.symbol.clone(),
delta_quantity: 0,
quantity_after,
average_cost,
realized_pnl_delta: 0.0,
reason: format!("cash_dividend {:.6}", action.share_cash),
});
}
}
let split_ratio = action.split_ratio();
if (split_ratio - 1.0).abs() > f64::EPSILON {
checked_quantity(
&action.symbol,
date,
portfolio
.position(&action.symbol)
.expect("position exists for split")
.quantity,
split_ratio,
0,
)?;
portfolio
.adjust_stock_pool_split(&action.symbol, split_ratio)
.map_err(BacktestError::Execution)?;
let (delta_quantity, quantity_after, average_cost) = {
let position = portfolio
.position_mut_if_exists(&action.symbol)
.expect("position exists for split action");
let delta_quantity = position.apply_split_ratio(split_ratio);
(delta_quantity, position.quantity, position.average_cost)
};
if delta_quantity != 0 {
let note = format!(
"stock_split {} ratio={:.6} delta_qty={}",
action.symbol, split_ratio, delta_quantity
);
notes.push(note);
report.position_events.push(PositionEvent {
date,
symbol: action.symbol.clone(),
delta_quantity,
quantity_after,
average_cost,
realized_pnl_delta: 0.0,
reason: format!("stock_split {:.6}", split_ratio),
});
}
}
if let Some((successor_symbol, ratio, cash_per_share)) = action
.validated_successor_terms()
.map_err(BacktestError::Execution)?
{
checked_quantity(
&action.symbol,
date,
portfolio
.position(&action.symbol)
.expect("position exists for conversion")
.quantity,
ratio,
portfolio
.position(successor_symbol)
.map_or(0, |position| position.quantity),
)?;
let Some(outcome) = portfolio.apply_successor_conversion(
&action.symbol,
successor_symbol,
ratio,
cash_per_share,
) else {
continue;
};
let reason = format!(
"successor_conversion {}->{} ratio={:.6} cash_per_share={:.6}",
outcome.old_symbol, outcome.new_symbol, ratio, cash_per_share
);
notes.push(reason.clone());
report.position_events.push(PositionEvent {
date,
symbol: outcome.old_symbol.clone(),
delta_quantity: -(outcome.old_quantity as i32),
quantity_after: 0,
average_cost: 0.0,
realized_pnl_delta: 0.0,
reason: reason.clone(),
});
report.position_events.push(PositionEvent {
date,
symbol: outcome.new_symbol.clone(),
delta_quantity: outcome.new_quantity_delta,
quantity_after: outcome.new_quantity_after,
average_cost: outcome.new_average_cost_after,
realized_pnl_delta: 0.0,
reason: reason.clone(),
});
if outcome.cash_delta.abs() > f64::EPSILON {
let cash_before = portfolio.cash();
portfolio
.apply_cash_delta(outcome.cash_delta)
.map_err(BacktestError::Execution)?;
report.account_events.push(AccountEvent {
date,
cash_before,
cash_after: portfolio.cash(),
total_equity: portfolio.total_equity(),
note: format!("{} cash={:.2}", reason, outcome.cash_delta),
});
}
}
}
portfolio.prune_flat_positions();
Ok(report)
}
fn checked_quantity(
symbol: &str,
date: NaiveDate,
quantity: u32,
ratio: f64,
merged: u32,
) -> Result<(), BacktestError> {
let scaled = (f64::from(quantity) * ratio).round();
if !scaled.is_finite()
|| scaled < 0.
|| scaled > f64::from(i32::MAX)
|| scaled + f64::from(merged) > f64::from(u32::MAX)
{
return Err(BacktestError::Execution(format!(
"corporate_action_quantity_overflow: symbol={symbol} action_date={date}"
)));
}
Ok(())
}
/// Preserve the declared fee-free accounting allocation model; this does not
/// submit a market order or use a later opening quote as an earlier fact.
pub(crate) fn settle_receivables(
date: NaiveDate,
data: &DataSet,
portfolio: &mut PortfolioState,
notes: &mut Vec<String>,
reinvest_enabled: bool,
runtime_input: Option<&crate::manual_execution::ManualExecutionReplay>,
) -> Result<BrokerExecutionReport, BacktestError> {
if !portfolio
.cash_receivables()
.iter()
.any(|row| row.payable_date <= date)
{
return Ok(BrokerExecutionReport::default());
}
let mut next = portfolio.clone();
let mut recorded = Vec::new();
let control = if reinvest_enabled {
manual_reinvestment_control(date, runtime_input)?
} else {
None
};
let report = settle_receivables_inner(
date,
data,
&mut next,
&mut recorded,
reinvest_enabled,
control,
)?;
*portfolio = next;
notes.extend(recorded);
Ok(report)
}
/// The accounting stage precedes the market session. A later same-day setting
/// must not retroactively change an allocation already observed at settlement.
#[derive(Clone, Copy)]
enum ManualReinvestmentControl<'a> {
Event(&'a crate::position_exposure::PositionExposureEvent),
LegacyZero(NaiveDate),
}
impl ManualReinvestmentControl<'_> {
fn suppression(self, symbol: &str) -> Option<String> {
use crate::position_exposure::PositionExposureAction as Action;
match self {
Self::LegacyZero(date) => Some(format!(
"runtime_zero_exposure legacy_effective_date={date}"
)),
Self::Event(event) => {
if matches!(event.action, Action::Restore) {
return None;
}
let reason = if matches!(
event.action,
Action::Scale { requested_bps: 0 }
| Action::Set {
target_exposure_bps: 0
}
) {
"runtime_zero_exposure"
} else if event
.allocation_weights_bps
.as_ref()
.is_some_and(|weights| weights.get(symbol).copied().unwrap_or(0) == 0)
{
"runtime_zero_allocation"
} else {
return None;
};
Some(format!(
"{reason} event_sequence={} effective_at={}",
event.sequence, event.effective_at
))
}
}
}
}
fn manual_reinvestment_control(
date: NaiveDate,
runtime_input: Option<&crate::manual_execution::ManualExecutionReplay>,
) -> Result<Option<ManualReinvestmentControl<'_>>, BacktestError> {
let Some(input) = runtime_input else {
return Ok(None);
};
let at = chrono::FixedOffset::east_opt(8 * 3600)
.unwrap()
.from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap())
.single()
.ok_or_else(|| {
BacktestError::Execution(
"dividend_reinvestment: accounting stage clock is out of range".into(),
)
})?
.with_timezone(&chrono::Utc);
// The input has already been validated and bound to the runtime audit.
// Keep the same (time, sequence) precedence as PositionExposureTimeline.
if let Some(event) = input
.position_exposure_events
.iter()
.filter(|event| event.effective_at <= at)
.max_by_key(|event| (event.effective_at, event.sequence))
{
return Ok(Some(ManualReinvestmentControl::Event(event)));
}
Ok(input
.legacy_position_exposure_bps
.range(..=date)
.next_back()
.filter(|(_, bps)| **bps == 0)
.map(|(day, _)| ManualReinvestmentControl::LegacyZero(*day)))
}
fn settle_receivables_inner(
date: NaiveDate,
data: &DataSet,
portfolio: &mut PortfolioState,
notes: &mut Vec<String>,
reinvest_enabled: bool,
control: Option<ManualReinvestmentControl<'_>>,
) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
let due = portfolio.take_due_cash_receivables(date);
for receivable in due {
let cash_before = portfolio.cash();
portfolio
.settle_cash_receivable(&receivable)
.map_err(BacktestError::Execution)?;
let mut note = format!(
"cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}",
receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount
);
if let Some(suppression) =
control.and_then(|control| control.suppression(&receivable.symbol))
&& receivable.reason.starts_with("cash_dividend")
&& receivable.amount > 0.
{
note.push_str(&format!(
" dividend_reinvestment_not_applied reason={suppression} cash_retained=true"
));
} else if reinvest_enabled
&& receivable.reason.starts_with("cash_dividend")
&& receivable.amount > 0.0
{
let instrument = data.instrument(&receivable.symbol).ok_or_else(|| BacktestError::Execution(format!(
"dividend_reinvestment: instrument metadata missing symbol={} payable_date={date}", receivable.symbol)))?;
if let Some(reason) = instrument.dated_market_absence_reason(date) {
note.push_str(&format!(
" dividend_reinvestment_not_applied reason={reason} cash_retained=true"
));
} else {
let (reinvest_price, reference_basis) = if let Some(position) = portfolio
.position(&receivable.symbol)
.filter(|position| position.quantity > 0)
{
(Some(position.last_price), "adjusted_carried_mark")
} else {
(
data.calendar().previous_day(date).and_then(|prev_date| {
data.price_on_or_before(
prev_date,
&receivable.symbol,
PriceField::Close,
)
}),
"previous_completed_close",
)
};
let price = reinvest_price.filter(|price| price.is_finite() && *price > 0.).ok_or_else(|| BacktestError::Execution(format!(
"dividend_reinvestment: accounting reference missing or invalid symbol={} payable_date={date} basis={reference_basis}", receivable.symbol)))?;
let round_lot = instrument.round_lot;
if round_lot == 0 {
return Err(BacktestError::Execution(format!(
"dividend_reinvestment: invalid quantity unit symbol={}",
receivable.symbol
)));
}
{
let raw = (receivable.amount / price).floor();
if !raw.is_finite() || raw > i32::MAX as f64 {
return Err(BacktestError::Execution("dividend_reinvestment: accounting allocation quantity exceeds the ledger contract".into()));
}
let raw_quantity = raw as u32;
let reinvest_quantity = (raw_quantity / round_lot) * round_lot;
if reinvest_quantity > 0 {
// Report the same micro-unit amount actually posted to
// the ledger, not a floating multiplication residue.
let reinvest_money =
crate::FixedMoney::from_f64(reinvest_quantity as f64 * price)
.ok_or_else(|| {
BacktestError::Execution(
"dividend_reinvestment: allocation amount out of range"
.into(),
)
})?;
let cash_delta = reinvest_money.checked_neg().ok_or_else(|| {
BacktestError::Execution(
"dividend_reinvestment: cash amount out of range".into(),
)
})?;
let residual_cash = crate::FixedMoney::from_f64(receivable.amount)
.and_then(|cash| cash.checked_sub(reinvest_money))
.ok_or_else(|| {
BacktestError::Execution(
"dividend_reinvestment: residual amount out of range".into(),
)
})?
.to_f64();
let reinvest_cash = reinvest_money.to_f64();
portfolio
.apply_cash_delta_fixed(cash_delta)
.map_err(BacktestError::Execution)?;
portfolio.position_mut(&receivable.symbol).buy(
date,
reinvest_quantity,
price,
);
note = format!(
"cash_receivable_reinvested {} ex_date={} payable_date={} cash={:.2} reinvest_qty={} reinvest_price={:.4} residual_cash={:.2}",
receivable.symbol,
receivable.ex_date,
receivable.payable_date,
receivable.amount,
reinvest_quantity,
price,
residual_cash
);
report.fill_events.push(FillEvent {
origin: crate::events::FillOrigin::DividendReinvestment,
date,
decision_date: None,
order_created_date: None,
execution_date: None,
execution_start_timestamp: date.and_hms_opt(0, 0, 0),
execution_timestamp: date.and_hms_opt(0, 0, 0),
order_id: None,
symbol: receivable.symbol.clone(),
side: OrderSide::Buy,
quantity: reinvest_quantity,
price,
gross_amount: reinvest_cash,
commission: 0.0,
stamp_tax: 0.0,
transfer_fee: 0.0,
net_cash_flow: cash_delta.to_f64(),
reason: "dividend_reinvestment".to_string(),
});
report.position_events.push(PositionEvent {
date,
symbol: receivable.symbol.clone(),
delta_quantity: reinvest_quantity as i32,
quantity_after: portfolio
.position(&receivable.symbol)
.map(|position| position.quantity)
.unwrap_or(0),
average_cost: portfolio
.position(&receivable.symbol)
.map(|position| position.average_cost)
.unwrap_or(0.0),
realized_pnl_delta: 0.0,
reason: "dividend_reinvestment".to_string(),
});
report.process_events.push(ProcessEvent {
date,
kind: ProcessEventKind::Trade,
order_id: None,
symbol: Some(receivable.symbol.clone()),
side: Some(OrderSide::Buy),
detail: format!("dividend_reinvestment model=fee_free_accounting booked_at={} quantity={} price={} reference_basis={} ex_date={} payable_date={} residual_cash={}",
date.and_hms_opt(0,0,0).unwrap(), reinvest_quantity, price, reference_basis,
receivable.ex_date, receivable.payable_date, residual_cash),
});
}
}
}
}
notes.push(note.clone());
report.account_events.push(AccountEvent {
date,
cash_before,
cash_after: portfolio.cash(),
total_equity: portfolio.total_equity(),
note,
});
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn date() -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()
}
fn data(delisted: bool) -> DataSet {
DataSet::from_components(
vec![crate::Instrument {
symbol: "000001.SZ".into(),
name: "fixture".into(),
board: "SZ".into(),
round_lot: 100,
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
delisted_at: delisted.then_some(date()),
status: "active".into(),
}],
vec![],
vec![],
vec![],
vec![crate::BenchmarkSnapshot {
date: date(),
benchmark: "000300.SH".into(),
open: 100.,
close: 100.,
prev_close: 100.,
volume: 0,
}],
)
.unwrap()
}
fn book() -> PortfolioState {
let mut book = PortfolioState::new(10.);
book.add_cash_receivable(CashReceivable {
symbol: "000001.SZ".into(),
ex_date: date().pred_opt().unwrap(),
payable_date: date(),
amount: 100.,
reason: "cash_dividend 1".into(),
});
book
}
#[test]
fn missing_accounting_reference_is_atomic_not_a_silent_cash_only_success() {
let mut book = book();
let before = book.financial_replay_identity();
let error =
settle_receivables(date(), &data(false), &mut book, &mut Vec::new(), true, None)
.unwrap_err();
assert!(error.to_string().contains("accounting reference missing"));
assert_eq!(book.financial_replay_identity(), before);
}
#[test]
fn terminated_security_keeps_paid_cash_and_is_not_recreated_by_reinvestment() {
let mut book = book();
let mut notes = Vec::new();
let report =
settle_receivables(date(), &data(true), &mut book, &mut notes, true, None).unwrap();
assert_eq!(book.cash(), 110.);
assert!(book.positions().is_empty());
assert!(book.cash_receivables().is_empty());
assert!(report.fill_events.is_empty());
assert!(notes[0].contains("dividend_reinvestment_not_applied"));
}
#[test]
fn manual_zero_skips_only_unused_allocation_facts_not_invalid_cash_evidence() {
let mut input = crate::manual_execution::ManualExecutionReplay {
schema: crate::manual_execution::MANUAL_REPLAY_SCHEMA.into(),
runtime_id: "runtime".into(),
account_id: "account".into(),
source_contract_sha256: "a".repeat(64),
content_sha256: String::new(),
observation_cutoff: "2026-09-14T08:00:00Z".parse().unwrap(),
actions: vec![],
position_exposure_events: vec![],
legacy_position_exposure_bps: std::collections::BTreeMap::from([(date(), 0)]),
};
input.content_sha256 = input.content_digest().unwrap();
input.validate().unwrap();
let mut account = book();
let mut notes = Vec::new();
// No historical price is present, but no optional purchase is wanted.
let report = settle_receivables(
date(),
&data(false),
&mut account,
&mut notes,
true,
Some(&input),
)
.unwrap();
assert_eq!(account.cash(), 110.);
assert!(account.positions().is_empty());
assert!(report.fill_events.is_empty());
assert!(notes[0].contains("runtime_zero_exposure"));
let mut account = book();
account.add_cash_receivable(CashReceivable {
symbol: "000001.SZ".into(),
ex_date: date(),
payable_date: date(),
amount: f64::NAN,
reason: "cash_dividend invalid fixture".into(),
});
let mut notes = vec!["prior".into()];
assert!(
settle_receivables(
date(),
&data(false),
&mut account,
&mut notes,
true,
Some(&input)
)
.is_err()
);
assert_eq!(account.cash(), 10.);
assert_eq!(account.cash_receivables().len(), 2);
assert!(account.cash_receivables()[1].amount.is_nan());
assert_eq!(notes, ["prior"]);
}
fn conversion() -> crate::CorporateAction {
crate::CorporateAction {
date: date(),
symbol: "000001.SZ".into(),
payable_date: None,
share_cash: 0.,
share_bonus: 0.,
share_gift: 0.,
issue_quantity: 0.,
issue_price: 0.,
reform: false,
adjust_factor: None,
successor_symbol: Some("000002.SZ".into()),
successor_ratio: Some(1.5),
successor_cash: Some(0.5),
}
}
fn conversion_data(actions: Vec<crate::CorporateAction>, include_successor: bool) -> DataSet {
let mut instruments = data(false)
.instruments()
.values()
.cloned()
.collect::<Vec<_>>();
if include_successor {
let mut successor = instruments[0].clone();
successor.symbol = "000002.SZ".into();
instruments.push(successor);
}
DataSet::from_components_with_actions(
instruments,
vec![],
vec![],
vec![],
vec![crate::BenchmarkSnapshot {
date: date(),
benchmark: "000300.SH".into(),
open: 100.,
close: 100.,
prev_close: 100.,
volume: 0,
}],
actions,
)
.unwrap()
}
fn conversion_book() -> PortfolioState {
let mut book = PortfolioState::new(1000.);
book.position_mut("000001.SZ")
.buy(date().pred_opt().unwrap(), 100, 10.);
book
}
#[test]
fn successor_without_frozen_instrument_metadata_is_not_an_implicit_new_security() {
let mut action = conversion();
action.share_cash = 1.;
action.share_bonus = 1.;
let data = conversion_data(vec![action], false);
let mut book = conversion_book();
let before = book.financial_replay_identity();
let mut notes = vec!["prior".into()];
let error = apply(date(), &data, &mut book, &mut notes, true, true).unwrap_err();
assert!(error.to_string().contains("successor_instrument_missing"));
assert_eq!(book.financial_replay_identity(), before);
assert_eq!(notes, ["prior"]);
}
#[test]
fn invalid_successor_terms_cannot_be_replaced_with_one_share_or_zero_cash() {
let base = conversion();
let mut cases = Vec::new();
for ratio in [
None,
Some(0.),
Some(-1.),
Some(f64::NAN),
Some(f64::INFINITY),
] {
let mut row = base.clone();
row.successor_ratio = ratio;
cases.push(row);
}
for symbol in [
None,
Some(""),
Some(" "),
Some("000001.SZ"),
Some(" 000002.SZ"),
] {
let mut row = base.clone();
row.successor_symbol = symbol.map(str::to_owned);
cases.push(row);
}
for cash in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut row = base.clone();
row.successor_cash = Some(cash);
cases.push(row);
}
for action in cases {
let mut book = conversion_book();
let before = book.financial_replay_identity();
let data = conversion_data(vec![action.clone()], true);
let mut notes = Vec::new();
assert!(
apply(date(), &data, &mut book, &mut notes, true, true).is_err(),
"accepted {action:?}"
);
assert_eq!(book.financial_replay_identity(), before);
assert!(notes.is_empty());
}
}
#[test]
fn a_late_corporate_batch_failure_keeps_prior_cash_positions_and_notes() {
let mut dividend = conversion();
dividend.successor_symbol = None;
dividend.successor_ratio = None;
dividend.successor_cash = None;
dividend.share_cash = 1.;
dividend.share_bonus = 1.;
let mut failure = conversion();
failure.successor_cash = Some(1e100);
let data = conversion_data(vec![dividend, failure], true);
let mut book = conversion_book();
let mut state = crate::stock_pool_state::StockPoolExecutionState {
last_execution_date: date().pred_opt(),
..Default::default()
};
state.position_action_bases.insert(
"000001.SZ".into(),
crate::stock_pool_state::StockPoolPositionActionBasis {
generation: "original".into(),
first_execution_date: date().pred_opt().unwrap(),
quantity: rust_decimal::Decimal::from(100),
},
);
state.last_target_weights.insert("000001.SZ".into(), 10000);
book.set_stock_pool_execution_state("pool", state.clone())
.unwrap();
let before = book.financial_replay_identity();
let mut notes = vec!["prior".into()];
assert!(apply(date(), &data, &mut book, &mut notes, true, true).is_err());
assert_eq!(book.financial_replay_identity(), before);
assert_eq!(book.stock_pool_execution_state("pool"), state);
assert_eq!(notes, ["prior"]);
}
#[test]
fn corporate_quantity_overflow_fails_without_saturation_or_a_negative_delta() {
for split in [false, true] {
let mut action = conversion();
if split {
action.share_bonus = 1e100;
} else {
action.successor_ratio = Some(1e100);
}
let data = conversion_data(vec![action], true);
let mut book = conversion_book();
let before = book.financial_replay_identity();
let mut notes = Vec::new();
let error = apply(date(), &data, &mut book, &mut notes, true, true).unwrap_err();
assert!(
error
.to_string()
.contains("corporate_action_quantity_overflow")
);
assert_eq!(book.financial_replay_identity(), before);
assert!(notes.is_empty());
}
}
#[test]
fn verified_conversion_preserves_lots_without_creating_orders_or_fills() {
let data = conversion_data(vec![conversion()], true);
let mut book = conversion_book();
book.position_mut("000002.SZ").buy(date(), 50, 20.);
let mut notes = Vec::new();
let report = apply(date(), &data, &mut book, &mut notes, true, true).unwrap();
assert!(book.position("000001.SZ").is_none());
let successor = book.position("000002.SZ").unwrap();
assert_eq!(successor.quantity, 200);
assert_eq!(successor.opened_date(), date().pred_opt());
assert_eq!(successor.last_buy_date(), Some(date()));
assert_eq!(book.cash(), 1050.);
assert!(report.fill_events.is_empty());
assert!(report.order_events.is_empty());
assert_eq!(report.position_events.len(), 2);
assert!(notes[0].contains("ratio=1.500000"));
}
}
+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
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
//! Historical ETF execution fallback. Never manufactures an intraday bar.
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use rust_decimal::Decimal;
use crate::{BacktestError, DataSet};
pub(crate) fn opening_time() -> NaiveTime {
NaiveTime::from_hms_opt(9, 30, 0).expect("valid exchange opening time")
}
#[derive(Debug, Clone)]
pub(crate) struct EtfFallbackReference {
pub price: f64,
pub reference_date: NaiveDate,
/// None means the next official session is outside the loaded calendar.
/// No natural-day guess or price from beyond the requested run is used.
pub execute_on: Option<NaiveDate>,
pub immediate: bool,
}
pub(crate) fn reference(data: &DataSet, symbol: &str, at: NaiveDateTime) -> Result<EtfFallbackReference, BacktestError> {
let fail = |reason: &str| BacktestError::Execution(format!(
"etf_daily_open_fallback:{reason} symbol={symbol} signal_at={at}"
));
let instrument = data.instrument(symbol).ok_or_else(|| fail("instrument_identity_missing"))?;
if !instrument.is_exchange_traded_fund() || instrument.listed_at.is_none() {
return Err(fail("verified_etf_identity_required"));
}
if instrument.dated_market_absence_reason(at.date()).is_some() {
return Err(fail("outside_instrument_lifecycle"));
}
if at.time() == opening_time() {
let row = data.market(at.date(), symbol).ok_or_else(|| fail("daily_open_missing"))?;
if !row.open.is_finite() || row.open <= 0.0 { return Err(fail("daily_open_invalid")); }
return Ok(EtfFallbackReference { price: row.open, reference_date: at.date(), execute_on: Some(at.date()), immediate: true });
}
let previous = data.previous_trading_date(at.date(), 1).ok_or_else(|| fail("previous_official_session_missing"))?;
let close = data.market(previous, symbol).map(|row| row.close).ok_or_else(|| fail("previous_completed_close_missing"))?;
if !close.is_finite() || close <= 0.0 { return Err(fail("previous_completed_close_invalid")); }
Ok(EtfFallbackReference {
price: close, reference_date: previous, immediate: false,
execute_on: if at.time() < opening_time() { Some(at.date()) } else { data.next_trading_date(at.date(), 1) },
})
}
#[derive(Debug, Clone)]
pub(crate) struct DeferredEtfTarget {
pub pool_id: String,
pub generation: String,
pub symbol: String,
pub signal_date: NaiveDate,
pub signal_at: NaiveDateTime,
pub execute_on: Option<NaiveDate>,
pub target_value: Decimal,
pub target_weight_bps: i32,
pub side: crate::stock_pool_execution::OrderSide,
pub max_positions: usize,
pub rule: std::sync::Arc<crate::stock_pool_execution::StockPoolExecutionRule>,
pub members: std::sync::Arc<Vec<crate::stock_pool_execution::StockPoolMemberSpec>>,
pub reason: String,
}
/// Owned by one broker/run. Replacing a full pool generation supersedes older
/// queued targets; order of the latest candidate list is retained.
#[derive(Debug, Default, Clone)]
pub(crate) struct DeferredEtfTargets {
generations: std::collections::BTreeMap<String, String>,
rows: Vec<DeferredEtfTarget>,
}
impl DeferredEtfTargets {
pub fn replace_generation(&mut self, pool_id: &str, generation: &str) -> usize {
if self.generations.get(pool_id).is_some_and(|old| old == generation) { return 0; }
self.generations.insert(pool_id.into(), generation.into());
let before = self.rows.len();
self.rows.retain(|row| row.pool_id != pool_id);
before - self.rows.len()
}
pub fn upsert(&mut self, row: DeferredEtfTarget) {
if let Some(existing) = self.rows.iter_mut().find(|v| v.pool_id == row.pool_id && v.symbol == row.symbol) {
*existing = row;
} else { self.rows.push(row); }
}
pub fn take_due(&mut self, date: NaiveDate) -> Vec<DeferredEtfTarget> {
let mut due = Vec::new();
self.rows.retain(|row| {
if row.execute_on.is_some_and(|day| day <= date) { due.push(row.clone()); false } else { true }
});
due.sort_by_key(|row| match row.side { crate::stock_pool_execution::OrderSide::Sell => 0, crate::stock_pool_execution::OrderSide::Buy => 1 });
due
}
pub fn len(&self) -> usize { self.rows.len() }
}
#[cfg(test)]
mod tests {
use super::*;
fn target(symbol:&str,side:crate::stock_pool_execution::OrderSide,generation:&str)->DeferredEtfTarget {
let date=NaiveDate::from_ymd_opt(2026,1,2).unwrap();
DeferredEtfTarget {pool_id:"pool".into(),generation:generation.into(),symbol:symbol.into(),signal_date:date,signal_at:date.and_hms_opt(13,0,0).unwrap(),execute_on:NaiveDate::from_ymd_opt(2026,1,5),target_value:1000.into(),target_weight_bps:5000,side,max_positions:2,rule:Default::default(),members:std::sync::Arc::new(vec![]),reason:"fixture".into()}
}
#[test]
fn latest_generation_overwrites_pending_targets_and_preserves_candidate_order() {
use crate::stock_pool_execution::OrderSide::{Buy,Sell};
let mut queue=DeferredEtfTargets::default();
queue.replace_generation("pool","v1");
queue.upsert(target("510300.SH",Buy,"v1"));
queue.upsert(target("159915.SZ",Buy,"v1"));
assert_eq!(queue.replace_generation("pool","v1"),0);
assert_eq!(queue.replace_generation("pool","v2"),2);
queue.upsert(target("560450.SH",Buy,"v2"));
queue.upsert(target("159915.SZ",Sell,"v2"));
queue.upsert(target("510300.SH",Buy,"v2"));
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,1,2).unwrap()).is_empty());
let due=queue.take_due(NaiveDate::from_ymd_opt(2026,1,5).unwrap());
assert_eq!(due.iter().map(|v|v.symbol.as_str()).collect::<Vec<_>>(),vec!["159915.SZ","560450.SH","510300.SH"]);
assert!(due.iter().all(|v|v.generation=="v2"));
assert_eq!(queue.len(),0);
}
#[test]
fn no_loaded_next_session_is_not_guessed_from_natural_days() {
let mut queue=DeferredEtfTargets::default();
let mut item=target("510300.SH",crate::stock_pool_execution::OrderSide::Buy,"v1");
item.execute_on=None;
queue.upsert(item);
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,2,1).unwrap()).is_empty());
assert_eq!(queue.len(),1);
}
}
+54 -1
View File
@@ -181,8 +181,22 @@ impl OrderEvent {
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FillOrigin {
#[default]
MarketExecution,
DividendReinvestment,
}
impl FillOrigin {
pub fn is_market_execution(&self) -> bool { *self == Self::MarketExecution }
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FillEvent {
#[serde(default, skip_serializing_if = "FillOrigin::is_market_execution")]
pub origin: FillOrigin,
#[serde(with = "date_format")]
pub date: NaiveDate,
#[serde(default, with = "optional_date_format")]
@@ -219,6 +233,14 @@ pub struct FillEvent {
impl FillEvent {
pub fn validate(&self) -> Result<(), String> {
if self.origin == FillOrigin::DividendReinvestment && (
self.order_id.is_some() || self.side != OrderSide::Buy
|| self.commission != 0. || self.stamp_tax != 0. || self.transfer_fee != 0.
|| self.execution_timestamp != self.date.and_hms_opt(0, 0, 0)
|| self.execution_start_timestamp != self.execution_timestamp
) {
return Err("dividend accounting allocation cannot carry an exchange order, fees, or a market clock".into());
}
if self.symbol.trim().is_empty()
|| self.quantity == 0
|| !self.price.is_finite()
@@ -311,12 +333,15 @@ pub enum ProcessEventKind {
OrderUpdateReject,
OrderUnsolicitedUpdate,
Trade,
ManualExecutionObserved,
UniverseUpdated,
UniverseSubscribed,
UniverseUnsubscribed,
AccountDepositWithdraw,
AccountFinanceRepay,
AccountManagementFee,
SessionCapacityAudit,
EtfExecutionFallback,
}
impl ProcessEventKind {
@@ -356,12 +381,15 @@ impl ProcessEventKind {
Self::OrderUpdateReject => "order_update_reject",
Self::OrderUnsolicitedUpdate => "order_unsolicited_update",
Self::Trade => "trade",
Self::ManualExecutionObserved => "manual_execution_observed",
Self::UniverseUpdated => "universe_updated",
Self::UniverseSubscribed => "universe_subscribed",
Self::UniverseUnsubscribed => "universe_unsubscribed",
Self::AccountDepositWithdraw => "account_deposit_withdraw",
Self::AccountFinanceRepay => "account_finance_repay",
Self::AccountManagementFee => "account_management_fee",
Self::SessionCapacityAudit => "session_capacity_audit",
Self::EtfExecutionFallback => "etf_execution_fallback",
}
}
@@ -387,12 +415,15 @@ impl ProcessEventKind {
| Self::OrderUpdateReject
| Self::OrderUnsolicitedUpdate
| Self::Trade
| Self::ManualExecutionObserved
| Self::UniverseUpdated
| Self::UniverseSubscribed
| Self::UniverseUnsubscribed
| Self::AccountDepositWithdraw
| Self::AccountFinanceRepay
| Self::AccountManagementFee
| Self::SessionCapacityAudit
| Self::EtfExecutionFallback
| Self::Settlement
)
}
@@ -416,7 +447,7 @@ pub struct ProcessEvent {
mod tests {
use chrono::{NaiveDate, NaiveDateTime};
use super::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEventKind};
use super::{FillEvent, FillOrigin, OrderEvent, OrderSide, OrderStatus, ProcessEventKind};
fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent {
OrderEvent {
@@ -460,6 +491,7 @@ mod tests {
fn fill_event(start: Option<NaiveDateTime>, end: Option<NaiveDateTime>) -> FillEvent {
FillEvent {
origin: crate::events::FillOrigin::MarketExecution,
date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
decision_date: None,
order_created_date: None,
@@ -480,6 +512,27 @@ mod tests {
}
}
#[test]
fn accounting_origin_cannot_disguise_an_exchange_order_or_fee() {
let at = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(0, 0, 0).unwrap();
let mut fill = fill_event(Some(at), Some(at));
fill.origin = FillOrigin::DividendReinvestment;
fill.order_id = None;
fill.commission = 0.;
fill.net_cash_flow = -1000.;
assert!(fill.validate().is_ok());
for kind in 0..3 {
let mut invalid = fill.clone();
match kind {
0 => invalid.order_id = Some(1),
1 => invalid.commission = 1.,
_ => { invalid.execution_timestamp = Some(at + chrono::Duration::hours(9)); invalid.execution_start_timestamp = invalid.execution_timestamp; }
}
assert!(invalid.validate().is_err());
}
assert!(serde_json::to_value(fill_event(None, None)).unwrap().get("origin").is_none());
}
#[test]
fn fill_execution_timestamp_range_is_explicit_and_backward_compatible() {
let start = NaiveDate::from_ymd_opt(2025, 1, 2)
+205
View File
@@ -0,0 +1,205 @@
//! 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,
}
impl VolumeCapacityMode {
pub fn validate(self, enabled: bool, has_execution_observations: bool) -> Result<(), CapacityError> {
if !enabled { return Ok(()); }
match self {
Self::ExecutionObservation if !has_execution_observations => Err(CapacityError::MissingObservation),
Self::CompletedBar => Err(CapacityError::MissingCompletedBar),
_ => Ok(()),
}
}
pub fn limits_execution_quantity(self) -> bool { self != Self::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,
#[error("completed_bar capacity requires declared bar end and availability; an undated daily total is not a completed observation")]
MissingCompletedBar,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CapacityAuditSummary {
pub mode: VolumeCapacityMode,
pub enabled: bool,
pub participation_rate: f64,
pub audited_symbol_sessions: usize,
pub failed_symbol_sessions: usize,
pub audit_passed: Option<bool>,
pub execution_time_capacity_proven: bool,
}
impl CapacityAuditSummary {
pub fn observe(&mut self, audit: &SessionCapacityAudit) {
self.audited_symbol_sessions += 1;
self.failed_symbol_sessions += usize::from(!audit.passed);
self.audit_passed = Some(self.failed_symbol_sessions == 0);
}
}
/// 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,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<_>>(),
}),
}
}
+5 -150
View File
@@ -1,7 +1,7 @@
//! Causal, typed indicator/event expressions shared by research and trading.
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::Value;
use std::collections::BTreeMap;
use ta_lib::{
Core,
@@ -82,7 +82,7 @@ pub struct Series {
pub values: Vec<Option<f64>>,
}
const OPERATORS: &[&str] = &[
pub(crate) const OPERATORS: &[&str] = &[
"GT",
"GTE",
"LT",
@@ -168,33 +168,7 @@ const OPERATORS: &[&str] = &[
"IF",
];
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":format!("{:?}",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,"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 use crate::factor_event_catalog::catalog;
impl Frame {
pub fn validate(&self) -> Result<(), String> {
@@ -958,124 +932,5 @@ fn operator(
}
#[cfg(test)]
mod tests {
use super::*;
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);
}
}
#[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);
}
@@ -0,0 +1,196 @@
//! Check typed pending intent numbers before JSON could replace NaN/Inf with null.
//! This traverses the original Serialize representation without materializing it.
use serde::{Serialize, Serializer, ser};
#[derive(Clone, Copy)]
struct Finite;
pub(crate) fn validate(value: &impl Serialize) -> Result<(), serde_json::Error> {
value.serialize(Finite)
}
macro_rules! scalar {
($($method:ident: $ty:ty),* $(,)?) => {$(
fn $method(self, _: $ty) -> Result<(), Self::Error> { Ok(()) }
)*};
}
impl Serializer for Finite {
type Ok = ();
type Error = serde_json::Error;
type SerializeSeq = Self;
type SerializeTuple = Self;
type SerializeTupleStruct = Self;
type SerializeTupleVariant = Self;
type SerializeMap = Self;
type SerializeStruct = Self;
type SerializeStructVariant = Self;
scalar!(serialize_bool: bool, serialize_i8: i8, serialize_i16: i16,
serialize_i32: i32, serialize_i64: i64, serialize_i128: i128,
serialize_u8: u8, serialize_u16: u16, serialize_u32: u32,
serialize_u64: u64, serialize_u128: u128, serialize_char: char,
serialize_str: &str, serialize_bytes: &[u8]);
fn serialize_f32(self, value: f32) -> Result<(), Self::Error> {
self.serialize_f64(f64::from(value))
}
fn serialize_f64(self, value: f64) -> Result<(), Self::Error> {
if value.is_finite() {
Ok(())
} else {
Err(ser::Error::custom(
"pending strategy intent contains a non-finite number",
))
}
}
fn serialize_none(self) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_some<T: ?Sized + Serialize>(self, value: &T) -> Result<(), Self::Error> {
value.serialize(self)
}
fn serialize_unit(self) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_unit_struct(self, _: &'static str) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_unit_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_newtype_struct<T: ?Sized + Serialize>(
self,
_: &'static str,
value: &T,
) -> Result<(), Self::Error> {
value.serialize(self)
}
fn serialize_newtype_variant<T: ?Sized + Serialize>(
self,
_: &'static str,
_: u32,
_: &'static str,
value: &T,
) -> Result<(), Self::Error> {
value.serialize(self)
}
fn serialize_seq(self, _: Option<usize>) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_tuple(self, _: usize) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_tuple_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
_: usize,
) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_map(self, _: Option<usize>) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_struct_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
_: usize,
) -> Result<Self, Self::Error> {
Ok(self)
}
}
macro_rules! sequence {
($trait:ident, $method:ident) => {
impl ser::$trait for Finite {
type Ok = ();
type Error = serde_json::Error;
fn $method<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn end(self) -> Result<(), Self::Error> {
Ok(())
}
}
};
}
sequence!(SerializeSeq, serialize_element);
sequence!(SerializeTuple, serialize_element);
sequence!(SerializeTupleStruct, serialize_field);
sequence!(SerializeTupleVariant, serialize_field);
impl ser::SerializeMap for Finite {
type Ok = ();
type Error = serde_json::Error;
fn serialize_key<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn serialize_value<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn end(self) -> Result<(), Self::Error> {
Ok(())
}
}
macro_rules! structure {
($trait:ident) => {
impl ser::$trait for Finite {
type Ok = ();
type Error = serde_json::Error;
fn serialize_field<T: ?Sized + Serialize>(
&mut self,
_: &'static str,
value: &T,
) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn end(self) -> Result<(), Self::Error> {
Ok(())
}
}
};
}
structure!(SerializeStruct);
structure!(SerializeStructVariant);
#[cfg(test)]
mod tests {
use super::*;
use crate::strategy::{OrderIntent, StrategyDecision};
#[test]
fn pending_numbers_cannot_be_silently_serialized_as_optional_nulls() {
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let decision = StrategyDecision {
order_intents: vec![
OrderIntent::LimitTargetPercent {
symbol: "000001.SZ".into(),
target_percent: 0.5,
limit_price: value,
reason: "test".into(),
}
.with_time_in_force(crate::strategy::OrderTimeInForce::Day),
],
..Default::default()
};
assert!(validate(&decision).is_err());
assert!(validate(&vec![Some(value)]).is_err());
}
assert!(validate(&(None::<f64>, vec![0., -0., 0.123456789], "NaN")).is_ok());
}
}
+11
View File
@@ -28,6 +28,17 @@ impl FixedMoney {
self.0
}
pub fn to_decimal_string(self) -> String {
let magnitude = self.0.unsigned_abs();
let scale = MONEY_SCALE as u128;
let sign = if self.0 < 0 { "-" } else { "" };
let width = MONEY_SCALE.ilog10() as usize;
format!("{sign}{}.{:0width$}", magnitude / scale, magnitude % scale)
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
pub fn from_decimal_str(value: &str) -> Result<Self, String> {
let value = value.trim();
if value.is_empty() {
+5 -4
View File
@@ -26,7 +26,7 @@ fn sum_futures_money(values: impl IntoIterator<Item = FixedMoney>, label: &str)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum FuturesDirection {
Long,
Short,
@@ -62,7 +62,7 @@ impl FuturesDirection {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum FuturesPositionEffect {
Open,
Close,
@@ -81,7 +81,7 @@ impl FuturesPositionEffect {
}
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, Serialize)]
pub struct FuturesContractSpec {
pub contract_multiplier: f64,
pub long_margin_rate: f64,
@@ -190,7 +190,7 @@ impl FuturesTransactionCostModel {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize)]
pub struct FuturesOrderIntent {
pub symbol: String,
pub direction: FuturesDirection,
@@ -1048,6 +1048,7 @@ impl FuturesAccountState {
)
.to_f64();
report.fill_events.push(FillEvent {
origin: crate::events::FillOrigin::MarketExecution,
date,
decision_date: None,
order_created_date: None,
+384
View File
@@ -0,0 +1,384 @@
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, Serialize)]
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.evaluate_with_predecessors(symbol, execution_date, evidence, calendar, std::iter::empty())
}
/// Only accept predecessors from validated, actually applied holding
/// conversions. Catalog aliases or requested strategy symbols are not
/// evidence that a configured lock covers another security.
pub fn evaluate_with_predecessors<'a>(
&self,
symbol: &str,
execution_date: NaiveDate,
evidence: &HoldingLifecycleEvidence,
calendar: &TradingCalendar,
verified_predecessors: impl IntoIterator<Item = &'a str>,
) -> Result<AutomaticTradePermission, String> {
self.validate()?;
let predecessors = verified_predecessors.into_iter().collect::<std::collections::BTreeSet<_>>();
if self.locks.iter().any(|lock| {
(lock.symbol == symbol || predecessors.contains(lock.symbol.as_str()))
&& 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")));
}
}
+6
View File
@@ -27,6 +27,12 @@ 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)
}
+21 -2
View File
@@ -1,30 +1,49 @@
pub mod broker;
pub mod calendar;
pub mod cost;
mod corporate_book;
pub mod data;
mod numeric_factors;
pub mod daily_patterns;
pub mod pattern_context;
pub mod session_events;
pub mod factor_events;
pub mod execution_capacity;
mod etf_execution;
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;
pub mod fixed_point;
mod finite_serialization;
pub mod futures;
pub mod instrument;
pub mod metrics;
pub mod manual_execution;
mod manual_corporate_replay;
mod numeric_expr_vm;
pub mod platform_expr_strategy;
pub mod platform_runtime_schema;
pub mod platform_strategy_spec;
pub mod portfolio;
pub mod portfolio_loss;
pub mod position_exposure;
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_market_cap;
pub mod stock_pool_state;
pub mod stock_pool_quote_facts;
pub mod signal_contract;
pub mod strategy_ai;
pub mod universe;
@@ -46,11 +65,11 @@ pub use engine::{
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
ProcessEventRetention, backtest_execution_dates,
ProcessEventRetention, backtest_execution_dates, backtest_execution_dates_with_rules,
};
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
pub use events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
AccountEvent, FillEvent, FillOrigin, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind,
};
pub use fixed_point::{
@@ -0,0 +1,434 @@
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, Utc};
use sha2::{Digest, Sha256};
use crate::manual_execution::{
AppliedManualFill, ManualCorporateActionReference, ManualCorporateAdjustment,
ManualCorporatePositionChange, ManualExecutionReplay, ManualFillObservation,
};
use crate::{DataSet, FillEvent, FixedMoney, MatchingType, OrderSide, PortfolioState, PriceField};
#[derive(Debug, Clone)]
struct CashEffect {
at: NaiveDateTime,
amount: FixedMoney,
}
/// Replays issued financial facts, never strategy callbacks or hypothetical orders.
/// The ordinary book remains observable until an actual receipt is delivered.
pub(crate) struct ManualCorporateReplay {
first_date: NaiveDate,
sessions: RefCell<BTreeSet<NaiveDate>>,
closed: RefCell<BTreeSet<NaiveDate>>,
cash: RefCell<Vec<CashEffect>>,
reconciled_count: Cell<usize>,
cash_dividends: bool,
adjust_cost: bool,
reinvest: bool,
matching: MatchingType,
daily_price: PriceField,
same_day_mark_at_fill: bool,
}
impl ManualCorporateReplay {
pub(crate) fn new(
first_date: NaiveDate,
cash_dividends: bool,
adjust_cost: bool,
reinvest: bool,
matching: MatchingType,
daily_price: PriceField,
same_day_mark_at_fill: bool,
) -> Self {
Self {
first_date,
sessions: RefCell::new(BTreeSet::new()),
closed: RefCell::new(BTreeSet::new()),
cash: RefCell::new(Vec::new()),
reconciled_count: Cell::new(0),
cash_dividends,
adjust_cost,
reinvest,
matching,
daily_price,
same_day_mark_at_fill,
}
}
pub(crate) fn record_session(&self, date: NaiveDate) {
self.sessions.borrow_mut().insert(date);
}
pub(crate) fn record_close(&self, date: NaiveDate) {
self.closed.borrow_mut().insert(date);
}
pub(crate) fn committed(&self, count: usize) {
self.reconciled_count.set(count);
}
pub(crate) fn record_cash(
&self,
at: Option<NaiveDateTime>,
before: FixedMoney,
after: FixedMoney,
) -> Result<(), String> {
let amount = after
.checked_sub(before)
.ok_or("manual corporate cash observation overflow")?;
if amount != FixedMoney::ZERO {
self.cash.borrow_mut().push(CashEffect {
at: at.ok_or("manual corporate cash observation has no execution clock")?,
amount,
});
}
Ok(())
}
fn action_references(
&self,
observation: ManualFillObservation<'_>,
data: &DataSet,
) -> Result<Vec<ManualCorporateActionReference>, String> {
let mut symbols = BTreeSet::from([observation.order.symbol.clone()]);
let observed_date = local(observation.fill.observed_at).date();
let mut actions = Vec::new();
for date in self.sessions.borrow().range((
std::ops::Bound::Excluded(observation.fill.trade_date),
std::ops::Bound::Included(observed_date),
)) {
for action in data.corporate_actions_on(*date) {
if !symbols.contains(&action.symbol) {
continue;
}
let successor_terms = crate::corporate_book::validate_action(action, data)?;
let effective = (action.split_ratio() - 1.).abs() > f64::EPSILON
|| action.has_successor_conversion()
|| (self.cash_dividends && action.share_cash.abs() > f64::EPSILON);
if !effective {
continue;
}
if let Some((successor, _, _)) = successor_terms {
symbols.insert(successor.to_owned());
}
actions.push(ManualCorporateActionReference {
date: *date,
symbol: action.symbol.clone(),
successor_symbol: action.successor_symbol.clone(),
share_cash: action.share_cash.to_string(),
split_ratio: action.split_ratio().to_string(),
successor_ratio: action.successor_ratio.map(|value| value.to_string()),
successor_cash: action.successor_cash.map(|value| value.to_string()),
sha256: digest(
&serde_json::to_value(action).map_err(|error| error.to_string())?,
)?,
});
}
}
Ok(actions)
}
pub(crate) fn required(
&self,
observation: ManualFillObservation<'_>,
data: &DataSet,
) -> Result<bool, String> {
Ok(!self.action_references(observation, data)?.is_empty())
}
pub(crate) fn project(
&self,
source: &ManualExecutionReplay,
applied_count: usize,
observation: ManualFillObservation<'_>,
current: &mut PortfolioState,
data: &DataSet,
fills: &[FillEvent],
has_pending: bool,
) -> Result<(AppliedManualFill, ManualCorporateAdjustment), String> {
if has_pending {
return Err("manual observation conflicts with pending shadow orders".into());
}
let all = source.observations()?;
if all.get(applied_count).is_none_or(|next| {
next.fill.observation_event_id != observation.fill.observation_event_id
}) {
return Err(
"manual corporate observation prefix differs from the immutable trace".into(),
);
}
let actions = self.action_references(observation, data)?;
if actions.is_empty() {
return Err("manual corporate projection has no processed corporate action".into());
}
let at = local(observation.fill.observed_at);
let reference = self.replay(
source,
current.initial_cash_fixed(),
&all[..applied_count],
self.reconciled_count.get(),
data,
fills,
at,
)?;
let expected = current.financial_replay_identity();
let reference_identity = reference.financial_replay_identity();
if reference_identity != expected {
return Err(format!(
"manual corporate ledger coverage mismatch: symbol={} observed_at={} expected={} replayed={}",
observation.order.symbol,
observation.fill.observed_at,
digest(&expected)?,
digest(&reference_identity)?
));
}
let replayed = self.replay(
source,
current.initial_cash_fixed(),
&all[..=applied_count],
applied_count + 1,
data,
fills,
at,
)?;
let replayed_identity = replayed.financial_replay_identity();
let gross = FixedMoney::from_decimal_str(&observation.fill.gross_amount()?.to_string())?;
let fees = FixedMoney::from_decimal_str(&observation.fill.total_fees()?.to_string())?;
let cash_delta = match observation.order.side {
OrderSide::Buy => gross.checked_add(fees).and_then(FixedMoney::checked_neg),
OrderSide::Sell => gross.checked_sub(fees),
}
.ok_or("manual corporate trade cash overflow")?;
let before = current.cash_fixed();
let after = replayed.cash_fixed();
let corporate_cash = after
.checked_sub(before)
.and_then(|delta| delta.checked_sub(cash_delta))
.ok_or("manual corporate adjustment overflow")?;
let symbols = current
.positions()
.keys()
.chain(replayed.positions().keys())
.cloned()
.chain(std::iter::once(observation.order.symbol.clone()))
.collect::<BTreeSet<_>>();
let mut positions = BTreeMap::new();
for symbol in symbols {
let change = ManualCorporatePositionChange {
quantity_before: current
.position(&symbol)
.map_or(0, |position| position.quantity),
quantity_after: replayed
.position(&symbol)
.map_or(0, |position| position.quantity),
cost_basis_before: current
.financial_position_basis(&symbol)
.to_decimal_string(),
cost_basis_after: replayed
.financial_position_basis(&symbol)
.to_decimal_string(),
};
if change.quantity_before != change.quantity_after
|| change.cost_basis_before != change.cost_basis_after
{
positions.insert(symbol, change);
}
}
let quantity_after = replayed
.position(&observation.order.symbol)
.map_or(0, |position| position.quantity);
let adjustment = ManualCorporateAdjustment {
schema: "fidc.manual-corporate-adjustment/v1".into(),
observed_at: observation.fill.observed_at,
cash_dividends_enabled: self.cash_dividends,
dividend_cost_basis_adjustment: self.adjust_cost,
dividend_reinvestment: self.reinvest,
actions,
cash_before: before.to_decimal_string(),
cash_after: after.to_decimal_string(),
corporate_cash_delta: corporate_cash.to_decimal_string(),
positions,
reference_sha256: digest(&reference_identity)?,
replayed_sha256: digest(&replayed_identity)?,
};
current.replace_replayed_financial_book(replayed)?;
Ok((
AppliedManualFill {
gross,
fees,
cash_delta,
quantity_after,
},
adjustment,
))
}
fn replay(
&self,
runtime_input: &ManualExecutionReplay,
initial_cash: FixedMoney,
manual: &[ManualFillObservation<'_>],
economic_count: usize,
data: &DataSet,
fills: &[FillEvent],
at: NaiveDateTime,
) -> Result<PortfolioState, String> {
enum Event<'a> {
Session,
Cash(&'a CashEffect),
Corporate(NaiveDate),
Settle(NaiveDate),
Manual(ManualFillObservation<'a>),
Simulated(&'a FillEvent),
Close(NaiveDate),
}
let mut events = Vec::new();
let sessions = self.sessions.borrow();
let closed = self.closed.borrow();
let cash = self.cash.borrow();
for date in &*sessions {
let clock = date.and_hms_opt(0, 0, 0).unwrap();
events.push((clock, 0, 0, Event::Session));
events.push((clock, 2, 0, Event::Corporate(*date)));
events.push((clock, 3, 0, Event::Settle(*date)));
}
for (index, effect) in cash.iter().enumerate() {
events.push((effect.at, 1, index, Event::Cash(effect)));
}
for (index, observation) in manual.iter().enumerate() {
let clock = if index < economic_count {
local(observation.fill.executed_at)
} else {
local(observation.fill.observed_at)
};
if clock.date() < self.first_date {
return Err("manual corporate execution precedes the represented initial ledger; opening facts are required".into());
}
events.push((clock, 4, fills.len() + index, Event::Manual(*observation)));
}
for (index, fill) in fills.iter().enumerate() {
fill.validate()?;
if fill.origin == crate::events::FillOrigin::DividendReinvestment {
// The declared accounting model is recalculated from the
// corrected entitlements; this was never a submitted order.
continue;
}
let date = fill.execution_date.unwrap_or(fill.date);
// This is the frozen daily matching model, not a broker timestamp.
// Intraday contracts must supply their actual execution clock.
let clock = match fill.execution_timestamp {
Some(clock) => clock,
None if matches!(
self.matching,
MatchingType::OpenAuction | MatchingType::NextBarOpen
) =>
{
date.and_hms_opt(9, 30, 0).unwrap()
}
None if self.matching == MatchingType::CurrentBarClose
&& self.daily_price == PriceField::Close =>
{
date.and_hms_opt(15, 0, 0).unwrap()
}
None => {
return Err(
"manual corporate replay lacks an intraday fill execution clock".into(),
);
}
};
events.push((clock, 4, index, Event::Simulated(fill)));
}
for date in &*closed {
events.push((
date.and_hms_nano_opt(23, 59, 59, 999_999_999)
.unwrap()
.min(at),
5,
0,
Event::Close(*date),
));
}
events.sort_by_key(|(clock, priority, sequence, _)| (*clock, *priority, *sequence));
let mut book = PortfolioState::from_fixed_initial_cash(initial_cash);
for (clock, _, _, event) in events {
if clock > at {
return Err("manual corporate replay contains a future financial fact".into());
}
match event {
Event::Session => book.begin_trading_day(),
Event::Cash(effect) => {
book.apply_cash_delta_fixed(effect.amount)?;
if book.cash_fixed() < FixedMoney::ZERO {
return Err(
"manual corporate replay conflicts with prior cash facts".into()
);
}
}
Event::Corporate(date) => {
crate::corporate_book::apply(
date,
data,
&mut book,
&mut Vec::new(),
self.cash_dividends,
self.adjust_cost,
)
.map_err(|error| error.to_string())?;
}
Event::Settle(date) => {
crate::corporate_book::settle_receivables(date, data, &mut book, &mut Vec::new(), self.reinvest, Some(runtime_input))
.map_err(|error| error.to_string())?;
}
Event::Manual(observation) => {
observation.apply(&mut book, data, false)?;
}
Event::Simulated(fill) => {
let gross = FixedMoney::from_f64(fill.gross_amount)
.ok_or("invalid simulated gross amount")?;
let fees = FixedMoney::checked_sum_f64([
fill.commission,
fill.stamp_tax,
fill.transfer_fee,
])
.ok_or("invalid simulated fee amount")?;
book.apply_observed_manual_fill(
fill.execution_date.unwrap_or(fill.date),
&fill.symbol,
fill.side,
fill.quantity,
fill.price,
fill.price,
gross,
fees,
)?;
book.prune_flat_positions();
}
Event::Close(date) => {
book.update_prices_with_options(
date,
data,
PriceField::Close,
self.same_day_mark_at_fill,
)
.map_err(|error| error.to_string())?;
}
}
if book.cash_fixed() < FixedMoney::ZERO {
return Err("manual corporate replay would borrow unobserved cash".into());
}
}
Ok(book)
}
}
fn local(value: DateTime<Utc>) -> NaiveDateTime {
value
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
.naive_local()
}
fn digest(value: &serde_json::Value) -> Result<String, String> {
Ok(format!(
"{:x}",
Sha256::digest(serde_json::to_vec(value).map_err(|error| error.to_string())?)
))
}
+748
View File
@@ -0,0 +1,748 @@
//! Confirmed manual fills are external observations, not simulated broker fills.
//! The producer must bind these records to the runtime's durable order/audit facts.
use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, FixedOffset, NaiveDate, Timelike, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::events::OrderSide;
use crate::{DataSet, FixedMoney, PortfolioState};
use rust_decimal::prelude::ToPrimitive;
pub const MANUAL_REPLAY_SCHEMA: &str = "fidc.observed-manual-executions/v3";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualExecutionReplay {
pub schema: String,
pub runtime_id: String,
pub account_id: String,
pub source_contract_sha256: String,
pub content_sha256: String,
pub observation_cutoff: DateTime<Utc>,
pub actions: Vec<ManualExecutionAction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub legacy_position_exposure_bps: BTreeMap<NaiveDate, i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualExecutionAction {
pub action_id: String,
pub source: ManualExecutionSource,
pub audit_event_ids: Vec<String>,
pub confirmed_at: DateTime<Utc>,
pub confirmation_observed_at: DateTime<Utc>,
pub outcome: ManualActionOutcome,
pub orders: Vec<ManualExecutionOrder>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ManualActionOutcome {
NoOrdersNeeded,
NotExecuted,
OrdersTerminal,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ManualExecutionSource {
ManualSecurityTrade,
ManualPositionAction,
ManualRebalance,
StockPoolAllocation,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualExecutionOrder {
pub order_id: String,
pub broker_order_id: Option<String>,
pub source_adapter: Option<String>,
pub symbol: String,
pub side: OrderSide,
pub quantity: u32,
pub order_created_at: DateTime<Utc>,
pub terminal_observed_at: DateTime<Utc>,
pub terminal_status: ManualOrderTerminalStatus,
pub fills: Vec<ManualExecutionFill>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ManualOrderTerminalStatus {
Filled,
Cancelled,
Rejected,
Expired,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualExecutionFill {
pub trade_id: String,
pub observation_event_id: String,
pub observation_sequence: u64,
pub fee_observation_event_id: String,
pub fee_observation_sequence: u64,
pub fee_observed_at: DateTime<Utc>,
pub trade_date: NaiveDate,
pub executed_at: DateTime<Utc>,
pub observed_at: DateTime<Utc>,
pub timestamp_precision: ManualTimestampPrecision,
pub quantity: u32,
#[serde(with = "rust_decimal::serde::str")]
pub price: Decimal,
#[serde(default, with = "rust_decimal::serde::str_option")]
pub commission: Option<Decimal>,
#[serde(default, with = "rust_decimal::serde::str_option")]
pub stamp_tax: Option<Decimal>,
#[serde(default, with = "rust_decimal::serde::str_option")]
pub transfer_fee: Option<Decimal>,
/// Full observed charge, including any venue fees not itemized above.
#[serde(with = "rust_decimal::serde::str")]
pub total_fee: Decimal,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ManualTimestampPrecision {
Second,
Millisecond,
Microsecond,
Nanosecond,
}
impl ManualTimestampPrecision {
fn nanoseconds(self) -> i64 {
match self {
Self::Second => 1_000_000_000,
Self::Millisecond => 1_000_000,
Self::Microsecond => 1_000,
Self::Nanosecond => 1,
}
}
}
impl ManualExecutionFill {
pub fn gross_amount(&self) -> Result<Decimal, String> {
self.price
.checked_mul(Decimal::from(self.quantity))
.ok_or_else(|| "manual fill gross amount overflow".into())
}
pub fn total_fees(&self) -> Result<Decimal, String> {
let known = [self.commission, self.stamp_tax, self.transfer_fee]
.into_iter()
.flatten()
.try_fold(Decimal::ZERO, |sum, fee| {
if fee < Decimal::ZERO {
return Err("manual fill fee component is negative");
}
sum.checked_add(fee).ok_or("manual fill fees overflow")
})?;
if self.total_fee < known {
return Err("manual total fee is below its known components".into());
}
Ok(self.total_fee)
}
}
fn identifier(value: &str) -> Result<(), String> {
if value.is_empty()
|| value.trim() != value
|| value.len() > 256
|| value.chars().any(char::is_control)
{
return Err("manual execution identity is empty, untrimmed or invalid".into());
}
Ok(())
}
impl ManualExecutionReplay {
/// Market/indicator data is needed for securities whose observed fills
/// change the portfolio. A rejected, never-filled order is not data demand.
pub fn required_data_symbols(&self) -> Result<BTreeSet<String>, String> {
self.validate()?;
Ok(self
.actions
.iter()
.flat_map(|action| &action.orders)
.filter(|order| !order.fills.is_empty())
.map(|order| order.symbol.clone())
.collect())
}
pub fn observations(&self) -> Result<Vec<ManualFillObservation<'_>>, String> {
self.validate()?;
let mut observations = Vec::new();
for action in &self.actions {
for order in &action.orders {
for fill in &order.fills {
observations.push(ManualFillObservation {
action,
order,
fill,
});
}
}
}
observations.sort_by_key(|entry| (entry.fill.observed_at, entry.fill.observation_sequence));
Ok(observations)
}
pub fn content_digest(&self) -> Result<String, String> {
let mut value = serde_json::to_value(self).map_err(|error| error.to_string())?;
value
.as_object_mut()
.ok_or("manual replay is not an object")?
.remove("contentSha256");
let bytes = serde_json::to_vec(&value).map_err(|error| error.to_string())?;
Ok(format!("{:x}", Sha256::digest(bytes)))
}
pub fn validate(&self) -> Result<(), String> {
if self.schema != MANUAL_REPLAY_SCHEMA
&& self.schema != "fidc.observed-manual-executions/v2"
{
return Err("unsupported manual replay schema".into());
}
if self.schema == "fidc.observed-manual-executions/v2"
&& (!self.position_exposure_events.is_empty()
|| !self.legacy_position_exposure_bps.is_empty())
{
return Err("runtime configuration requires manual replay v3".into());
}
crate::position_exposure::PositionExposureTimeline::from_events(
&self.position_exposure_events,
)?;
if self.position_exposure_events.iter().any(|event| event.effective_at > self.observation_cutoff) {
return Err("observed runtime position event is after the evidence cutoff".into());
}
if self
.legacy_position_exposure_bps
.values()
.any(|value| !(0..=10000).contains(value))
{
return Err("legacy manual exposure is outside 0..10000 bps".into());
}
identifier(&self.runtime_id)?;
identifier(&self.account_id)?;
if self.source_contract_sha256.len() != 64
|| !self
.source_contract_sha256
.bytes()
.all(|v| v.is_ascii_hexdigit())
{
return Err("manual replay source contract hash is invalid".into());
}
if self.content_digest()? != self.content_sha256 {
return Err("manual replay content digest mismatch".into());
}
if self.actions.len() > 100_000 {
return Err("manual replay action limit exceeded; trace was not truncated".into());
}
let shanghai = FixedOffset::east_opt(8 * 3600).unwrap();
let mut actions = BTreeSet::new();
let mut audits = BTreeSet::new();
let mut orders = BTreeSet::new();
let mut broker_orders = BTreeSet::new();
let mut trades = BTreeSet::new();
let mut observation_events = BTreeSet::new();
let mut observation_sequences = BTreeSet::new();
let mut fee_observations = BTreeSet::new();
let mut receipt_ids = BTreeMap::new();
let mut receipt_sequences = BTreeMap::new();
for action in &self.actions {
identifier(&action.action_id)?;
if !actions.insert(action.action_id.as_str())
|| action.confirmed_at > self.observation_cutoff
|| action.confirmation_observed_at < action.confirmed_at
|| action.confirmation_observed_at > self.observation_cutoff
{
return Err("duplicate manual action or confirmation after cutoff".into());
}
if action.audit_event_ids.is_empty() {
return Err("manual action has no immutable audit binding".into());
}
if (action.outcome != ManualActionOutcome::OrdersTerminal) != action.orders.is_empty() {
return Err("manual action outcome does not prove its order coverage".into());
}
for id in &action.audit_event_ids {
identifier(id)?;
if !audits.insert(id.as_str()) {
return Err("manual audit event is bound more than once".into());
}
}
for order in &action.orders {
identifier(&order.order_id)?;
if let Some(adapter) = &order.source_adapter {
identifier(adapter)?;
}
identifier(&order.symbol)?;
if let Some(id) = &order.broker_order_id {
identifier(id)?;
if !broker_orders.insert((
order
.source_adapter
.as_deref()
.ok_or("broker identity requires its source adapter")?,
order.order_created_at.with_timezone(&shanghai).date_naive(),
id.as_str(),
)) {
return Err("manual local orders share one broker order identity".into());
}
}
if !order.fills.is_empty() && order.source_adapter.is_none() {
return Err("manual fills require a known source adapter".into());
}
if !order.fills.is_empty()
&& order.source_adapter.as_deref() != Some("paper")
&& order.broker_order_id.is_none()
{
return Err(
"manual broker fills require their original broker order identity".into(),
);
}
if !orders.insert(order.order_id.as_str())
|| order.quantity == 0
|| order.quantity > i32::MAX as u32
{
return Err("duplicate manual order or invalid quantity".into());
}
if order.order_created_at < action.confirmed_at
|| order.terminal_observed_at < order.order_created_at
|| order.terminal_observed_at > self.observation_cutoff
{
return Err(
"manual order confirmation/submission/terminal time is inconsistent".into(),
);
}
let mut filled = 0_u32;
for fill in &order.fills {
identifier(&fill.trade_id)?;
identifier(&fill.observation_event_id)?;
identifier(&fill.fee_observation_event_id)?;
if fill.observation_sequence == 0
|| fill.observation_sequence > i64::MAX as u64
|| !observation_events.insert(fill.observation_event_id.as_str())
|| !observation_sequences.insert(fill.observation_sequence)
{
return Err(
"manual fill requires a unique durable observation event and sequence"
.into(),
);
}
if fill.fee_observation_sequence == 0
|| fill.fee_observation_sequence > i64::MAX as u64
|| fill.fee_observed_at < fill.observed_at
|| fill.fee_observed_at > self.observation_cutoff
|| !fee_observations.insert((
fill.fee_observation_event_id.as_str(),
fill.fee_observation_sequence,
))
{
return Err("manual finalized fees require their own unique observation within the cutoff".into());
}
if (fill.fee_observation_event_id == fill.observation_event_id)
!= (fill.fee_observation_sequence == fill.observation_sequence)
|| (fill.fee_observation_event_id == fill.observation_event_id
&& fill.fee_observed_at != fill.observed_at)
{
return Err("manual fill and fee observation identities disagree".into());
}
if !trades.insert((fill.trade_date, fill.trade_id.as_str()))
|| fill.quantity == 0
{
return Err("duplicate manual trade or zero fill quantity".into());
}
for (id, sequence) in [
(&fill.observation_event_id, fill.observation_sequence),
(
&fill.fee_observation_event_id,
fill.fee_observation_sequence,
),
] {
if receipt_ids
.insert(id, (&fill.trade_id, sequence))
.is_some_and(|owner| owner != (&fill.trade_id, sequence))
|| receipt_sequences
.insert(sequence, (&fill.trade_id, id))
.is_some_and(|owner| owner != (&fill.trade_id, id))
{
return Err("manual observation identity is reused by a different trade or sequence".into());
}
}
if fill.executed_at.with_timezone(&shanghai).date_naive() != fill.trade_date
|| fill.observed_at > self.observation_cutoff
|| fill.observed_at < order.order_created_at
|| fill.observed_at < action.confirmation_observed_at
|| fill.observed_at < fill.executed_at
|| fill.executed_at > order.terminal_observed_at
{
return Err("manual fill execution/observation time is inconsistent".into());
}
if i64::from(fill.executed_at.nanosecond())
% fill.timestamp_precision.nanoseconds()
!= 0
{
return Err(
"broker timestamp contains digits finer than its declared precision"
.into(),
);
}
let upper = fill
.executed_at
.checked_add_signed(chrono::Duration::nanoseconds(
fill.timestamp_precision.nanoseconds(),
))
.ok_or("manual execution timestamp overflow")?;
let earliest = order.order_created_at.max(action.confirmation_observed_at);
if fill.executed_at < earliest && earliest >= upper {
return Err("manual fill predates its order or durable confirmation".into());
}
if fill.price <= Decimal::ZERO {
return Err("manual fill requires a positive price".into());
}
fill.gross_amount()?
.checked_add(fill.total_fees()?)
.ok_or("manual fill cash amount overflow")?;
filled = filled
.checked_add(fill.quantity)
.ok_or("manual cumulative fill quantity overflow")?;
}
if filled > order.quantity
|| (order.terminal_status == ManualOrderTerminalStatus::Filled
&& filled != order.quantity)
|| (order.terminal_status == ManualOrderTerminalStatus::Rejected && filled != 0)
|| (matches!(
order.terminal_status,
ManualOrderTerminalStatus::Cancelled | ManualOrderTerminalStatus::Expired
) && filled == order.quantity)
{
return Err("manual terminal status disagrees with cumulative fills".into());
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
pub struct ManualFillObservation<'a> {
pub action: &'a ManualExecutionAction,
pub order: &'a ManualExecutionOrder,
pub fill: &'a ManualExecutionFill,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AppliedManualFill {
pub gross: FixedMoney,
pub fees: FixedMoney,
pub cash_delta: FixedMoney,
pub quantity_after: u32,
}
/// One replay owns its immutable trace and progress. Advancing is atomic even
/// if a later receipt in the same step disagrees with the shadow account.
pub struct ManualReplayCursor {
replay: std::sync::Arc<ManualExecutionReplay>,
indices: Vec<(usize, usize, usize)>,
cursor: usize,
clock: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ManualReplayApplication {
pub action_id: String,
pub order_id: String,
pub trade_id: String,
pub observation_event_id: String,
pub observation_sequence: u64,
pub observed_at: DateTime<Utc>,
pub fee_observation_event_id: String,
pub fee_observed_at: DateTime<Utc>,
pub executed_at: DateTime<Utc>,
pub symbol: String,
pub side: OrderSide,
pub quantity: u32,
pub quantity_after: u32,
pub price: String,
pub commission: Option<String>,
pub stamp_tax: Option<String>,
pub transfer_fee: Option<String>,
pub source_total_fee: String,
pub source_gross_amount: String,
pub ledger_gross_amount: String,
pub ledger_fees: String,
pub cash_delta: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub corporate_adjustment: Option<ManualCorporateAdjustment>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualCorporateAdjustment {
pub schema: String,
pub observed_at: DateTime<Utc>,
pub cash_dividends_enabled: bool,
pub dividend_cost_basis_adjustment: bool,
#[serde(default, skip_serializing_if = "disabled_flag")]
pub dividend_reinvestment: bool,
pub actions: Vec<ManualCorporateActionReference>,
pub cash_before: String,
pub cash_after: String,
pub corporate_cash_delta: String,
pub positions: BTreeMap<String, ManualCorporatePositionChange>,
pub reference_sha256: String,
pub replayed_sha256: String,
}
fn disabled_flag(value: &bool) -> bool { !value }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualCorporateActionReference {
pub date: NaiveDate,
pub symbol: String,
pub successor_symbol: Option<String>,
pub share_cash: String,
pub split_ratio: String,
pub successor_ratio: Option<String>,
pub successor_cash: Option<String>,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualCorporatePositionChange {
pub quantity_before: u32,
pub quantity_after: u32,
pub cost_basis_before: String,
pub cost_basis_after: String,
}
impl ManualReplayCursor {
pub(crate) fn frozen_source(&self) -> std::sync::Arc<ManualExecutionReplay> {
self.replay.clone()
}
pub(crate) fn next_observation(&self) -> Option<ManualFillObservation<'_>> {
self.indices.get(self.cursor).map(|&(a, o, f)| ManualFillObservation {
action: &self.replay.actions[a], order: &self.replay.actions[a].orders[o],
fill: &self.replay.actions[a].orders[o].fills[f],
})
}
pub(crate) fn advance_next_projected<F>(
&mut self, portfolio: &mut PortfolioState, project: F,
) -> Result<Option<ManualReplayApplication>, String>
where F: FnOnce(ManualFillObservation<'_>, &mut PortfolioState) -> Result<(AppliedManualFill, ManualCorporateAdjustment), String> {
let Some(observation) = self.next_observation() else { return Ok(None); };
let at = observation.fill.observed_at;
if at > self.replay.observation_cutoff || self.clock.is_some_and(|clock| at < clock) {
return Err("manual projected observation clock violates the frozen trace".into());
}
let mut next = portfolio.clone();
let (applied, adjustment) = project(observation, &mut next)?;
let mut application = observation.application(applied)?;
application.corporate_adjustment = Some(adjustment);
crate::finite_serialization::validate(&application).map_err(|error| error.to_string())?;
*portfolio = next;
self.cursor += 1;
self.clock = Some(at);
Ok(Some(application))
}
pub fn new(replay: ManualExecutionReplay) -> Result<Self, String> {
Self::from_shared(std::sync::Arc::new(replay))
}
pub fn from_shared(replay: std::sync::Arc<ManualExecutionReplay>) -> Result<Self, String> {
replay.validate()?;
let mut indices = Vec::new();
for (a, action) in replay.actions.iter().enumerate() {
for (o, order) in action.orders.iter().enumerate() {
for f in 0..order.fills.len() {
indices.push((a, o, f));
}
}
}
indices.sort_by_key(|&(a, o, f)| {
let fill = &replay.actions[a].orders[o].fills[f];
(fill.observed_at, fill.observation_sequence)
});
Ok(Self {
replay,
indices,
cursor: 0,
clock: None,
})
}
pub fn next_observation_at(&self) -> Option<DateTime<Utc>> {
self.indices
.get(self.cursor)
.map(|&(a, o, f)| self.replay.actions[a].orders[o].fills[f].observed_at)
}
pub fn applied_count(&self) -> usize {
self.cursor
}
pub fn advance(
&mut self,
at: DateTime<Utc>,
portfolio: &mut PortfolioState,
data: &DataSet,
has_pending_orders: bool,
) -> Result<Vec<ManualReplayApplication>, String> {
let end = self.cursor
+ self.indices[self.cursor..].iter().take_while(|&&(a, o, f)| {
self.replay.actions[a].orders[o].fills[f].observed_at <= at
}).count();
self.advance_through(at, end, portfolio, data, has_pending_orders)
}
/// One receipt at a time lets callbacks observe the intermediate state
/// when multiple fills share a timestamp but have distinct durable sequences.
pub fn advance_next(
&mut self, portfolio: &mut PortfolioState, data: &DataSet, has_pending_orders: bool,
) -> Result<Option<ManualReplayApplication>, String> {
let Some(at) = self.next_observation_at() else { return Ok(None); };
let mut applications = self.advance_through(at, self.cursor + 1, portfolio, data, has_pending_orders)?;
Ok(applications.pop())
}
fn advance_through(
&mut self, at: DateTime<Utc>, end: usize, portfolio: &mut PortfolioState,
data: &DataSet, has_pending_orders: bool,
) -> Result<Vec<ManualReplayApplication>, String> {
if at > self.replay.observation_cutoff {
return Err("manual observation clock exceeds the frozen evidence cutoff".into());
}
if self.clock.is_some_and(|clock| at < clock) {
return Err("manual observation clock moved backwards".into());
}
if end == self.cursor {
self.clock = Some(at);
return Ok(vec![]);
}
let mut next = portfolio.clone();
let mut applications = Vec::with_capacity(end - self.cursor);
for &(a, o, f) in &self.indices[self.cursor..end] {
let action = &self.replay.actions[a];
let order = &action.orders[o];
let fill = &order.fills[f];
let applied = ManualFillObservation {
action,
order,
fill,
}
.apply(&mut next, data, has_pending_orders)?;
applications.push(ManualReplayApplication {
action_id: action.action_id.clone(),
order_id: order.order_id.clone(),
trade_id: fill.trade_id.clone(),
observation_event_id: fill.observation_event_id.clone(),
observation_sequence: fill.observation_sequence,
observed_at: fill.observed_at,
fee_observation_event_id: fill.fee_observation_event_id.clone(),
fee_observed_at: fill.fee_observed_at,
executed_at: fill.executed_at,
symbol: order.symbol.clone(),
side: order.side,
quantity: fill.quantity,
quantity_after: applied.quantity_after,
price: fill.price.to_string(),
commission: fill.commission.map(|fee| fee.to_string()),
stamp_tax: fill.stamp_tax.map(|fee| fee.to_string()),
transfer_fee: fill.transfer_fee.map(|fee| fee.to_string()),
source_total_fee: fill.total_fee.to_string(),
source_gross_amount: fill.gross_amount()?.to_string(),
ledger_gross_amount: applied.gross.to_decimal_string(),
ledger_fees: applied.fees.to_decimal_string(),
cash_delta: applied.cash_delta.to_decimal_string(),
corporate_adjustment: None,
});
}
*portfolio = next;
self.cursor = end;
self.clock = Some(at);
Ok(applications)
}
}
impl ManualFillObservation<'_> {
fn application(&self, applied: AppliedManualFill) -> Result<ManualReplayApplication, String> {
Ok(ManualReplayApplication {
action_id: self.action.action_id.clone(), order_id: self.order.order_id.clone(),
trade_id: self.fill.trade_id.clone(), observation_event_id: self.fill.observation_event_id.clone(),
observation_sequence: self.fill.observation_sequence, observed_at: self.fill.observed_at,
fee_observation_event_id: self.fill.fee_observation_event_id.clone(), fee_observed_at: self.fill.fee_observed_at,
executed_at: self.fill.executed_at, symbol: self.order.symbol.clone(), side: self.order.side,
quantity: self.fill.quantity, quantity_after: applied.quantity_after, price: self.fill.price.to_string(),
commission: self.fill.commission.map(|fee| fee.to_string()), stamp_tax: self.fill.stamp_tax.map(|fee| fee.to_string()),
transfer_fee: self.fill.transfer_fee.map(|fee| fee.to_string()), source_total_fee: self.fill.total_fee.to_string(),
source_gross_amount: self.fill.gross_amount()?.to_string(), ledger_gross_amount: applied.gross.to_decimal_string(),
ledger_fees: applied.fees.to_decimal_string(), cash_delta: applied.cash_delta.to_decimal_string(), corporate_adjustment: None,
})
}
pub(crate) fn apply(
&self,
portfolio: &mut PortfolioState,
data: &DataSet,
has_pending_orders: bool,
) -> Result<AppliedManualFill, String> {
if has_pending_orders {
return Err("manual observation conflicts with pending shadow orders".into());
}
let instrument = data
.instrument(&self.order.symbol)
.ok_or("manual observation instrument is absent from frozen source data")?;
if instrument
.dated_market_absence_reason(self.fill.trade_date)
.is_some()
{
return Err("manual execution contradicts the frozen instrument lifecycle".into());
}
let gross = FixedMoney::from_decimal_str(&self.fill.gross_amount()?.to_string())?;
let fees = FixedMoney::from_decimal_str(&self.fill.total_fees()?.to_string())?;
let price = self
.fill
.price
.to_f64()
.filter(|price| price.is_finite() && *price > 0.)
.ok_or("manual execution price cannot be represented for valuation")?;
// This is the real observed trade price, not a fabricated quote. The
// normal market clock remains responsible for subsequent marks.
let cash_delta = portfolio.apply_observed_manual_fill(
self.fill.trade_date,
&self.order.symbol,
self.order.side,
self.fill.quantity,
price,
price,
gross,
fees,
)?;
Ok(AppliedManualFill {
gross,
fees,
cash_delta,
quantity_after: portfolio
.position(&self.order.symbol)
.map_or(0, |position| position.quantity),
})
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,761 @@
use super::*;
use serde_json::{Value, json};
fn sample() -> ManualExecutionReplay {
let fill = json!({"tradeId":"trade-1","observationEventId":"received-1","observationSequence":1,"tradeDate":"2026-09-14","executedAt":"2026-09-14T01:30:00Z",
"feeObservationEventId":"received-1","feeObservationSequence":1,"feeObservedAt":"2026-09-14T01:30:01Z",
"observedAt":"2026-09-14T01:30:01Z","timestampPrecision":"second","quantity":100,
"price":"10.1234567891","commission":"0.1000001","stampTax":"0","transferFee":"0.02","totalFee":"0.1200001"});
let mut input:ManualExecutionReplay=serde_json::from_value(json!({
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"runtime-1","accountId":"account-1",
"sourceContractSha256":"a".repeat(64),"contentSha256":"", "observationCutoff":"2026-09-14T08:00:00Z",
"actions":[{"actionId":"action-1","source":"manual_security_trade","auditEventIds":["audit-1"],
"confirmedAt":"2026-09-14T01:30:00.500Z","confirmationObservedAt":"2026-09-14T01:30:00.550Z","outcome":"orders_terminal","orders":[{
"orderId":"order-1","brokerOrderId":"broker-1","sourceAdapter":"gt-api","symbol":"000001.SZ","side":"Buy","quantity":100,
"orderCreatedAt":"2026-09-14T01:30:00.600Z","terminalObservedAt":"2026-09-14T01:30:00.900Z","terminalStatus":"filled",
"fills":[fill]
}]}]
})).unwrap();
reseal(&mut input);
input
}
fn reseal(input: &mut ManualExecutionReplay) {
input.content_sha256 = input.content_digest().unwrap();
}
#[test]
fn a_failed_corporate_projection_does_not_change_the_book_or_receipt_cursor() {
let mut cursor = ManualReplayCursor::new(sample()).unwrap();
let mut portfolio = PortfolioState::new(10000.);
let before = portfolio.financial_replay_identity();
let error = cursor.advance_next_projected(&mut portfolio, |_, next| {
next.apply_cash_delta(-50.)?;
next.position_mut("000001.SZ").buy(NaiveDate::from_ymd_opt(2026, 9, 11).unwrap(), 100, 10.);
Err("financial coverage mismatch".into())
}).unwrap_err();
assert_eq!(error, "financial coverage mismatch");
assert_eq!(portfolio.financial_replay_identity(), before);
assert_eq!(cursor.applied_count(), 0);
}
fn delayed_buy_replay() -> ManualExecutionReplay {
let mut input = sample();
let template = input.actions[0].clone();
input.actions.clear();
for (index, side, executed, observed, price, fee) in [
(
0,
OrderSide::Buy,
"2026-09-14T01:30:00Z",
"2026-09-14T01:30:01Z",
"20",
"0.25",
),
(
1,
OrderSide::Buy,
"2026-09-11T06:00:00Z",
"2026-09-14T01:30:02Z",
"10",
"0.75",
),
(
2,
OrderSide::Sell,
"2026-09-14T01:31:00Z",
"2026-09-14T01:31:01Z",
"10",
"0.5",
),
(
3,
OrderSide::Sell,
"2026-09-14T01:32:00Z",
"2026-09-14T01:32:01Z",
"10",
"0.5",
),
] {
let executed: DateTime<Utc> = executed.parse().unwrap();
let observed: DateTime<Utc> = observed.parse().unwrap();
let mut action = template.clone();
action.action_id = format!("action-{index}");
action.audit_event_ids = vec![format!("audit-{index}")];
action.confirmed_at = executed - chrono::Duration::seconds(2);
action.confirmation_observed_at = action.confirmed_at;
let order = &mut action.orders[0];
order.order_id = format!("order-{index}");
order.broker_order_id = Some(format!("broker-{index}"));
order.side = side;
order.order_created_at = executed - chrono::Duration::seconds(1);
order.terminal_observed_at = observed;
let fill = &mut order.fills[0];
fill.trade_id = format!("trade-{index}");
fill.observation_event_id = format!("receipt-{index}");
fill.observation_sequence = index + 1;
fill.fee_observation_event_id = fill.observation_event_id.clone();
fill.fee_observation_sequence = fill.observation_sequence;
fill.trade_date = executed
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
.date_naive();
fill.executed_at = executed;
fill.observed_at = observed;
fill.fee_observed_at = observed;
fill.price = price.parse().unwrap();
fill.commission = None;
fill.stamp_tax = None;
fill.transfer_fee = None;
fill.total_fee = fee.parse().unwrap();
input.actions.push(action);
}
reseal(&mut input);
input.validate().unwrap();
input
}
#[test]
fn late_buy_retains_the_earliest_opening_and_latest_buy_dates() {
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut portfolio = PortfolioState::new(10000.);
let applications = cursor
.advance(
"2026-09-14T01:30:02Z".parse().unwrap(),
&mut portfolio,
&data,
false,
)
.unwrap();
assert_eq!(
applications
.iter()
.map(|row| row.trade_id.as_str())
.collect::<Vec<_>>(),
["trade-0", "trade-1"]
);
let position = portfolio.position("000001.SZ").unwrap();
assert_eq!(position.opened_date(), NaiveDate::from_ymd_opt(2026, 9, 11));
assert_eq!(
position.last_buy_date(),
NaiveDate::from_ymd_opt(2026, 9, 14)
);
assert_eq!(position.quantity, 200);
let calendar = crate::TradingCalendar::new(
[11, 14, 15, 16, 17, 18]
.map(|day| NaiveDate::from_ymd_opt(2026, 9, day).unwrap())
.into(),
);
let evidence = crate::holding_policy::HoldingLifecycleEvidence {
has_position: true,
opened_date: position.opened_date(),
last_buy_date: position.last_buy_date(),
last_sell_date: None,
};
let mut policy = crate::holding_policy::AutomaticTradeProtection {
max_holding_days: 1,
..Default::default()
};
assert!(
policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(),
&evidence,
&calendar
)
.unwrap()
.max_holding_exit
);
policy.buy_protection_days = 3;
for day in [14, 15, 16, 17] {
let permission = policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, day).unwrap(),
&evidence,
&calendar,
)
.unwrap();
assert_eq!(permission.sell_denial, Some("buy_fill_protection"));
assert!(!permission.max_holding_exit);
}
assert!(
policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, 18).unwrap(),
&evidence,
&calendar
)
.unwrap()
.max_holding_exit
);
}
#[test]
fn late_buy_fifo_depletion_preserves_costs_and_cannot_unlock_today_lots() {
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut portfolio = PortfolioState::new(10000.);
let applications = cursor
.advance(
"2026-09-14T01:31:01Z".parse().unwrap(),
&mut portfolio,
&data,
false,
)
.unwrap();
assert_eq!(applications.len(), 3);
let position = portfolio.position("000001.SZ").unwrap();
assert_eq!(position.quantity, 100);
assert_eq!(position.unrealized_pnl(), -1000.25);
assert_eq!(
position.sellable_qty(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()),
0
);
assert_eq!(position.realized_pnl(), -0.75);
assert_eq!(portfolio.cash(), 7998.5);
assert_eq!(portfolio.external_cash_flow_total(), 0.);
assert!(
cursor
.advance(
"2026-09-14T01:32:01Z".parse().unwrap(),
&mut portfolio,
&data,
false
)
.unwrap_err()
.contains("T+1")
);
assert_eq!(cursor.applied_count(), 3);
assert_eq!(portfolio.cash(), 7998.5);
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
}
fn semantic_result(input: &ManualExecutionReplay) -> Result<(), String> {
let mut input = input.clone();
reseal(&mut input);
input.validate()
}
#[test]
fn complete_exact_decimal_evidence_allows_later_observation_and_retains_source_digits() {
let input = sample();
input.validate().unwrap();
let fill = &input.actions[0].orders[0].fills[0];
assert_eq!(fill.gross_amount().unwrap().to_string(), "1012.3456789100");
assert_eq!(fill.total_fees().unwrap().to_string(), "0.1200001");
assert_eq!(
serde_json::to_value(&input).unwrap()["actions"][0]["orders"][0]["fills"][0]["price"],
"10.1234567891"
);
}
#[test]
fn data_scope_only_contains_actual_filled_securities_and_validates_the_source() {
let mut input = sample();
let mut rejected = input.actions[0].orders[0].clone();
rejected.order_id = "rejected-order".into();
rejected.broker_order_id = None;
rejected.source_adapter = None;
rejected.symbol = "510300.SH".into();
rejected.terminal_status = ManualOrderTerminalStatus::Rejected;
rejected.fills.clear();
input.actions[0].orders.push(rejected);
reseal(&mut input);
assert_eq!(
input.required_data_symbols().unwrap(),
BTreeSet::from(["000001.SZ".into()])
);
input.actions[0].orders[0].symbol = "600000.SH".into();
assert!(input.required_data_symbols().is_err());
}
#[test]
fn v2_facts_keep_their_encoding_but_cannot_silently_carry_new_runtime_settings() {
let mut input = sample();
input.schema = "fidc.observed-manual-executions/v2".into();
reseal(&mut input);
input.validate().unwrap();
let old = serde_json::to_value(&input).unwrap();
assert!(old.get("positionExposureEvents").is_none());
assert!(old.get("legacyPositionExposureBps").is_none());
input
.legacy_position_exposure_bps
.insert(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(), 5000);
reseal(&mut input);
assert!(input.validate().is_err());
input.schema = MANUAL_REPLAY_SCHEMA.into();
reseal(&mut input);
input.validate().unwrap();
}
#[test]
fn runtime_position_events_cannot_claim_observations_after_the_source_cutoff() {
let mut input = sample();
input.position_exposure_events.push(serde_json::from_value(json!({
"eventId": "position-event", "sequence": 1, "effectiveAt": input.observation_cutoff,
"action": "scale", "requestedBps": 5000
})).unwrap());
semantic_result(&input).unwrap();
input.position_exposure_events[0].effective_at += chrono::Duration::nanoseconds(1);
assert!(semantic_result(&input).unwrap_err().contains("after the evidence cutoff"));
}
#[test]
fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() {
let original = serde_json::to_value(sample()).unwrap();
for field in ["price", "totalFee"] {
let mut missing = original.clone();
missing["actions"][0]["orders"][0]["fills"][0]
.as_object_mut()
.unwrap()
.remove(field);
assert!(
serde_json::from_value::<ManualExecutionReplay>(missing).is_err(),
"{field}"
);
let mut numeric = original.clone();
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(1.1);
assert!(
serde_json::from_value::<ManualExecutionReplay>(numeric).is_err(),
"numeric {field}"
);
}
for mutate in [
("schema", json!("unknown")),
("sourceContractSha256", json!("broken")),
("accountId", json!(" ")),
] {
let mut value = original.clone();
value[mutate.0] = mutate.1;
assert!(
semantic_result(&serde_json::from_value::<ManualExecutionReplay>(value).unwrap())
.is_err()
);
}
}
#[test]
fn inconsistent_counts_terminals_audits_and_duplicate_facts_are_rejected() {
let original = sample();
let mut invalid = original.clone();
invalid.actions[0].orders[0].quantity = 200;
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
invalid.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Rejected;
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
invalid.actions[0].audit_event_ids.clear();
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
invalid.actions.push(invalid.actions[0].clone());
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
let duplicate = invalid.actions[0].orders[0].fills[0].clone();
invalid.actions[0].orders[0].fills.push(duplicate);
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
invalid.actions[0].orders[0].broker_order_id = None;
assert!(semantic_result(&invalid).is_err());
invalid.actions[0].orders[0].source_adapter = Some("paper".into());
reseal(&mut invalid);
invalid.validate().unwrap();
}
#[test]
fn source_time_precision_is_not_invented_and_submitted_time_must_fit_the_interval() {
let mut input = sample();
input.actions[0].orders[0].order_created_at = "2026-09-14T01:30:00.999999Z".parse().unwrap();
input.actions[0].orders[0].terminal_observed_at = "2026-09-14T01:30:01.500Z".parse().unwrap();
input.actions[0].orders[0].fills[0].observed_at = "2026-09-14T01:30:02Z".parse().unwrap();
input.actions[0].orders[0].fills[0].fee_observed_at =
input.actions[0].orders[0].fills[0].observed_at;
reseal(&mut input);
input.validate().unwrap();
input.actions[0].orders[0].order_created_at = "2026-09-14T01:30:01Z".parse().unwrap();
assert!(semantic_result(&input).is_err());
let mut input = sample();
input.actions[0].orders[0].fills[0].executed_at = "2026-09-14T01:30:00.800Z".parse().unwrap();
assert!(semantic_result(&input).is_err());
input.actions[0].orders[0].fills[0].timestamp_precision = ManualTimestampPrecision::Millisecond;
reseal(&mut input);
input.validate().unwrap();
input.actions[0].orders[0].fills[0].executed_at =
"2026-09-14T01:30:00.800001Z".parse().unwrap();
assert!(semantic_result(&input).is_err());
}
#[test]
fn confirmed_no_order_outcome_is_distinct_from_unconfirmed_or_unknown_work() {
let mut input = sample();
input.actions[0].orders.clear();
assert!(semantic_result(&input).is_err());
input.actions[0].outcome = ManualActionOutcome::NoOrdersNeeded;
reseal(&mut input);
input.validate().unwrap();
input.actions[0].outcome = ManualActionOutcome::NotExecuted;
reseal(&mut input);
input.validate().unwrap();
let mut value = serde_json::to_value(input).unwrap();
value["actions"][0]["outcome"] = json!("result_unknown");
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
}
#[test]
fn raw_timezone_and_cutoff_are_required() {
let mut value = serde_json::to_value(sample()).unwrap();
value["actions"][0]["orders"][0]["fills"][0]["executedAt"] = json!("2026-09-14T09:30:00");
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
let mut input = sample();
input.observation_cutoff = "2026-09-14T01:30:00.700Z".parse().unwrap();
assert!(semantic_result(&input).is_err());
let mut value = serde_json::to_value(sample()).unwrap();
value["actions"][0]["orders"][0]["fills"][0]["totalFee"] = Value::Null;
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
}
#[test]
fn authoritative_total_fee_does_not_require_inventing_unknown_components() {
let mut input = sample();
let fill = &mut input.actions[0].orders[0].fills[0];
fill.commission = None;
fill.stamp_tax = None;
fill.transfer_fee = None;
assert_eq!(
fill.total_fees().unwrap(),
"0.1200001".parse::<Decimal>().unwrap()
);
assert!(semantic_result(&input).is_ok());
let value = serde_json::to_value(&input).unwrap();
assert!(value["actions"][0]["orders"][0]["fills"][0]["commission"].is_null());
assert_eq!(
value["actions"][0]["orders"][0]["fills"][0]["totalFee"],
"0.1200001"
);
for field in ["commission", "stampTax", "transferFee"] {
let mut numeric = value.clone();
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(0.1);
assert!(serde_json::from_value::<ManualExecutionReplay>(numeric).is_err());
}
}
#[test]
fn manual_fee_total_includes_extra_charges_and_rejects_inconsistent_components() {
let mut input = sample();
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
assert!(semantic_result(&input).is_ok());
assert_eq!(
input.actions[0].orders[0].fills[0]
.total_fees()
.unwrap()
.to_string(),
"0.15"
);
input.actions[0].orders[0].fills[0].total_fee = "0.1".parse().unwrap();
assert!(semantic_result(&input).is_err());
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
input.actions[0].orders[0].fills[0].commission = Some(Decimal::NEGATIVE_ONE);
assert!(semantic_result(&input).is_err());
}
#[test]
fn late_fee_evidence_keeps_the_original_fill_observation_clock() {
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut input = sample();
let fill = &mut input.actions[0].orders[0].fills[0];
let original = fill.observed_at;
fill.fee_observation_event_id = "fee-receipt-1".into();
fill.fee_observation_sequence = 2;
fill.fee_observed_at = original + chrono::Duration::hours(1);
let fee_time = fill.fee_observed_at;
reseal(&mut input);
let mut cursor = ManualReplayCursor::new(input).unwrap();
assert_eq!(cursor.next_observation_at(), Some(original));
let mut portfolio = PortfolioState::new(10_000.);
let result = cursor
.advance(original, &mut portfolio, &data, false)
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].observed_at, original);
assert_eq!(result[0].fee_observed_at, fee_time);
assert_eq!(result[0].source_total_fee, "0.1200001");
assert!(
cursor
.advance(fee_time, &mut portfolio, &data, false)
.unwrap()
.is_empty()
);
}
#[test]
fn changing_any_external_price_or_identity_invalidates_the_frozen_trace() {
let input = sample();
let original = input.content_sha256.clone();
let mut changed = input.clone();
changed.actions[0].orders[0].fills[0].price += Decimal::ONE;
assert_ne!(changed.content_digest().unwrap(), original);
assert_eq!(
changed.validate().unwrap_err(),
"manual replay content digest mismatch"
);
let mut changed = input;
changed.account_id = "another-account".into();
assert_ne!(changed.content_digest().unwrap(), original);
assert!(changed.validate().is_err());
}
fn identity_data(listed: NaiveDate) -> DataSet {
DataSet::from_components(
vec![crate::Instrument {
symbol: "000001.SZ".into(),
name: "test".into(),
board: "SZ".into(),
round_lot: 100,
listed_at: Some(listed),
delisted_at: None,
status: "active".into(),
}],
vec![],
vec![],
vec![],
vec![crate::BenchmarkSnapshot {
date: listed,
benchmark: "000300.SH".into(),
open: 100.,
close: 100.,
prev_close: 100.,
volume: 0,
}],
)
.unwrap()
}
#[test]
fn confirmed_manual_fill_changes_cash_and_lots_but_not_external_cash_flow_units() {
let input = sample();
let observations = input.observations().unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut account = PortfolioState::new(10_000.);
let applied = observations[0].apply(&mut account, &data, false).unwrap();
assert_eq!(
applied.gross,
FixedMoney::from_decimal_str("1012.345679").unwrap()
);
assert_eq!(applied.fees, FixedMoney::from_decimal_str("0.12").unwrap());
assert_eq!(account.cash(), 8987.534321);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
assert_eq!(
account
.position("000001.SZ")
.unwrap()
.sellable_qty(input.actions[0].orders[0].fills[0].trade_date),
0
);
assert_eq!(account.external_cash_flow_total(), 0.);
assert_eq!(account.starting_cash(), 10_000.);
}
#[test]
fn manual_mismatches_are_atomic_and_do_not_borrow_shares_cash_or_override_pending_orders() {
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let input = sample();
let observations = input.observations().unwrap();
let mut poor = PortfolioState::new(10.);
assert!(observations[0].apply(&mut poor, &data, false).is_err());
assert_eq!(poor.cash(), 10.);
assert!(poor.positions().is_empty());
let mut account = PortfolioState::new(10_000.);
assert!(observations[0].apply(&mut account, &data, true).is_err());
assert_eq!(account.cash(), 10_000.);
assert!(account.positions().is_empty());
observations[0].apply(&mut account, &data, false).unwrap();
let before = account.cash();
let mut sell = input.clone();
sell.actions[0].orders[0].side = OrderSide::Sell;
reseal(&mut sell);
assert!(
sell.observations().unwrap()[0]
.apply(&mut account, &data, false)
.unwrap_err()
.contains("T+1")
);
assert_eq!(account.cash(), before);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
let unlisted = identity_data(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap());
assert!(
observations[0]
.apply(&mut account, &unlisted, false)
.unwrap_err()
.contains("lifecycle")
);
assert_eq!(account.cash(), before);
}
#[test]
fn the_next_day_manual_sale_keeps_the_actual_quantity_and_fee_contract() {
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let input = sample();
let mut account = PortfolioState::new(10_000.);
input.observations().unwrap()[0]
.apply(&mut account, &data, false)
.unwrap();
let mut sell = input.clone();
let order = &mut sell.actions[0].orders[0];
order.side = OrderSide::Sell;
order.order_created_at += chrono::Duration::days(1);
order.terminal_observed_at += chrono::Duration::days(1);
order.fills[0].trade_date = order.fills[0].trade_date.succ_opt().unwrap();
order.fills[0].executed_at += chrono::Duration::days(1);
order.fills[0].observed_at += chrono::Duration::days(1);
order.fills[0].fee_observed_at += chrono::Duration::days(1);
sell.observation_cutoff += chrono::Duration::days(1);
reseal(&mut sell);
let applied = sell.observations().unwrap()[0]
.apply(&mut account, &data, false)
.unwrap();
assert_eq!(applied.quantity_after, 0);
assert_eq!(account.cash(), 9999.76);
assert_eq!(account.external_cash_flow_total(), 0.);
}
#[test]
fn observations_follow_durable_receipt_order_and_not_input_array_order() {
let mut input = sample();
let mut second = input.actions[0].orders[0].fills[0].clone();
second.trade_id = "trade-2".into();
second.observation_event_id = "received-2".into();
second.observation_sequence = 2;
second.fee_observation_event_id = "received-2".into();
second.fee_observation_sequence = 2;
input.actions[0].orders[0].quantity = 200;
input.actions[0].orders[0].fills.insert(0, second);
reseal(&mut input);
assert_eq!(
input
.observations()
.unwrap()
.iter()
.map(|row| row.fill.observation_sequence)
.collect::<Vec<_>>(),
vec![1, 2]
);
let mut invalid = input.clone();
invalid.actions[0].orders[0].fills[0].observation_sequence = 1;
assert!(
semantic_result(&invalid)
.unwrap_err()
.contains("observation")
);
let mut invalid = input;
invalid.actions[0].orders[0].fills[0].observation_event_id = "received-1".into();
assert!(
semantic_result(&invalid)
.unwrap_err()
.contains("observation")
);
}
#[test]
fn partial_cancel_is_valid_but_full_fill_cannot_be_reported_as_cancelled() {
let mut input = sample();
input.actions[0].orders[0].quantity = 200;
input.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Cancelled;
semantic_result(&input).unwrap();
input.actions[0].orders[0].quantity = 100;
assert!(
semantic_result(&input)
.unwrap_err()
.contains("terminal status")
);
}
#[test]
fn cursor_waits_for_observation_and_never_reapplies_or_rewinds() {
let input = sample();
let at = input.actions[0].orders[0].fills[0].observed_at;
let mut replay = ManualReplayCursor::new(input).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut account = PortfolioState::new(10_000.);
assert_eq!(replay.next_observation_at(), Some(at));
assert!(
replay
.advance(
at - chrono::Duration::milliseconds(1),
&mut account,
&data,
false
)
.unwrap()
.is_empty()
);
assert_eq!(account.cash(), 10_000.);
let records = replay.advance(at, &mut account, &data, false).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].cash_delta, "-1012.465679");
assert_eq!(replay.applied_count(), 1);
assert_eq!(replay.next_observation_at(), None);
let cash = account.cash();
assert!(
replay
.advance(at, &mut account, &data, false)
.unwrap()
.is_empty()
);
assert_eq!(account.cash(), cash);
assert!(
replay
.advance(
at - chrono::Duration::seconds(1),
&mut account,
&data,
false
)
.unwrap_err()
.contains("backwards")
);
}
#[test]
fn failed_multi_receipt_advance_keeps_both_progress_and_portfolio_unchanged() {
let mut input = sample();
let mut next = input.actions[0].orders[0].fills[0].clone();
next.trade_id = "trade-2".into();
next.observation_event_id = "received-2".into();
next.observation_sequence = 2;
next.fee_observation_event_id = "received-2".into();
next.fee_observation_sequence = 2;
input.actions[0].orders[0].quantity = 200;
input.actions[0].orders[0].fills.push(next);
reseal(&mut input);
let at = input.actions[0].orders[0].fills[0].observed_at;
let mut replay = ManualReplayCursor::new(input).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut account = PortfolioState::new(1_500.);
assert!(replay.advance(at, &mut account, &data, false).is_err());
assert_eq!(account.cash(), 1_500.);
assert!(account.positions().is_empty());
assert_eq!(replay.applied_count(), 0);
assert_eq!(replay.next_observation_at(), Some(at));
}
#[test]
fn fixed_money_decimal_text_preserves_micro_units_without_float_conversion() {
for text in [
"0",
"100",
"-100",
"0.000001",
"-0.000001",
"12345678901234567890123456.123456",
] {
assert_eq!(
FixedMoney::from_decimal_str(text)
.unwrap()
.to_decimal_string(),
text
);
}
let min = FixedMoney::from_raw(i128::MIN);
assert!(min.to_decimal_string().starts_with('-'));
}
+43 -2
View File
@@ -93,6 +93,15 @@ pub fn compute_backtest_metrics(
account_events: &[AccountEvent],
initial_cash: f64,
risk_free_contract: Option<&RiskFreeRateContract>,
) -> Result<BacktestMetrics, String> {
compute_backtest_metrics_with_manual(equity_curve, fills, &[], daily_holdings, account_events, initial_cash, risk_free_contract)
}
pub fn compute_backtest_metrics_with_manual(
equity_curve: &[DailyEquityPoint], fills: &[FillEvent],
manual_executions: &[crate::manual_execution::ManualReplayApplication],
daily_holdings: &[HoldingSummary], account_events: &[AccountEvent], initial_cash: f64,
risk_free_contract: Option<&RiskFreeRateContract>,
) -> Result<BacktestMetrics, String> {
let Some(first_point) = equity_curve.first() else {
return Ok(BacktestMetrics {
@@ -229,12 +238,20 @@ pub fn compute_backtest_metrics(
);
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
let turnover_by_date = fills
let mut turnover_by_date = fills
.iter()
.fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| {
*acc.entry(fill.date).or_default() += fill.gross_amount.abs();
acc
});
for execution in manual_executions {
use rust_decimal::prelude::ToPrimitive;
let gross = execution.ledger_gross_amount.parse::<rust_decimal::Decimal>()
.ok().and_then(|value| value.to_f64()).filter(|value| value.is_finite() && *value >= 0.)
.ok_or("manual turnover requires its validated ledger gross amount")?;
let date = execution.observed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive();
*turnover_by_date.entry(date).or_default() += gross;
}
let equity_by_date = equity_curve
.iter()
.map(|point| (point.date, point.total_equity))
@@ -558,7 +575,9 @@ fn alpha_beta(
}
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;
@@ -767,6 +786,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,
+415
View File
@@ -0,0 +1,415 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt;
use std::ops::Index;
use compact_str::CompactString;
use serde::de::{MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Sorted numeric fields stored contiguously, without a tree node per snapshot.
#[derive(Clone, Default, PartialEq)]
pub struct NumericFactorMap {
entries: Vec<(CompactString, f64)>,
}
fn compact_key(key: Cow<'static, str>) -> CompactString {
match key {
Cow::Borrowed(value) => CompactString::const_new(value),
Cow::Owned(value) => CompactString::from(value),
}
}
impl NumericFactorMap {
pub const fn new() -> Self {
Self {
entries: Vec::new(),
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn clear(&mut self) {
self.entries.clear();
}
/// Reserve known new fields without geometric spare capacity per snapshot.
pub fn reserve_exact(&mut self, additional: usize) {
self.entries.reserve_exact(additional);
}
pub fn get(&self, key: &str) -> Option<&f64> {
self.entries
.binary_search_by(|(name, _)| name.as_str().cmp(key))
.ok()
.map(|index| &self.entries[index].1)
}
pub fn get_mut(&mut self, key: &str) -> Option<&mut f64> {
self.entries
.binary_search_by(|(name, _)| name.as_str().cmp(key))
.ok()
.map(|index| &mut self.entries[index].1)
}
pub fn contains_key(&self, key: &str) -> bool {
self.get(key).is_some()
}
pub fn insert(&mut self, key: Cow<'static, str>, value: f64) -> Option<f64> {
self.insert_compact(compact_key(key), value)
}
pub fn insert_compact(&mut self, key: CompactString, value: f64) -> Option<f64> {
if self
.entries
.last()
.is_none_or(|(last, _)| last.as_str() < key.as_str())
{
self.entries.push((key, value));
return None;
}
match self
.entries
.binary_search_by(|(name, _)| name.as_str().cmp(key.as_str()))
{
Ok(index) => Some(std::mem::replace(&mut self.entries[index].1, value)),
Err(index) => {
self.entries.insert(index, (key, value));
None
}
}
}
pub fn remove(&mut self, key: &str) -> Option<f64> {
self.entries
.binary_search_by(|(name, _)| name.as_str().cmp(key))
.ok()
.map(|index| self.entries.remove(index).1)
}
pub fn retain(&mut self, mut keep: impl FnMut(&CompactString, &mut f64) -> bool) {
self.entries.retain_mut(|(key, value)| keep(key, value));
}
pub fn iter(&self) -> Iter<'_> {
Iter(self.entries.iter())
}
pub fn keys(&self) -> impl DoubleEndedIterator<Item = &CompactString> + ExactSizeIterator {
self.entries.iter().map(|(key, _)| key)
}
pub fn values(&self) -> impl DoubleEndedIterator<Item = &f64> + ExactSizeIterator {
self.entries.iter().map(|(_, value)| value)
}
}
impl fmt::Debug for NumericFactorMap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_map().entries(self).finish()
}
}
impl Index<&str> for NumericFactorMap {
type Output = f64;
fn index(&self, key: &str) -> &Self::Output {
self.get(key).expect("numeric factor key not found")
}
}
pub struct Iter<'a>(std::slice::Iter<'a, (CompactString, f64)>);
impl<'a> Iterator for Iter<'a> {
type Item = (&'a CompactString, &'a f64);
fn next(&mut self) -> Option<Self::Item> {
self.0.next().map(|(k, v)| (k, v))
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.0.size_hint()
}
}
impl DoubleEndedIterator for Iter<'_> {
fn next_back(&mut self) -> Option<Self::Item> {
self.0.next_back().map(|(k, v)| (k, v))
}
}
impl ExactSizeIterator for Iter<'_> {}
impl<'a> IntoIterator for &'a NumericFactorMap {
type Item = (&'a CompactString, &'a f64);
type IntoIter = Iter<'a>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl IntoIterator for NumericFactorMap {
type Item = (CompactString, f64);
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}
impl FromIterator<(Cow<'static, str>, f64)> for NumericFactorMap {
fn from_iter<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(iter: T) -> Self {
iter.into_iter().map(|(key, value)| (compact_key(key), value)).collect()
}
}
impl FromIterator<(CompactString, f64)> for NumericFactorMap {
fn from_iter<T: IntoIterator<Item = (CompactString, f64)>>(iter: T) -> Self {
let mut entries: Vec<_> = iter.into_iter().collect();
// Stable sorting preserves last-value-wins for repeated input keys.
if !entries.windows(2).all(|pair| pair[0].0 <= pair[1].0) {
entries.sort_by(|left, right| left.0.cmp(&right.0));
}
entries.dedup_by(|later, earlier| {
if later.0 == earlier.0 {
earlier.1 = later.1;
true
} else {
false
}
});
Self { entries }
}
}
impl Extend<(Cow<'static, str>, f64)> for NumericFactorMap {
fn extend<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(&mut self, iter: T) {
self.extend(iter.into_iter().map(|(key, value)| (compact_key(key), value)));
}
}
impl Extend<(CompactString, f64)> for NumericFactorMap {
fn extend<T: IntoIterator<Item = (CompactString, f64)>>(&mut self, iter: T) {
let mut incoming: Self = iter.into_iter().collect();
if incoming.is_empty() {
return;
}
if self.is_empty() {
*self = incoming;
return;
}
if self.entries.last().unwrap().0 < incoming.entries[0].0 {
self.entries.append(&mut incoming.entries);
return;
}
// Merge sorted sets in linear time; wide factor batches must not shift
// the existing vector once per field. Existing keys keep their identity.
let mut merged = Vec::with_capacity(self.len() + incoming.len());
let mut old = std::mem::take(&mut self.entries).into_iter().peekable();
let mut new = incoming.entries.into_iter().peekable();
while let (Some(left), Some(right)) = (old.peek(), new.peek()) {
match left.0.cmp(&right.0) {
std::cmp::Ordering::Less => merged.push(old.next().unwrap()),
std::cmp::Ordering::Greater => merged.push(new.next().unwrap()),
std::cmp::Ordering::Equal => {
let (key, _) = old.next().unwrap();
merged.push((key, new.next().unwrap().1));
}
}
}
merged.extend(old);
merged.extend(new);
self.entries = merged;
}
}
impl<const N: usize> From<[(Cow<'static, str>, f64); N]> for NumericFactorMap {
fn from(entries: [(Cow<'static, str>, f64); N]) -> Self {
entries.into_iter().collect()
}
}
impl From<BTreeMap<Cow<'static, str>, f64>> for NumericFactorMap {
fn from(entries: BTreeMap<Cow<'static, str>, f64>) -> Self {
entries.into_iter().collect()
}
}
impl Serialize for NumericFactorMap {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.len()))?;
for (key, value) in self {
map.serialize_entry(key, value)?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for NumericFactorMap {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Fields;
impl<'de> Visitor<'de> for Fields {
type Value = NumericFactorMap;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a numeric factor map")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut entries = Vec::new();
while let Some((key, value)) = map.next_entry::<CompactString, f64>()? {
entries.push((key, value));
}
Ok(entries.into_iter().collect())
}
}
deserializer.deserialize_map(Fields)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_reservation_preserves_values_and_avoids_growth_during_known_inserts() {
let mut map = NumericFactorMap::from([
(Cow::Borrowed("amount"), 125.25),
(Cow::Borrowed("nullable_value"), f64::from_bits(0x7ff8000000000042)),
(Cow::Borrowed("signal"), -0.0),
]);
let original = map.iter().map(|(key, value)| (key.to_string(), value.to_bits())).collect::<Vec<_>>();
map.reserve_exact(2);
assert_eq!(map.iter().map(|(key, value)| (key.to_string(), value.to_bits())).collect::<Vec<_>>(), original);
let buffer = map.entries.as_ptr();
map.insert(Cow::Borrowed("pre_close"), 12.5);
map.insert(Cow::Borrowed("no_limit"), 0.0);
assert_eq!(map.entries.as_ptr(), buffer);
assert_eq!(map.len(), 5);
assert_eq!(map["signal"].to_bits(), (-0.0_f64).to_bits());
assert_eq!(map["nullable_value"].to_bits(), 0x7ff8000000000042);
let before = map.entries.as_ptr();
map.reserve_exact(0);
assert_eq!(map.entries.as_ptr(), before);
}
#[test]
fn compact_keys_inline_dynamic_names_and_keep_long_static_storage() {
const LONG: &str = "a_long_static_factor_identifier_that_must_remain_borrowed";
let map = NumericFactorMap::from([
(Cow::Owned("dynamic_factor_20".to_owned()), -0.0),
(Cow::Borrowed(LONG), 1.0),
]);
let cloned = map.clone();
let short = cloned.keys().find(|key| key.as_str() == "dynamic_factor_20").unwrap();
assert!(!short.is_heap_allocated());
let long = cloned.keys().find(|key| key.as_str() == LONG).unwrap();
assert_eq!(long.as_static_str(), Some(LONG));
assert_eq!(cloned["dynamic_factor_20"].to_bits(), (-0.0_f64).to_bits());
assert_eq!(std::mem::size_of::<(CompactString, f64)>(), std::mem::size_of::<(Cow<'static, str>, f64)>());
}
#[test]
fn long_dynamic_unicode_and_short_keys_keep_the_same_json_map() {
let entries = ["", "a", "a_field_longer_than_the_inline_string_capacity", "价格因子", "ths_up_days_stock"]
.into_iter().enumerate().map(|(index, key)| (Cow::Owned(key.to_string()), index as f64 + 0.25))
.collect::<BTreeMap<_, _>>();
let map = NumericFactorMap::from(entries.clone());
assert_eq!(serde_json::to_string(&map).unwrap(), serde_json::to_string(&entries).unwrap());
let decoded: NumericFactorMap = serde_json::from_str(&serde_json::to_string(&map).unwrap()).unwrap();
assert_eq!(decoded, map);
assert!(!decoded.keys().find(|key| key.as_str() == "ths_up_days_stock").unwrap().is_heap_allocated());
}
#[test]
fn updates_order_removal_and_values_match_tree_map() {
let mut flat = NumericFactorMap::new();
let mut tree = BTreeMap::new();
let mut seed = 71_u64;
for index in 0..10000 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let key: Cow<'static, str> = Cow::Owned(format!("factor_{:04}", (seed >> 32) % 1000));
let value = (index as f64 - 5000.0) / 7.0;
if index % 11 == 0 {
assert_eq!(flat.remove(key.as_ref()), tree.remove(key.as_ref()));
} else {
assert_eq!(flat.insert(key.clone(), value), tree.insert(key, value));
}
}
assert_eq!(
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
);
flat.retain(|_, value| *value > 100.0);
tree.retain(|_, value| *value > 100.0);
assert_eq!(
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
);
assert_eq!(
std::mem::size_of::<NumericFactorMap>(),
std::mem::size_of_val(&tree)
);
}
#[test]
fn bulk_load_is_sorted_and_keeps_last_value_for_each_field() {
let input = vec![
(Cow::Borrowed("z"), 2.0),
(Cow::Borrowed("a"), 1.0),
(Cow::Borrowed("z"), 3.0),
(Cow::Borrowed("z"), 4.0),
];
let flat: NumericFactorMap = input.clone().into_iter().collect();
let tree: BTreeMap<_, _> = input.into_iter().collect();
assert_eq!(
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
);
assert_eq!(flat["z"], 4.0);
}
#[test]
fn serialization_keeps_the_map_contract_and_precise_numbers() {
let input = [
(Cow::Borrowed("zero"), -0.0),
(Cow::Borrowed("tiny"), 1.0000000000000002),
(Cow::Borrowed("large"), 9.123456789123456e20),
];
let flat = NumericFactorMap::from(input.clone());
let tree = BTreeMap::from(input);
let json = serde_json::to_string(&flat).unwrap();
assert_eq!(json, serde_json::to_string(&tree).unwrap());
let decoded: NumericFactorMap = serde_json::from_str(&json).unwrap();
for (key, value) in &flat {
assert_eq!(value.to_bits(), decoded[key.as_ref()].to_bits());
}
let duplicate: NumericFactorMap = serde_json::from_str(r#"{"x":1,"x":2}"#).unwrap();
assert_eq!(duplicate["x"], 2.0);
}
#[test]
fn clone_does_not_share_mutable_values() {
let original = NumericFactorMap::from([(Cow::Borrowed("signal"), 1.0)]);
let mut copy = original.clone();
*copy.get_mut("signal").unwrap() = 0.0;
copy.insert(Cow::Borrowed("other"), 2.0);
assert_eq!(original["signal"], 1.0);
assert!(!original.contains_key("other"));
}
#[test]
fn wide_batch_merge_matches_tree_and_preserves_old_key_ownership() {
let entries = (0..4096)
.map(|index| (Cow::Owned(format!("f_{index:05}")), index as f64))
.collect::<Vec<_>>();
let mut flat: NumericFactorMap = entries.clone().into_iter().collect();
let mut tree = BTreeMap::from_iter(entries);
flat.insert(Cow::Borrowed("shared"), -0.0);
tree.insert(Cow::Borrowed("shared"), -0.0);
let incoming = (2048..8192)
.rev()
.map(|index| (Cow::Owned(format!("f_{index:05}")), -(index as f64)))
.chain([(Cow::Owned("shared".to_owned()), 1.0)])
.collect::<Vec<_>>();
flat.extend(incoming.clone());
tree.extend(incoming);
assert_eq!(
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
);
assert_eq!(flat.keys().last().map(CompactString::as_str), Some("shared"));
assert!(!flat.keys().last().unwrap().is_heap_allocated());
flat.extend([(Cow::Borrowed("zz"), f64::NAN)]);
assert!(flat["zz"].is_nan());
flat.extend(std::iter::empty::<(CompactString, f64)>());
assert_eq!(flat.len(), tree.len() + 1);
}
}
+41 -3
View File
@@ -212,6 +212,11 @@ pub fn build_dataset_context(
}
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
// A runner bundle also contains source/extract copies. Follow the same
// authoritative spec selection as the execution loader, not those copies.
if let Some(spec) = value.get("strategySpec").or_else(|| value.get("strategy_spec")) {
return specs_in_value(spec);
}
let mut specs = Vec::new();
match value {
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
@@ -221,7 +226,15 @@ pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
}
}
Value::Object(items) => {
for v in items.values() {
let typed_pool = items.get("stockPool").or_else(|| items.get("stock_pool"))
.is_some_and(Value::is_object);
for (key, v) in items {
// The executable pool already supplies decoded expressions.
// Its display/source serialization escapes those expressions
// one more time and is not another executable program.
if typed_pool && matches!(key.as_str(), "sourceCode" | "source_code") {
continue;
}
specs.extend(specs_in_value(v)?);
}
}
@@ -248,6 +261,31 @@ mod tests {
use super::*;
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
use serde_json::json;
#[test]
fn structured_pool_conditions_are_not_rescanned_inside_serialized_source_code() {
let pattern = json!({"template":"expression","parameters":{"history_window":20},
"expression":{"kind":"operator","name":"GT","args":[{"kind":"field","name":"amount"},{"kind":"number","value":0}]}});
let expr = format!("pattern_signal({})", serde_json::to_string(&pattern.to_string()).unwrap());
let pool = json!({"schema_version":1,"pool_id":"fixture","version_id":"v1","members":[],
"allocation_policy":{},"timing_policy":{},"stop_take_policy":{},"out_of_pool_policy":"hold",
"exit_signals":[{"role":"risk_exit","when_expr":expr,"remaining_position_bps":5000,"reason":"fixture"}]});
let source = format!("stock_pool.config({pool})");
for (pool_key, source_key) in [("stockPool", "sourceCode"), ("stock_pool", "source_code")] {
let value = json!({pool_key:pool,source_key:source,"runtimeExpressions":{"trading":{"buyFilterExpr":expr}}});
assert_eq!(specs_in_value(&value).unwrap().len(), 2);
for wrapper in ["strategySpec", "strategy_spec"] {
let bundle = json!({wrapper:value,"strategy_source":{"source_code":source},
"strategy_extract":{"parameters":{"source_code":source}}});
assert_eq!(specs_in_value(&bundle).unwrap().len(), 2);
}
let mut invalid = value.clone();
invalid[pool_key]["exit_signals"][0]["when_expr"] = json!("pattern_signal(not-json)");
assert!(specs_in_value(&invalid).is_err(), "invalid actual conditions must still fail");
assert!(specs_in_value(&json!({"strategySpec":invalid})).is_err());
}
assert_eq!(specs_in_value(&json!({"sourceCode":format!("risk.stop_loss({expr})")})).unwrap().len(),1);
}
#[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();
@@ -298,7 +336,7 @@ mod tests {
][n][i];
market.push(DailyMarketSnapshot {
date: *d,
symbol: s.to_string(),
symbol: (*s).into(),
timestamp: None,
day_open: c,
open: c,
@@ -321,7 +359,7 @@ mod tests {
});
factors.push(DailyFactorSnapshot {
date: *d,
symbol: s.to_string(),
symbol: (*s).into(),
market_cap_bn: 1.,
free_float_cap_bn: 1.,
pe_ttm: 10.,
File diff suppressed because it is too large Load Diff
+273
View File
@@ -0,0 +1,273 @@
//! 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();
if !self.config.stop_loss_expr.trim().is_empty() || !self.config.take_profit_expr.trim().is_empty()
|| self.config.position_target_rules.len() != program.exit_signals.len()
|| self.config.position_target_rules.iter().zip(&program.exit_signals).any(|(compiled, frozen)|
compiled.when_expr != frozen.when_expr || compiled.remaining_position_bps != frozen.remaining_position_bps
|| compiled.reason != frozen.reason || compiled.stock_pool_role != frozen.role)
{
return Err(BacktestError::Execution("stock_pool_exit_roles_required: exit rules must remain bound to the frozen stock_pool program".into()));
}
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_with_exit_roles(
Some(&program.timing_policy),
!self.config.buy_filter_expr.trim().is_empty(),
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::OrdinarySell),
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::RiskExit),
)
.map_err(BacktestError::Execution)?;
if self.config.in_skip_window(ctx.decision_date) {
return Ok(StrategyDecision::default());
}
let explicit_quote_condition = self.selection_quote_usage != StockFilterQuoteUsage::DailyOnly
|| [self.config.buy_filter_expr.as_str(), self.config.stop_loss_expr.as_str(), self.config.take_profit_expr.as_str()]
.into_iter().chain(self.config.position_target_rules.iter().map(|rule|rule.when_expr.as_str()))
.any(|expression|Self::stock_filter_quote_usage_for_expr(expression)!=StockFilterQuoteUsage::DailyOnly);
if explicit_quote_condition && ctx.active_datetime.is_some_and(|at|at.time()<NaiveTime::from_hms_opt(15,0,0).unwrap()) {
for symbol in program.members.iter().map(|member|&member.symbol).chain(ctx.portfolio.positions().keys()) {
if ctx.data.instrument(symbol).is_some_and(|instrument|instrument.is_exchange_traded_fund()&&instrument.dated_market_absence_reason(ctx.execution_date).is_none())
&& self.scheduled_quote_at_time(ctx,ctx.execution_date,symbol,None).is_none()
{
return Err(BacktestError::Execution(format!("etf_intraday_condition_evidence_missing:{symbol}; completed daily references cannot make minute/tick conditions true")));
}
}
}
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()]);
}
}
}
for (role, targets) in self.current_position_target_rules_by_role(ctx, ctx.decision_date, factor_date, &day)? {
let output = match role { pool::StockPoolExitRole::OrdinarySell => &mut constraints.position_target_bps, pool::StockPoolExitRole::RiskExit => &mut constraints.independent_position_target_bps };
for (symbol, (bps, _)) in targets { output.insert(symbol, bps); }
}
let limit = constraints.target_holding_count.unwrap_or(ranked.len());
let final_symbols = ranked
.iter()
.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 allocation_weights = self
.config
.runtime_position_exposure_timeline
.allocation_weights_at(portfolio_loss_decision_at(ctx))
.or_else(|| {
self.config
.position_exposure_timeline
.allocation_weights_at(portfolio_loss_decision_at(ctx))
});
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;
if let Some(weights) = allocation_weights {
member.target_weight_bps = Some(*weights.get(symbol).unwrap_or(&0));
}
member
})
.collect();
let (base_ratio, reserve_cash) =
pool::stock_pool_funding_from_configuration(&program.allocation_policy)
.map_err(BacktestError::Execution)?;
let base_exposure = self
.config
.position_exposure_timeline
.exposure_at(
portfolio_loss_decision_at(ctx),
ctx.execution_date,
&self.config.position_exposure_schedule,
f64::from(base_ratio) / 10000.,
)
.unwrap_or(f64::from(base_ratio) / 10000.);
let ratio = self
.config
.runtime_position_exposure_timeline
.exposure_at(
portfolio_loss_decision_at(ctx),
ctx.execution_date,
&self.config.runtime_position_exposure_schedule,
base_exposure,
)
.or(Some(base_exposure))
.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()
})
}
}
+224 -30
View File
@@ -17,6 +17,12 @@ use crate::{
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyRuntimeSpec {
#[serde(default,alias="stock_pool")]
pub stock_pool:Option<crate::stock_pool_execution::StockPoolProgram>,
#[serde(default)]
pub signal_book: Option<crate::signal_contract::SignalBook>,
#[serde(default)]
pub signal_book_ref: Option<crate::signal_contract::SignalBookReference>,
#[serde(default, alias = "strategy_id")]
pub strategy_id: Option<String>,
#[serde(default)]
@@ -85,6 +91,8 @@ pub struct StrategyRebalanceSpec {
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyExecutionSpec {
#[serde(default, alias = "volume_capacity_mode")]
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
#[serde(default)]
pub frequency: Option<String>,
#[serde(default, alias = "matching_type")]
@@ -158,9 +166,22 @@ pub struct StrategyExecutionSpec {
pub sell_then_buy_delay_slippage_rate: Option<f64>,
}
impl StrategyRuntimeSpec {
pub fn volume_capacity_mode(&self) -> Result<crate::execution_capacity::VolumeCapacityMode, String> {
let engine = self.engine_config.as_ref().and_then(|config| config.volume_capacity_mode);
let execution = self.execution.as_ref().and_then(|config| config.volume_capacity_mode);
if engine.zip(execution).is_some_and(|(a, b)| a != b) {
return Err("conflicting engine/execution volumeCapacityMode".into());
}
Ok(execution.or(engine).unwrap_or_default())
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyEngineConfig {
#[serde(default, alias = "volume_capacity_mode")]
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
#[serde(default)]
pub frequency: Option<String>,
#[serde(default, alias = "template_id")]
@@ -644,7 +665,7 @@ fn normalize_risk_policy_aliases_in_value(value: &mut Value) -> Result<(), Strin
/// contract can legitimately arrive with both spellings. Canonicalise those
/// pairs once at the boundary, while rejecting conflicting values instead of
/// silently choosing one.
fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> {
pub fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> {
normalize_strategy_aliases_in_value_inner(value, false)
}
@@ -660,7 +681,7 @@ fn normalize_strategy_aliases_in_value_inner(
for (key, child) in object.iter_mut() {
normalize_strategy_aliases_in_value_inner(
child,
in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy"),
in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy" | "automaticTradeProtection" | "automatic_trade_protection"),
)?;
}
}
@@ -675,11 +696,14 @@ fn normalize_strategy_aliases_in_value_inner(
}
const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
("signalBook", &["signal_book"]),
("signalBookRef", &["signal_book_ref"]),
("strategyId", &["strategy_id"]),
("tradeTimes", &["trade_times"]),
("signalSymbol", &["signal_symbol"]),
("engineConfig", &["engine_config"]),
("runtimeExpressions", &["runtime_expressions"]),
("automaticTradeProtection", &["automatic_trade_protection"]),
("rebalanceSchedule", &["rebalance_schedule"]),
("skipWindows", &["skip_windows"]),
("dynamicRange", &["dynamic_range"]),
@@ -714,10 +738,8 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
),
("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]),
("stampTaxChangeDate", &["stamp_tax_change_date"]),
("volumeLimit", &["volume_limit"]),
("volumeLimitEnabled", &["volume_limit_enabled"]),
("liquidityLimit", &["liquidity_limit"]),
("liquidityLimitEnabled", &["liquidity_limit_enabled"]),
("volumeLimit", &["volume_limit", "volumeLimitEnabled", "volume_limit_enabled"]),
("liquidityLimit", &["liquidity_limit", "liquidityLimitEnabled", "liquidity_limit_enabled"]),
("volumePercent", &["volume_percent"]),
("riskPolicy", &["risk_policy"]),
("strictValueBudget", &["strict_value_budget"]),
@@ -741,6 +763,16 @@ fn strategy_alias_values_semantically_equal(left: &Value, right: &Value) -> bool
return true;
}
match (left, right) {
(Value::Number(left), Value::Number(right)) => {
const MAX_EXACT: i64 = 9_007_199_254_740_992;
let exact_integer = |value: &serde_json::Number| {
value.as_i64().filter(|v| (-MAX_EXACT..=MAX_EXACT).contains(v)).map(|v| v as f64)
.or_else(|| value.as_u64().filter(|v| *v <= MAX_EXACT as u64).map(|v| v as f64))
};
if left.is_f64() && !right.is_f64() { exact_integer(right).zip(left.as_f64()).is_some_and(|(a,b)| a==b) }
else if right.is_f64() && !left.is_f64() { exact_integer(left).zip(right.as_f64()).is_some_and(|(a,b)| a==b) }
else { false }
}
(Value::String(left), Value::String(right)) => left.trim() == right.trim(),
(Value::String(left), Value::Number(right))
| (Value::Number(right), Value::String(left)) => left
@@ -897,6 +929,10 @@ pub struct StrategyExpressionSelectionConfig {
pub current_day_precomputed_factors: Option<bool>,
#[serde(default, alias = "candidate_symbols_by_date")]
pub candidate_symbols_by_date: BTreeMap<String, Vec<String>>,
#[serde(default, alias = "preserve_candidate_order")]
pub preserve_candidate_order: bool,
#[serde(default, alias = "candidate_source_book")]
pub candidate_source_book: Option<crate::stock_pool_candidates::CandidateSourceBook>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -913,6 +949,8 @@ pub struct StrategyExpressionRiskConfig {
pub exposure_expr: Option<String>,
#[serde(default, alias = "position_exposure_schedule")]
pub position_exposure_schedule: Vec<StrategyPositionExposureSchedulePoint>,
#[serde(default, alias = "position_exposure_events")]
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
#[serde(default)]
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
#[serde(default)]
@@ -990,6 +1028,8 @@ pub struct StrategyExpressionOrderingConfig {
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyExpressionTradingConfig {
#[serde(default, alias = "automatic_trade_protection")]
pub automatic_trade_protection: Option<crate::holding_policy::AutomaticTradeProtection>,
#[serde(default, alias = "buy_filter_expr")]
pub buy_filter_expr: Option<String>,
#[serde(default)]
@@ -1511,7 +1551,6 @@ fn normalize_slippage_model_name(value: &str) -> String {
| "price_rate"
| "price_ratio_slippage"
| "priceratioslippage" => "price_ratio".to_string(),
"dynamic_volume_volatility" => "dynamic".to_string(),
other => other.to_string(),
}
}
@@ -1556,11 +1595,13 @@ fn parse_slippage_model(
impact_coefficient: Option<f64>,
volatility_coefficient: Option<f64>,
max_value: Option<f64>,
) -> Option<SlippageModel> {
let value = valid_non_negative(value);
let impact_coefficient = valid_non_negative(impact_coefficient);
let volatility_coefficient = valid_non_negative(volatility_coefficient);
let max_value = valid_non_negative(max_value);
) -> Result<SlippageModel, String> {
for (name, parameter) in [("slippageValue", value), ("slippageImpactCoefficient", impact_coefficient),
("slippageVolatilityCoefficient", volatility_coefficient), ("slippageMaxValue", max_value)] {
if parameter.is_some_and(|number| !number.is_finite() || number < 0.0) {
return Err(format!("{name} must be finite and non-negative"));
}
}
let model = model
.map(normalize_slippage_model_name)
.filter(|item| !item.is_empty())
@@ -1573,16 +1614,23 @@ fn parse_slippage_model(
});
match model.as_str() {
"none" => Some(SlippageModel::None),
"price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
"tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))),
"limit_price" => Some(SlippageModel::LimitPrice),
"dynamic" => Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
impact_coefficient.unwrap_or(0.5),
volatility_coefficient.unwrap_or(0.3),
max_value.or(value).unwrap_or(0.01),
))),
_ => None,
"none" => Ok(SlippageModel::None),
"price_ratio" => Ok(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
"tick_size" => Ok(SlippageModel::TickSize(value.unwrap_or(0.0))),
"limit_price" => Ok(SlippageModel::LimitPrice),
"historical_volume_volatility" => {
let max_ratio = max_value.or(value).unwrap_or(0.01);
if max_ratio >= 1.0 {
return Err("historical slippage maximum must be less than 1".into());
}
Ok(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(
impact_coefficient.unwrap_or(0.5), volatility_coefficient.unwrap_or(0.3), max_ratio,
)))
},
"dynamic" | "dynamic_volume_volatility" => Err(
"retired_slippage_model: dynamic used unfinished daily data; explicitly select historical_volume_volatility or another supported model".into()
),
_ => Err(format!("unsupported slippageModel: {model}")),
}
}
@@ -1613,20 +1661,19 @@ fn apply_execution_behavior_overrides(
|| slippage_volatility_coefficient.is_some()
|| slippage_max_value.is_some()
{
if let Some(parsed) = parse_slippage_model(
cfg.slippage_model = parse_slippage_model(
slippage_model,
slippage_value,
slippage_impact_coefficient,
slippage_volatility_coefficient,
slippage_max_value,
) {
cfg.slippage_model = parsed;
}
)?;
}
if strict_value_budget == Some(false) {
return Err("strictValueBudget=false is not supported".to_string());
}
cfg.strict_value_budget = true;
if let Some(rate) = sell_then_buy_delay_slippage_rate {
if !rate.is_finite() || !(0.0..1.0).contains(&rate) {
return Err(
@@ -1792,6 +1839,7 @@ pub fn platform_expr_config_from_spec(
strategy_spec: Option<&StrategyRuntimeSpec>,
) -> Result<PlatformExprStrategyConfig, String> {
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.volume_capacity_mode = strategy_spec.map(StrategyRuntimeSpec::volume_capacity_mode).transpose()?.unwrap_or_default();
cfg.strategy_name = strategy_id.to_string();
if !signal_symbol.trim().is_empty() {
cfg.signal_symbol = signal_symbol.trim().to_string();
@@ -2110,12 +2158,29 @@ pub fn platform_expr_config_from_spec(
if let Some(enabled) = selection.current_day_precomputed_factors {
cfg.current_day_precomputed_factors = enabled;
}
if selection.preserve_candidate_order && selection.candidate_symbols_by_date.is_empty() {
return Err("preserveCandidateOrder requires a dated candidate book".to_string());
}
if let Some(book) = &selection.candidate_source_book {
if !selection.preserve_candidate_order {
return Err("candidateSourceBook requires preserveCandidateOrder=true".into());
}
let expected = book.resolved_symbols()?.into_iter()
.map(|(date, symbols)| (date.to_string(), symbols)).collect::<BTreeMap<_, _>>();
if expected != selection.candidate_symbols_by_date {
return Err("candidateSourceBook differs from resolved candidateSymbolsByDate".into());
}
if cfg.selection_limit_expr.trim() == "pool_candidate_count" {
cfg.max_positions = expected.values().map(Vec::len).max().unwrap_or(0).max(1);
}
}
for (raw_date, raw_symbols) in &selection.candidate_symbols_by_date {
let trade_date = NaiveDate::parse_from_str(raw_date, "%Y-%m-%d").map_err(|_| {
format!("candidateSymbolsByDate contains invalid date: {raw_date}")
})?;
let mut symbols = BTreeSet::new();
for raw_symbol in raw_symbols {
let mut order = BTreeMap::new();
for (index, raw_symbol) in raw_symbols.iter().enumerate() {
let symbol = normalize_symbol(raw_symbol, None);
let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
code.len() == 6
@@ -2132,8 +2197,12 @@ pub fn platform_expr_config_from_spec(
"candidateSymbolsByDate contains duplicate date/symbol: {raw_date} {symbol}"
));
}
order.insert(symbol, index);
}
cfg.candidate_symbols_by_date.insert(trade_date, symbols);
if selection.preserve_candidate_order {
cfg.candidate_order_by_date.insert(trade_date, order);
}
}
}
if let Some(allocation) = runtime_expr.allocation.as_ref()
@@ -2161,6 +2230,7 @@ pub fn platform_expr_config_from_spec(
expr.clone()
};
}
cfg.position_exposure_timeline = crate::position_exposure::PositionExposureTimeline::from_events(&risk.position_exposure_events)?;
for point in &risk.position_exposure_schedule {
let effective_date = NaiveDate::parse_from_str(
point.effective_date.trim(),
@@ -2272,6 +2342,7 @@ pub fn platform_expr_config_from_spec(
));
}
cfg.position_target_rules.push(PlatformPositionTargetRule {
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
when_expr: when_expr.to_string(),
remaining_position_bps: rule.remaining_position_bps,
reason: rule
@@ -2316,6 +2387,10 @@ pub fn platform_expr_config_from_spec(
}
}
if let Some(trading) = runtime_expr.trading.as_ref() {
if let Some(policy) = &trading.automatic_trade_protection {
policy.validate()?;
cfg.automatic_trade_protection = policy.clone();
}
if let Some(expr) = trading.buy_filter_expr.as_ref() {
cfg.buy_filter_expr = expr.clone();
}
@@ -2599,6 +2674,62 @@ pub fn platform_expr_config_from_spec(
}
cfg.strict_value_budget = true;
if spec.runtime_expressions.as_ref().and_then(|runtime| runtime.selection.as_ref())
.is_some_and(|selection| selection.candidate_source_book.is_some())
&& (cfg.matching_type != MatchingType::NextBarOpen || !cfg.current_day_precomputed_factors) {
return Err("daily candidate source book requires completed signal-day factors and next_bar_open".into());
}
let signal_book = match (&spec.signal_book,&spec.signal_book_ref) {
(Some(_),Some(_)) => return Err("inline_and_registered_signal_book_are_mutually_exclusive".into()),
(Some(raw),None) => Some(std::sync::Arc::new(raw.clone().validate()?)),
(None,Some(reference)) => crate::signal_contract::cached_signal_book(reference)?,
(None,None) => None,
};
if let Some(book) = signal_book {
if cfg.explicit_actions.len() != 1 || !matches!(cfg.explicit_actions[0], PlatformTradeAction::ConsumeSignal) {
return Err("signal_book_requires_one_consume_signal_action".into());
}
if !cfg.signal_rebalance_dates.is_empty() && cfg.signal_rebalance_dates != book.decision_dates() {
return Err("signal_book_schedule_does_not_match_strategy".into());
}
cfg.rotation_enabled = false;
cfg.signal_rebalance_dates = book.decision_dates();
cfg.initial_subscriptions.extend(book.symbols());
cfg.signal_book = Some(book);
} else if spec.signal_book_ref.is_some() {
if cfg.explicit_actions.len()!=1 || !matches!(cfg.explicit_actions[0],PlatformTradeAction::ConsumeSignal) {
return Err("signal_book_requires_one_consume_signal_action".into());
}
cfg.rotation_enabled=false;
} else if cfg.explicit_actions.iter().any(|action| matches!(action, PlatformTradeAction::ConsumeSignal)) {
return Err("consume_signal_requires_verified_signal_book".into());
}
let has_automatic_policy = spec.runtime_expressions.as_ref().and_then(|runtime| runtime.trading.as_ref()).is_some_and(|trading| trading.automatic_trade_protection.is_some());
if has_automatic_policy {
let limit = i64::from(cfg.automatic_trade_protection.max_holding_days);
if cfg.max_holding_days.is_some_and(|previous| previous != limit) {
return Err("conflicting maximum holding policies".into());
}
cfg.max_holding_days = (limit > 0).then_some(limit);
}
if let Some(pool)=&spec.stock_pool {
if cfg.signal_book.is_some() || spec.signal_book_ref.is_some() || !cfg.explicit_actions.is_empty(){return Err("stock_pool_program_cannot_mix_other_order_programs".into())}
let legacy_exit = !cfg.stop_loss_expr.trim().is_empty() || !cfg.take_profit_expr.trim().is_empty() || !cfg.position_target_rules.is_empty();
if legacy_exit { return Err("stock_pool_exit_roles_required: regenerate this historical stock-pool strategy from its saved configuration; legacy risk expressions do not preserve ordinary/risk exit roles".into()); }
let secondary_buy=!cfg.buy_filter_expr.trim().is_empty();
pool.validate(secondary_buy,false)?;
cfg.position_target_rules.extend(pool.exit_signals.iter().map(|signal| PlatformPositionTargetRule {
when_expr: signal.when_expr.clone(), remaining_position_bps: signal.remaining_position_bps,
reason: signal.reason.clone(), stock_pool_role: signal.role,
}));
cfg.stock_pool=Some(pool.clone());
cfg.hold_until_exit_enabled=false;
cfg.daily_top_up_enabled=false;
cfg.daily_position_target_adjust_enabled=false;
cfg.target_portfolio_daily_enabled=false;
}
Ok(cfg)
}
@@ -2751,6 +2882,7 @@ fn parse_platform_trade_action(
None => None,
};
match kind.as_str() {
"consume_signal" if when_expr.is_none() && time_in_force.is_none() => Some(PlatformTradeAction::ConsumeSignal),
"target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart {
target_weights_expr: action
.target_weights_expr
@@ -3149,6 +3281,16 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
mod tests {
use super::*;
#[test]
fn numeric_strategy_aliases_accept_exact_zero_but_never_hide_rounding_or_conflicts() {
let cfg = platform_expr_config_from_value("fees", "000001.SZ", &serde_json::json!({
"execution":{"minimumCommission":0.0,"minimum_commission":0}
})).unwrap();
assert_eq!(cfg.minimum_commission, Some(0.0));
assert!(!strategy_alias_values_semantically_equal(&serde_json::json!(9007199254740992u64), &serde_json::json!(9007199254740993u64)));
assert!(!strategy_alias_values_semantically_equal(&serde_json::json!(0.0), &serde_json::json!(1)));
}
#[test]
fn parses_buy_filter_as_a_separate_trading_condition() {
let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
@@ -3263,6 +3405,25 @@ mod tests {
);
}
#[test]
fn frozen_candidate_order_is_explicit_and_preserves_source_positions() {
let mut spec = serde_json::json!({"runtimeExpressions": {"selection": {
"candidateSymbolsByDate": {
"2025-01-02": ["600000.SH", "000001.SZ"], "2025-01-03": []
}
}}});
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let legacy = platform_expr_config_from_value("", "", &spec).unwrap();
assert!(legacy.candidate_order_by_date.is_empty());
spec["runtimeExpressions"]["selection"]["preserveCandidateOrder"] = serde_json::json!(true);
let ordered = platform_expr_config_from_value("", "", &spec).unwrap();
assert_eq!(ordered.candidate_order_by_date[&date]["600000.SH"], 0);
assert_eq!(ordered.candidate_order_by_date[&date]["000001.SZ"], 1);
assert!(ordered.candidate_order_by_date[&NaiveDate::from_ymd_opt(2025, 1, 3).unwrap()].is_empty());
spec["runtimeExpressions"]["selection"]["candidateSymbolsByDate"] = serde_json::json!({});
assert!(platform_expr_config_from_value("", "", &spec).unwrap_err().to_string().contains("dated candidate book"));
}
#[test]
fn rejects_invalid_or_duplicate_static_universe_symbols() {
let invalid = serde_json::json!({
@@ -3304,6 +3465,7 @@ mod tests {
assert_eq!(
cfg.position_target_rules,
vec![PlatformPositionTargetRule {
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
remaining_position_bps: 5000,
reason: "factor_reduce_position".to_string(),
@@ -4066,6 +4228,28 @@ mod tests {
);
}
#[test]
fn execution_limit_aliases_normalize_to_one_serde_field_without_touching_policy() {
for section in ["execution", "engineConfig"] {
let mut spec = serde_json::json!({});
spec[section] = serde_json::json!({
"volumeLimit": false, "volumeLimitEnabled": false, "volume_limit_enabled": false,
"liquidityLimit": true, "liquidityLimitEnabled": true, "liquidity_limit_enabled": true,
"riskPolicy": {"volumeLimitEnabled": false, "liquidityLimitEnabled": true}
});
let cfg = platform_expr_config_from_value("test", "000300.SH", &spec).unwrap();
assert!(!cfg.risk_config.trading_constraints.volume_limit_enabled);
assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled);
super::normalize_strategy_aliases_in_value(&mut spec).unwrap();
assert!(spec[section].get("volumeLimitEnabled").is_none());
assert!(spec[section].get("liquidity_limit_enabled").is_none());
assert_eq!(spec[section]["riskPolicy"]["liquidityLimitEnabled"], true);
spec[section]["liquidity_limit_enabled"] = serde_json::json!(false);
assert!(platform_expr_config_from_value("test", "000300.SH", &spec)
.unwrap_err().to_string().contains("conflicting alias values"));
}
}
#[test]
fn canonicalizes_duplicate_execution_aliases_without_changing_strategy_intent() {
let spec = serde_json::json!({
@@ -4249,10 +4433,10 @@ mod tests {
}
#[test]
fn parses_dynamic_slippage_into_platform_config() {
fn parses_explicit_historical_slippage_into_platform_config() {
let spec = serde_json::json!({
"execution": {
"slippageModel": "dynamic",
"slippageModel": "historical_volume_volatility",
"slippageImpactCoefficient": 0.6,
"slippageVolatilityCoefficient": 0.2,
"slippageMaxValue": 0.015
@@ -4263,10 +4447,20 @@ mod tests {
assert_eq!(
cfg.slippage_model,
SlippageModel::Dynamic(DynamicSlippageConfig::new(0.6, 0.2, 0.015))
SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(0.6, 0.2, 0.015))
);
}
#[test]
fn retired_or_unknown_slippage_models_do_not_fall_back_to_fixed_or_none() {
for model in ["dynamic", "dynamic_volume_volatility", "dynamic-volume-volatility", "unknown"] {
let spec = serde_json::json!({"execution": {"slippageModel": model, "slippageValue": 0.002}});
assert!(platform_expr_config_from_value("", "", &spec).is_err(), "{model}");
}
let spec = serde_json::json!({"execution": {"slippageModel": "historical_volume_volatility", "slippageImpactCoefficient": -1}});
assert!(platform_expr_config_from_value("", "", &spec).is_err());
}
#[test]
fn engine_stock_ma_filter_generates_price_and_volume_expr() {
let spec = serde_json::json!({
+350 -24
View File
@@ -60,6 +60,8 @@ 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.
@@ -88,6 +90,8 @@ 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,
@@ -114,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);
}
@@ -128,14 +138,30 @@ impl Position {
if quantity == 0 {
return;
}
let gross_amount = fixed_money_or_panic(execution_price * quantity as f64, "position buy gross amount");
self.buy_with_fixed_gross(date,quantity,execution_price,mark_price,gross_amount);
}
fn buy_with_fixed_gross(
&mut self,
date: NaiveDate,
quantity: u32,
execution_price: f64,
mark_price: f64,
gross_amount: FixedMoney,
) {
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);
} else if let Some(opened) = self.opened_date {
self.opened_date = Some(opened.min(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,
"position buy gross amount",
);
self.lots.push(PositionLot {
acquired_date: date,
quantity,
@@ -186,6 +212,20 @@ impl Position {
quantity: u32,
execution_price: f64,
mark_price: f64,
) -> Result<f64, String> {
if quantity > self.quantity {
return Err(format!("sell quantity {} exceeds current quantity {} for {}",quantity,self.quantity,self.symbol));
}
let total_proceeds = fixed_money(execution_price * quantity as f64,"position sell gross amount")?;
self.sell_with_fixed_gross(quantity,execution_price,mark_price,total_proceeds)
}
fn sell_with_fixed_gross(
&mut self,
quantity: u32,
execution_price: f64,
mark_price: f64,
total_proceeds: FixedMoney,
) -> Result<f64, String> {
if quantity > self.quantity {
return Err(format!(
@@ -194,10 +234,17 @@ impl Position {
));
}
let total_proceeds = fixed_money(
execution_price * quantity as f64,
"position sell gross amount",
)?;
// A delayed receipt or a successor conversion can append an older
// acquisition after a newer lot. Deplete by actual acquisition date;
// stable ordering preserves same-day receipts and their attached fees.
if quantity > 0
&& self
.lots
.windows(2)
.any(|pair| pair[0].acquired_date > pair[1].acquired_date)
{
self.lots.sort_by_key(|lot| lot.acquired_date);
}
let mut remaining = quantity;
let mut remaining_proceeds = total_proceeds;
let mut realized = FixedMoney::ZERO;
@@ -267,6 +314,7 @@ 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 {
@@ -661,6 +709,31 @@ pub struct PortfolioState {
cash_receivables: Vec<CashReceivable>,
pending_cash_flows: Vec<PendingCashFlow>,
day_sold_symbols: BTreeSet<String>,
// Observed holding conversions, never a catalog alias or a new target.
// Kept after a position becomes flat so an active date lock is not lost.
corporate_predecessors: BTreeMap<String, BTreeSet<String>>,
stock_pool_states: std::collections::BTreeMap<String,crate::stock_pool_state::StockPoolExecutionState>,
}
pub(crate) struct PortfolioCheckpoint {
saved: PortfolioState,
position_order: Option<Vec<String>>,
}
impl PortfolioCheckpoint {
pub(crate) fn restore(mut self, current: &mut PortfolioState) {
if let Some(order) = self.position_order.take() {
let mut positions = IndexMap::with_capacity(order.len());
for symbol in order {
let position = self.saved.positions.shift_remove(&symbol)
.or_else(|| current.positions.shift_remove(&symbol))
.expect("unchanged checkpoint position must remain present");
positions.insert(symbol, position);
}
self.saved.positions = positions;
}
*current = self.saved;
}
}
#[derive(Debug, Clone)]
@@ -682,9 +755,36 @@ pub(crate) struct SuccessorConversionOutcome {
}
impl PortfolioState {
/// Ordinary single-security orders need not duplicate every other lot.
/// Complex portfolio intents request the complete checkpoint instead.
pub(crate) fn checkpoint(&self, symbols: Option<&BTreeSet<String>>) -> PortfolioCheckpoint {
let Some(symbols) = symbols else {
return PortfolioCheckpoint { saved: self.clone(), position_order: None };
};
PortfolioCheckpoint {
saved: Self {
initial_cash: self.initial_cash, units: self.units, cash: self.cash,
external_cash_flow_total: self.external_cash_flow_total,
cash_liabilities: self.cash_liabilities, management_fee_rate: self.management_fee_rate,
management_fees: self.management_fees,
// prune_flat_positions can remove an unrelated zero row.
positions: self.positions.iter().filter(|(symbol, position)| position.quantity == 0 || symbols.contains(*symbol))
.map(|(symbol, position)| (symbol.clone(), position.clone())).collect(),
cash_receivables: self.cash_receivables.clone(), pending_cash_flows: self.pending_cash_flows.clone(),
day_sold_symbols: self.day_sold_symbols.clone(), corporate_predecessors: self.corporate_predecessors.clone(),
stock_pool_states: self.stock_pool_states.clone(),
},
position_order: Some(self.positions.keys().cloned().collect()),
}
}
pub fn new(initial_cash: f64) -> Self {
let initial_cash = fixed_money(initial_cash, "initial cash")
.expect("initial cash must be finite fixed-point money");
Self::from_fixed_initial_cash(initial_cash)
}
pub(crate) fn from_fixed_initial_cash(initial_cash: FixedMoney) -> Self {
Self {
initial_cash,
units: initial_cash,
@@ -697,6 +797,8 @@ impl PortfolioState {
cash_receivables: Vec::new(),
pending_cash_flows: Vec::new(),
day_sold_symbols: BTreeSet::new(),
corporate_predecessors: BTreeMap::new(),
stock_pool_states: std::collections::BTreeMap::new(),
}
}
@@ -706,6 +808,35 @@ impl PortfolioState {
self.initial_cash.to_f64()
}
pub(crate) fn initial_cash_fixed(&self) -> FixedMoney { self.initial_cash }
pub(crate) fn corporate_predecessors(&self, symbol: &str) -> impl Iterator<Item = &str> {
self.corporate_predecessors.get(symbol).into_iter().flatten().map(String::as_str)
}
pub(crate) fn observed_successor_symbols(&self) -> impl Iterator<Item = &str> {
self.corporate_predecessors.keys().map(String::as_str)
}
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(crate) fn adjust_stock_pool_split(&mut self, symbol: &str, ratio: f64) -> Result<(), String> {
let ratio = rust_decimal::Decimal::from_str_exact(&ratio.to_string())
.map_err(|_| "stock_pool_execution_state_split_invalid".to_string())?;
let adjusted = self.stock_pool_states.iter()
.map(|(pool, state)| Ok((pool.clone(), state.adjust_for_split(symbol, ratio)?)))
.collect::<Result<BTreeMap<_, _>, String>>()?;
self.stock_pool_states = adjusted;
Ok(())
}
pub fn initial_cash(&self) -> f64 {
self.initial_cash.to_f64()
}
@@ -718,6 +849,53 @@ impl PortfolioState {
self.cash.to_f64()
}
pub(crate) fn cash_fixed(&self) -> FixedMoney { self.cash }
pub(crate) fn financial_replay_identity(&self) -> serde_json::Value {
let positions = self.positions.iter().filter(|(_, position)| position.quantity > 0)
.map(|(symbol, position)| {
let mut lots = position.lots.iter().map(|lot| (lot.acquired_date, lot.quantity,
lot.entry_value.to_decimal_string(), lot.cost_basis.to_decimal_string())).collect::<Vec<_>>();
lots.sort();
(symbol.clone(), serde_json::json!({"quantity":position.quantity,"lots":lots,
"openedDate":position.opened_date,"lastBuyDate":position.last_buy_date}))
}).collect::<std::collections::BTreeMap<_, _>>();
let mut receivables = self.cash_receivables.iter().map(|row| (row.symbol.clone(), row.ex_date,
row.payable_date, fixed_money_or_panic(row.amount, "receivable identity").to_decimal_string(), row.reason.clone())).collect::<Vec<_>>();
receivables.sort();
let mut identity = serde_json::json!({"cash":self.cash.to_decimal_string(),"positions":positions,"receivables":receivables});
if !self.corporate_predecessors.is_empty() {
identity["corporatePredecessors"] = serde_json::json!(self.corporate_predecessors);
}
identity
}
pub(crate) fn financial_position_basis(&self, symbol: &str) -> FixedMoney {
self.positions.get(symbol).map_or(FixedMoney::ZERO, Position::total_cost_basis)
}
pub(crate) fn replace_replayed_financial_book(&mut self, mut replayed: PortfolioState) -> Result<(), String> {
if replayed.cash < FixedMoney::ZERO || replayed.initial_cash != self.initial_cash {
return Err("manual corporate replay changed initial capital or borrowed cash".into());
}
for (symbol, position) in &mut replayed.positions {
if let Some(current) = self.positions.get(symbol).filter(|current| current.quantity > 0) {
position.last_price = current.last_price;
position.refresh_day_pnl();
}
}
self.cash = replayed.cash;
self.positions = replayed.positions;
self.cash_receivables = replayed.cash_receivables;
self.day_sold_symbols = replayed.day_sold_symbols;
// Corrected actual receipts can prove a position was fully sold
// before conversion. Do not retain a now-disproved financial link.
self.corporate_predecessors = replayed.corporate_predecessors;
// Existing issued units, explicit cash-flow/financing facts, and task
// target state are observed controls, not counterfactual new orders.
Ok(())
}
pub fn external_cash_flow_total(&self) -> f64 {
self.external_cash_flow_total.to_f64()
}
@@ -753,13 +931,117 @@ impl PortfolioState {
}
pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> {
self.apply_cash_delta_fixed(fixed_money(delta, "cash delta")?)
}
pub(crate) fn apply_cash_delta_fixed(&mut self, delta: FixedMoney) -> Result<(), String> {
self.cash = self
.cash
.checked_add(fixed_money(delta, "cash delta")?)
.checked_add(delta)
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
Ok(())
}
/// Apply one fully observed external fill atomically. Its money is already
/// quantized from the original decimal amounts, not from a float product.
pub(crate) fn apply_observed_manual_fill(
&mut self,
trade_date: NaiveDate,
symbol: &str,
side: crate::events::OrderSide,
quantity: u32,
price: f64,
mark_price: f64,
gross: FixedMoney,
fees: FixedMoney,
) -> Result<FixedMoney, String> {
use crate::events::OrderSide;
if symbol.trim().is_empty()
|| quantity == 0
|| quantity > i32::MAX as u32
|| !price.is_finite()
|| price <= 0.
|| !mark_price.is_finite()
|| mark_price <= 0.
|| gross <= FixedMoney::ZERO
|| fees < FixedMoney::ZERO
{
return Err("invalid observed manual fill".into());
}
let mut position = self
.positions
.get(symbol)
.cloned()
.unwrap_or_else(|| Position::new(symbol));
let delta = match side {
OrderSide::Buy => gross.checked_add(fees).and_then(FixedMoney::checked_neg),
OrderSide::Sell => gross.checked_sub(fees),
}
.ok_or("manual fill cash delta overflow")?;
let next_cash = self
.cash
.checked_add(delta)
.filter(|cash| *cash >= FixedMoney::ZERO)
.ok_or("manual fill disagrees with shadow available cash")?;
let next_cost = position
.day_trade_cost
.checked_add(fees)
.ok_or("manual trade cost overflow")?;
match side {
OrderSide::Buy => {
let total_quantity = position
.quantity
.checked_add(quantity)
.ok_or("manual position quantity overflow")?;
FixedMoney::from_f64(mark_price * f64::from(total_quantity))
.ok_or("manual marked position value overflow")?;
position
.day_buy_quantity
.checked_add(quantity)
.ok_or("manual daily buy quantity overflow")?;
position
.day_trade_quantity_delta
.checked_add(quantity as i32)
.ok_or("manual daily quantity delta overflow")?;
position
.day_buy_value
.checked_add(gross)
.ok_or("manual daily buy value overflow")?;
let total_basis = gross.checked_add(fees).ok_or("manual lot basis overflow")?;
position
.total_cost_basis()
.checked_add(total_basis)
.ok_or("manual aggregate position basis overflow")?;
position.buy_with_fixed_gross(trade_date, quantity, price, mark_price, gross);
position
.lots
.last_mut()
.ok_or("manual buy produced no lot")?
.cost_basis = total_basis;
position.average_cost += fees.to_f64() / f64::from(position.quantity);
}
OrderSide::Sell => {
if quantity > position.sellable_qty(trade_date) {
return Err("manual fill disagrees with shadow sellable holdings or T+1".into());
}
position
.day_sell_quantity
.checked_add(quantity)
.ok_or("manual daily sell quantity overflow")?;
position
.day_trade_quantity_delta
.checked_sub(quantity as i32)
.ok_or("manual daily quantity delta overflow")?;
position.sell_with_fixed_gross(quantity, price, mark_price, gross)?;
}
}
position.day_trade_cost = next_cost;
position.refresh_day_pnl();
self.positions.insert(symbol.to_string(), position);
self.cash = next_cash;
Ok(delta)
}
pub fn prune_flat_positions(&mut self) {
let mut sold_symbols = Vec::new();
self.positions.retain(|symbol, position| {
@@ -1224,6 +1506,8 @@ 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;
@@ -1263,6 +1547,14 @@ 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);
@@ -1328,7 +1620,7 @@ impl PortfolioState {
}
successor.refresh_day_pnl();
Some(SuccessorConversionOutcome {
let outcome = SuccessorConversionOutcome {
old_symbol: old_symbol_owned,
new_symbol: new_symbol.to_string(),
old_quantity,
@@ -1340,7 +1632,14 @@ impl PortfolioState {
} else {
0.0
},
})
};
if converted_quantity > 0 {
let mut predecessors = self.corporate_predecessors.get(old_symbol).cloned().unwrap_or_default();
predecessors.insert(old_symbol.to_owned());
predecessors.remove(new_symbol);
self.corporate_predecessors.entry(new_symbol.to_owned()).or_default().extend(predecessors);
}
Some(outcome)
}
fn sum_fixed_money(
@@ -1392,7 +1691,34 @@ mod tests {
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
PriceField,
};
use std::collections::BTreeMap;
#[test]
fn scoped_checkpoint_restores_order_flat_rows_and_progress_without_copying_untouched_lots() {
let date = NaiveDate::from_ymd_opt(2026,9,15).unwrap();
let mut portfolio = PortfolioState::new(10000.);
portfolio.position_mut("000001.SZ").buy(date,100,10.);
portfolio.position_mut("000002.SZ").buy(date,200,10.);
portfolio.position_mut("000003.SZ").buy(date,100,10.);
portfolio.position_mut("000003.SZ").sell(100,11.).unwrap();
let flat_realized = portfolio.position("000003.SZ").unwrap().realized_pnl;
let untouched_lots = portfolio.position("000002.SZ").unwrap().lots.as_ptr();
let before = portfolio.financial_replay_identity();
let order = portfolio.positions.keys().cloned().collect::<Vec<_>>();
let checkpoint = portfolio.checkpoint(Some(&BTreeSet::from(["000001.SZ".into(), "000004.SZ".into()])));
assert!(!checkpoint.saved.positions.contains_key("000002.SZ"));
portfolio.position_mut("000001.SZ").sell(100,11.).unwrap();
portfolio.prune_flat_positions();
portfolio.position_mut("000004.SZ").buy(date,100,12.);
portfolio.apply_cash_delta(100.).unwrap();
portfolio.stock_pool_states.insert("changed".into(), Default::default());
checkpoint.restore(&mut portfolio);
assert_eq!(portfolio.financial_replay_identity(), before);
assert_eq!(portfolio.positions.keys().cloned().collect::<Vec<_>>(), order);
assert_eq!(portfolio.position("000002.SZ").unwrap().lots.as_ptr(), untouched_lots);
assert_eq!(portfolio.position("000003.SZ").unwrap().realized_pnl, flat_realized);
assert!(portfolio.stock_pool_states.is_empty());
assert!(portfolio.position("000004.SZ").is_none());
}
#[test]
fn cash_ledger_accumulates_micro_yuan_exactly() {
@@ -1595,7 +1921,7 @@ mod tests {
vec![
DailyMarketSnapshot {
date: prev_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: None,
day_open: 10.0,
open: 10.0,
@@ -1618,7 +1944,7 @@ mod tests {
},
DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: None,
day_open: 10.5,
open: 10.5,
@@ -1642,18 +1968,18 @@ mod tests {
],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -1708,7 +2034,7 @@ mod tests {
}],
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: None,
day_open: 10.5,
open: 10.5,
@@ -1731,18 +2057,18 @@ mod tests {
}],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -1794,7 +2120,7 @@ mod tests {
}],
vec![DailyMarketSnapshot {
date: prev_date,
symbol: "601028.SH".to_string(),
symbol: "601028.SH".into(),
timestamp: None,
day_open: 10.2,
open: 10.2,
@@ -1877,7 +2203,7 @@ mod tests {
vec![
DailyMarketSnapshot {
date: buy_date,
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: None,
day_open: 2.99,
open: 2.99,
@@ -1900,7 +2226,7 @@ mod tests {
},
DailyMarketSnapshot {
date: next_date,
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: None,
day_open: 3.03,
open: 3.03,
+536
View File
@@ -0,0 +1,536 @@
//! Dated manual adjustments are ordered facts; restoring is not a 100% target.
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
pub enum PositionExposureAction {
Scale {
#[serde(rename = "requestedBps", alias = "requested_bps")]
requested_bps: i32,
},
Set {
#[serde(rename = "targetExposureBps", alias = "target_exposure_bps")]
target_exposure_bps: i32,
},
Restore,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PositionExposureEvent {
#[serde(alias = "event_id")]
pub event_id: String,
pub sequence: u64,
#[serde(alias = "effective_at")]
pub effective_at: DateTime<Utc>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "allocation_weights_bps"
)]
pub allocation_weights_bps: Option<BTreeMap<String, i32>>,
#[serde(flatten)]
pub action: PositionExposureAction,
}
#[derive(Debug, Clone, Default)]
pub struct PositionExposureTimeline {
events: BTreeMap<(DateTime<Utc>, u64), (PositionExposureAction, Option<BTreeMap<String, i32>>)>,
}
impl PositionExposureTimeline {
pub fn from_events(events: &[PositionExposureEvent]) -> Result<Self, String> {
let mut result = Self::default();
let mut ids = BTreeSet::new();
let mut sequences = BTreeSet::new();
for event in events {
if event.event_id.trim().is_empty() || !ids.insert(event.event_id.as_str()) {
return Err("position exposure event id is missing or duplicated".into());
}
if event.sequence == 0 || !sequences.insert(event.sequence) {
return Err("position exposure event sequence must be positive and unique".into());
}
if let PositionExposureAction::Scale { requested_bps } = event.action
&& !(0..=10000).contains(&requested_bps)
{
return Err("position exposure scale must be between 0 and 10000 bps".into());
}
if let PositionExposureAction::Set {
target_exposure_bps,
} = event.action
&& !(0..=10_000).contains(&target_exposure_bps)
{
return Err("position exposure target must be between 0 and 10000 bps".into());
}
if let Some(weights) = &event.allocation_weights_bps {
let target = match event.action {
PositionExposureAction::Set {
target_exposure_bps,
} => target_exposure_bps,
PositionExposureAction::Scale { requested_bps } => requested_bps,
PositionExposureAction::Restore => {
return Err(
"restoring strategy allocation cannot carry manual weights".into()
);
}
};
validate_allocation_weights(weights, target)?;
}
result.events.insert(
(event.effective_at, event.sequence),
(event.action.clone(), event.allocation_weights_bps.clone()),
);
}
Ok(result)
}
/// Legacy day-level contracts remain day-level; never invent intraday times.
pub fn exposure_at(
&self,
at: DateTime<Utc>,
execution_date: NaiveDate,
legacy: &BTreeMap<NaiveDate, f64>,
strategy_exposure: f64,
) -> Option<f64> {
match self
.events
.range(..=(at, u64::MAX))
.next_back()
.map(|(_, (action, _))| action)
{
Some(PositionExposureAction::Scale { requested_bps }) => {
Some(strategy_exposure * f64::from(*requested_bps) / 10000.)
}
Some(PositionExposureAction::Set {
target_exposure_bps,
}) => Some(f64::from(*target_exposure_bps) / 10_000.),
Some(PositionExposureAction::Restore) => None,
None => legacy
.range(..=execution_date)
.next_back()
.map(|(_, value)| *value),
}
}
pub fn scale_at(&self, at: DateTime<Utc>) -> Option<i32> {
match self
.events
.range(..=(at, u64::MAX))
.next_back()
.map(|(_, (action, _))| action)
{
Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps),
_ => None,
}
}
pub fn allocation_weights_at(&self, at: DateTime<Utc>) -> Option<&BTreeMap<String, i32>> {
self.events
.range(..=(at, u64::MAX))
.next_back()
.and_then(|(_, (_, weights))| weights.as_ref())
}
}
pub fn validate_allocation_weights(
weights: &BTreeMap<String, i32>,
exposure_bps: i32,
) -> Result<(), String> {
if !(0..=10000).contains(&exposure_bps) || weights.len() > 10000 {
return Err("invalid allocation exposure or weight count".into());
}
for (symbol, weight) in weights {
if !(0..=10000).contains(weight)
|| !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(
"allocation weights require canonical stock/ETF symbols and 0..10000 bps".into(),
);
}
}
if (weights.is_empty() && exposure_bps != 0)
|| (!weights.is_empty() && weights.values().sum::<i32>() != 10000)
{
return Err("manual allocation weights must total 10000 bps; only a zero exposure may have no weights".into());
}
Ok(())
}
/// Scale new buys and desired targets without weakening sell/reduction or
/// cancellation instructions. Prices, subscriptions and cash flows are intact.
pub fn scale_explicit_intent(
mut intent: crate::OrderIntent,
bps: i32,
open_orders: &[crate::OpenOrderView],
) -> Result<Option<crate::OrderIntent>, String> {
use crate::OrderIntent as I;
if !(0..=10000).contains(&bps) {
return Err("position scale out of range".into());
}
if bps == 10000 {
return Ok(Some(intent));
}
if let I::WithTimeInForce {
intent: inner,
time_in_force,
} = intent
{
return Ok(
scale_explicit_intent(*inner, bps, open_orders)?.map(|intent| I::WithTimeInForce {
intent: Box::new(intent),
time_in_force,
}),
);
}
let integer = |value: i32| ((i64::from(value) * i64::from(bps)) / 10000) as i32;
let amount = |value: f64, target: bool| -> Result<f64, String> {
if !value.is_finite() || (target && value < 0.) {
return Err("position override received an invalid original amount".into());
}
Ok(if value > 0. {
value * f64::from(bps) / 10000.
} else {
value
})
};
match &mut intent {
I::Shares { quantity, .. }
| I::LimitShares { quantity, .. }
| I::Lots { lots: quantity, .. }
| I::LimitLots { lots: quantity, .. } => {
if *quantity > 0 {
*quantity = integer(*quantity);
if *quantity == 0 {
return Ok(None);
}
}
}
I::TargetShares {
target_quantity, ..
}
| I::LimitTargetShares {
target_quantity, ..
} => {
if *target_quantity < 0 {
return Err("position override received a negative target quantity".into());
}
*target_quantity = integer(*target_quantity);
}
I::Value { value, .. }
| I::LimitValue { value, .. }
| I::AlgoValue { value, .. }
| I::Percent { percent: value, .. }
| I::LimitPercent { percent: value, .. }
| I::AlgoPercent { percent: value, .. } => {
*value = amount(*value, false)?;
if *value == 0. {
return Ok(None);
}
}
I::TargetValue { target_value, .. }
| I::LimitTargetValue { target_value, .. }
| I::TimedTargetValue { target_value, .. }
| I::TargetPercent {
target_percent: target_value,
..
}
| I::LimitTargetPercent {
target_percent: target_value,
..
} => {
*target_value = amount(*target_value, true)?;
}
I::TargetPortfolioSmart { target_weights, .. } => {
for value in target_weights.values_mut() {
*value = amount(*value, true)?;
}
}
I::ModifyOrder {
order_id,
new_total_quantity: Some(quantity),
..
} => {
let order = open_orders
.iter()
.find(|order| order.order_id == *order_id)
.ok_or("position override cannot resolve the order being modified")?;
if order.side == crate::OrderSide::Buy && *quantity > order.requested_quantity {
let extra = u64::from(*quantity - order.requested_quantity) * bps as u64 / 10000;
*quantity = order.requested_quantity + extra as u32;
}
}
I::Futures { .. } | I::StockPool { .. } => {
return Err("manual equity scaling cannot transform this intent kind".into());
}
I::ModifyOrder { .. }
| I::CancelOrder { .. }
| I::CancelSymbol { .. }
| I::CancelAll { .. }
| I::UpdateUniverse { .. }
| I::Subscribe { .. }
| I::Unsubscribe { .. }
| I::DepositWithdraw { .. }
| I::FinanceRepay { .. }
| I::SetManagementFeeRate { .. } => {}
I::WithTimeInForce { .. } => unreachable!("wrapper handled first"),
}
Ok(Some(intent))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn scalar_preserves_strategy_risk_off_and_restore_keeps_original_exposure() {
let at = DateTime::parse_from_rfc3339("2026-01-05T09:30:00+08:00")
.unwrap()
.with_timezone(&Utc);
let event = PositionExposureEvent {
event_id: "scale".into(),
sequence: 1,
effective_at: at,
allocation_weights_bps: None,
action: PositionExposureAction::Scale {
requested_bps: 5000,
},
};
let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap();
assert_eq!(
timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.),
Some(0.)
);
assert_eq!(
timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.2),
Some(0.1)
);
let restored = PositionExposureEvent {
event_id: "restore".into(),
sequence: 2,
effective_at: at,
allocation_weights_bps: None,
action: PositionExposureAction::Restore,
};
let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap();
assert_eq!(
timeline
.exposure_at(
at,
at.date_naive(),
&BTreeMap::from([(at.date_naive(), 1.)]),
0.2
)
.unwrap_or(0.2),
0.2
);
}
#[test]
fn allocation_is_dated_and_any_later_scalar_or_restore_clears_it() {
let at = DateTime::parse_from_rfc3339("2026-09-14T10:00:00+08:00")
.unwrap()
.with_timezone(&Utc);
let weights = BTreeMap::from([("000001.SZ".into(), 3000), ("510300.SH".into(), 7000)]);
let event = PositionExposureEvent {
event_id: "allocation".into(),
sequence: 1,
effective_at: at,
action: PositionExposureAction::Set {
target_exposure_bps: 8000,
},
allocation_weights_bps: Some(weights.clone()),
};
let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap();
assert!(
timeline
.allocation_weights_at(at - chrono::Duration::seconds(1))
.is_none()
);
assert_eq!(timeline.allocation_weights_at(at), Some(&weights));
for action in [
PositionExposureAction::Set {
target_exposure_bps: 5000,
},
PositionExposureAction::Restore,
] {
let next = PositionExposureEvent {
event_id: "new".into(),
sequence: 2,
effective_at: at + chrono::Duration::seconds(1),
action,
allocation_weights_bps: None,
};
assert!(
PositionExposureTimeline::from_events(&[event.clone(), next])
.unwrap()
.allocation_weights_at(at + chrono::Duration::seconds(1))
.is_none()
);
}
assert!(
validate_allocation_weights(&BTreeMap::from([("000001.SZ".into(), 9000)]), 5000)
.is_err()
);
assert!(validate_allocation_weights(&BTreeMap::new(), 1).is_err());
assert!(validate_allocation_weights(&BTreeMap::new(), 0).is_ok());
}
#[test]
fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() {
use crate::OrderIntent as I;
let symbol = "000001.SZ".to_string();
let reason = "fixture".to_string();
for bps in [0, 3000, 5000, 10000] {
let ratio = f64::from(bps) / 10000.;
let buy = I::LimitShares {
symbol: symbol.clone(),
quantity: 1000,
limit_price: 12.345,
reason: reason.clone(),
};
let scaled = scale_explicit_intent(buy, bps, &[]).unwrap();
if bps == 0 {
assert!(scaled.is_none())
} else if let Some(I::LimitShares {
quantity,
limit_price,
..
}) = scaled
{
assert_eq!(quantity, (1000. * ratio) as i32);
assert_eq!(limit_price, 12.345);
} else {
panic!("wrong intent")
}
let sell = I::Shares {
symbol: symbol.clone(),
quantity: -1000,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(sell, bps, &[]).unwrap(),
Some(I::Shares {
quantity: -1000,
..
})
));
let clear = I::TargetShares {
symbol: symbol.clone(),
target_quantity: 0,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(clear, bps, &[]).unwrap(),
Some(I::TargetShares {
target_quantity: 0,
..
})
));
let target = I::TargetPercent {
symbol: symbol.clone(),
target_percent: 0.2,
reason: reason.clone(),
};
if let Some(I::TargetPercent { target_percent, .. }) =
scale_explicit_intent(target, bps, &[]).unwrap()
{
assert!((target_percent - 0.2 * ratio).abs() < 1e-12)
} else {
panic!("wrong target")
}
let deposit = I::DepositWithdraw {
amount: 123.456,
receiving_days: 2,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(deposit, bps, &[]).unwrap(),
Some(I::DepositWithdraw {
amount: 123.456,
receiving_days: 2,
..
})
));
}
assert!(
scale_explicit_intent(
I::TargetValue {
symbol,
target_value: f64::NAN,
reason
},
0,
&[]
)
.is_err()
);
}
#[test]
fn same_day_adjustments_restore_and_future_events_keep_their_own_times() {
let events: Vec<PositionExposureEvent> = serde_json::from_value(json!([
{"eventId":"first","sequence":1,"effectiveAt":"2026-09-10T10:00:00+08:00","action":"set","targetExposureBps":0},
{"eventId":"second","sequence":2,"effectiveAt":"2026-09-10T13:00:00+08:00","action":"set","targetExposureBps":5000},
{"eventId":"restore","sequence":3,"effectiveAt":"2026-09-10T14:00:00+08:00","action":"restore"},
{"eventId":"future","sequence":4,"effectiveAt":"2026-09-11T10:00:00+08:00","action":"set","targetExposureBps":1000}
])).unwrap();
let timeline = PositionExposureTimeline::from_events(&events).unwrap();
let date = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
let legacy = BTreeMap::from([(date.pred_opt().unwrap(), 0.8)]);
for (time, expected) in [
("09:30:00", Some(0.8)),
("10:00:00", Some(0.)),
("12:59:59", Some(0.)),
("13:00:00", Some(0.5)),
("14:00:00", None),
("15:00:00", None),
] {
let at = DateTime::parse_from_rfc3339(&format!("2026-09-10T{time}+08:00"))
.unwrap()
.with_timezone(&Utc);
assert_eq!(
timeline.exposure_at(at, date, &legacy, 0.2),
expected,
"{time}"
);
}
let next_open = DateTime::parse_from_rfc3339("2026-09-11T09:30:00+08:00")
.unwrap()
.with_timezone(&Utc);
assert_eq!(
timeline.exposure_at(next_open, date.succ_opt().unwrap(), &legacy, 0.2),
None
);
}
#[test]
fn rejects_unknown_actions_duplicate_identity_and_invalid_bps() {
let valid = json!({"eventId":"one","sequence":1,"effectiveAt":"2026-09-10T09:30:00+08:00","action":"set","targetExposureBps":5000});
for (key, value) in [
("action", json!("other")),
("effectiveAt", json!("2026-09-10 09:30:00")),
("targetExposureBps", json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(serde_json::from_value::<PositionExposureEvent>(invalid).is_err());
}
let event: PositionExposureEvent = serde_json::from_value(valid).unwrap();
assert!(PositionExposureTimeline::from_events(&[event.clone(), event.clone()]).is_err());
let mut invalid = event.clone();
invalid.action = PositionExposureAction::Set {
target_exposure_bps: 10001,
};
assert!(PositionExposureTimeline::from_events(&[invalid]).is_err());
let mut duplicate = event.clone();
duplicate.event_id = "two".into();
assert!(PositionExposureTimeline::from_events(&[event, duplicate]).is_err());
}
}
+182 -32
View File
@@ -76,6 +76,26 @@ impl Default for StaticRiskRuleConfig {
}
}
impl StaticRiskRuleConfig {
pub(crate) fn selection_checks_enabled(&self) -> bool {
(self.blacklist_enabled && !self.blacklisted_symbols.is_empty())
|| self.selection_state_checks_enabled()
}
fn selection_state_checks_enabled(&self) -> bool {
self.reject_st_selection
|| self.reject_star_st_selection
|| self.reject_paused_selection
|| self.reject_inactive_selection
|| self.reject_new_listing_selection
|| self.reject_kcb_selection
|| self.reject_bjse_selection
|| self.reject_one_yuan_selection
|| self.reject_upper_limit_selection
|| self.reject_lower_limit_selection
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TradingConstraintConfig {
/// Shared execution limits. These fields intentionally use the same
@@ -138,6 +158,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>,
@@ -404,10 +434,11 @@ 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
&& !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))
{
@@ -477,6 +508,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");
}
@@ -634,16 +674,7 @@ fn missing_risk_state_fields(code: &str) -> Vec<String> {
fn missing_selection_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -> bool {
let fields = missing_risk_state_fields(code);
if fields.is_empty() {
return config.static_rules.reject_st_selection
|| config.static_rules.reject_star_st_selection
|| config.static_rules.reject_paused_selection
|| config.static_rules.reject_inactive_selection
|| config.static_rules.reject_new_listing_selection
|| config.static_rules.reject_kcb_selection
|| config.static_rules.reject_bjse_selection
|| config.static_rules.reject_one_yuan_selection
|| config.static_rules.reject_upper_limit_selection
|| config.static_rules.reject_lower_limit_selection;
return config.static_rules.selection_state_checks_enabled();
}
missing_field_rejected(&fields, config, RiskCheckScope::Selection)
}
@@ -658,7 +689,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;
}
@@ -735,7 +765,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 {
@@ -759,18 +789,7 @@ fn missing_single_field_rejected(
RiskCheckScope::Sell => config.static_rules.reject_lower_limit_sell,
},
_ => match scope {
RiskCheckScope::Selection => {
config.static_rules.reject_st_selection
|| config.static_rules.reject_star_st_selection
|| config.static_rules.reject_paused_selection
|| config.static_rules.reject_inactive_selection
|| config.static_rules.reject_new_listing_selection
|| config.static_rules.reject_kcb_selection
|| config.static_rules.reject_bjse_selection
|| config.static_rules.reject_one_yuan_selection
|| config.static_rules.reject_upper_limit_selection
|| config.static_rules.reject_lower_limit_selection
}
RiskCheckScope::Selection => config.static_rules.selection_state_checks_enabled(),
RiskCheckScope::Buy => {
config.static_rules.reject_st_buy
|| config.static_rules.reject_star_st_buy
@@ -779,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
}
@@ -851,7 +869,7 @@ mod tests {
fn candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility {
date,
symbol: "002633.SZ".to_string(),
symbol: "002633.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -867,7 +885,7 @@ mod tests {
fn market(date: NaiveDate, last_price: f64, lower_limit: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date,
symbol: "002633.SZ".to_string(),
symbol: "002633.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: last_price,
open: last_price,
@@ -896,6 +914,138 @@ mod tests {
position
}
#[test]
fn selection_check_activation_covers_every_configured_flag_and_blacklist_state() {
let fields = [
"reject_st_selection", "reject_star_st_selection", "reject_paused_selection",
"reject_inactive_selection", "reject_new_listing_selection", "reject_kcb_selection",
"reject_bjse_selection", "reject_one_yuan_selection", "reject_upper_limit_selection",
"reject_lower_limit_selection",
];
let base = serde_json::to_value(StaticRiskRuleConfig::default()).unwrap();
let declared = base.as_object().unwrap().keys()
.filter(|key| key.ends_with("_selection"))
.map(String::as_str).collect::<BTreeSet<_>>();
assert_eq!(declared, fields.into_iter().collect());
for mask in 0..(1_u32 << fields.len()) {
for (blacklist_enabled, populated) in [(false, false), (false, true), (true, false), (true, true)] {
let mut value = base.clone();
for (bit, field) in fields.iter().enumerate() {
value[*field] = serde_json::json!(mask & (1 << bit) != 0);
}
value["blacklist_enabled"] = serde_json::json!(blacklist_enabled);
value["blacklisted_symbols"] = if populated {
serde_json::json!(["002633.SZ"])
} else { serde_json::json!([]) };
let config: StaticRiskRuleConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.selection_checks_enabled(), mask != 0 || (blacklist_enabled && populated));
}
}
}
#[test]
fn inactive_selection_checks_preserve_missing_facts_and_execution_rejections() {
let date = d(2025, 2, 6);
let mut candidate = candidate(date);
candidate.is_st = true;
candidate.is_star_st = true;
candidate.is_paused = true;
candidate.is_new_listing = true;
candidate.is_kcb = true;
candidate.is_one_yuan = true;
candidate.allow_buy = false;
let snapshot = market(date, 0.9, 0.9);
let config = FidcRiskControlConfig::default();
assert!(!config.static_rules.selection_checks_enabled());
let instrument = instrument("delisted", Some(date));
for code in [None, Some("not_listed"), Some("inactive_or_delisted"),
Some("missing_risk_state"), Some("missing_risk_state:is_st;is_kcb|allow_buy"),
Some("missing_risk_state:unknown_fact"), Some("missing_risk_state:IS_PAUSED")] {
candidate.risk_level_code = code.map(str::to_owned);
assert_eq!(ChinaAShareRiskControl::selection_rejection_decision_with_config(
date, &candidate, &snapshot, Some(&instrument), &config), None);
}
candidate.risk_level_code = None;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
date, &candidate, &snapshot, None, 0.9, &config), Some("paused"));
assert_eq!(ChinaAShareRiskControl::sell_rejection_reason_with_config(
date, &candidate, &snapshot, None, None, 0.9, &config), Some("paused"));
let mut blacklist_only = config;
blacklist_only.static_rules.blacklisted_symbols.insert(candidate.symbol.to_string());
assert!(blacklist_only.static_rules.selection_checks_enabled());
assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &snapshot, None, &blacklist_only), Some("blacklisted"));
}
#[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.to_string(),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);
@@ -960,7 +1110,7 @@ mod tests {
config
.static_rules
.blacklisted_symbols
.insert(candidate.symbol.clone());
.insert(candidate.symbol.to_string());
let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &market, None, &config,
@@ -1005,7 +1155,7 @@ mod tests {
let mut candidate = candidate(date);
let config = FidcRiskControlConfig::default();
for symbol in ["688001.SH", "689001.SH", "000001.SZ"] {
candidate.symbol = symbol.to_string();
candidate.symbol = symbol.into();
for is_kcb in [false, true] {
candidate.is_kcb = is_kcb;
let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
@@ -1113,7 +1263,7 @@ mod tests {
fn configurable_bjse_filter_can_be_disabled() {
let date = d(2025, 1, 2);
let mut candidate = candidate(date);
candidate.symbol = "430047.BJ".to_string();
candidate.symbol = "430047.BJ".into();
candidate.allow_sell = true;
let market = market(date, 6.27, 5.63);
let default_selection =
@@ -1145,7 +1295,7 @@ mod tests {
fn concrete_kcb_reason_wins_over_generic_missing_risk_state() {
let date = d(2025, 1, 2);
let mut candidate = candidate(date);
candidate.symbol = "688506.SH".to_string();
candidate.symbol = "688506.SH".into();
candidate.is_kcb = true;
candidate.risk_level_code = Some("missing_risk_state".to_string());
let market = market(date, 6.27, 5.63);
+1 -1
View File
@@ -233,7 +233,7 @@ impl<'a> Scheduler<'a> {
pub fn default_stage_time(stage: ScheduleStage) -> Option<NaiveTime> {
match stage {
ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")),
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")),
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 25, 0).expect("valid time")),
ScheduleStage::Bar => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
ScheduleStage::Minute => None,
ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
+191
View File
@@ -0,0 +1,191 @@
use std::ops::Index;
use super::prefix_sums;
#[derive(Debug, Clone)]
pub(super) enum ReferenceMatchedValues {
Identical,
Owned(Vec<f64>),
}
impl ReferenceMatchedValues {
pub(super) fn push(&mut self, value: f64, reference: &[f64], capacity: usize) {
let previous_len = reference.len().checked_sub(1).expect("reference row is missing");
match self {
Self::Identical if value.to_bits() == reference[previous_len].to_bits() => {}
Self::Identical => {
let mut values = Vec::with_capacity(capacity);
values.extend_from_slice(&reference[..previous_len]);
values.push(value);
*self = Self::Owned(values);
}
Self::Owned(values) => {
debug_assert_eq!(values.len(), previous_len);
values.push(value);
}
}
}
pub(super) fn values<'a>(&'a self, reference: &'a [f64]) -> &'a [f64] {
match self {
Self::Identical => reference,
Self::Owned(values) => {
debug_assert_eq!(values.len(), reference.len());
values
}
}
}
pub(super) fn set(&mut self, index: usize, value: f64, reference: &[f64]) {
assert!(index < reference.len(), "series index out of bounds");
match self {
Self::Owned(values) => values[index] = value,
Self::Identical if value.to_bits() == reference[index].to_bits() => {}
Self::Identical => {
let mut values = reference.to_vec();
values[index] = value;
*self = Self::Owned(values);
}
}
}
pub(super) fn prefix(&self) -> Self {
match self {
Self::Identical => Self::Identical,
Self::Owned(values) => Self::Owned(prefix_sums(values)),
}
}
}
#[derive(Debug, Clone)]
pub(super) struct RepeatedValues<T> {
repeated: T,
values: Option<Vec<T>>,
len: usize,
}
impl<T: Default + Clone + Eq> RepeatedValues<T> {
pub(super) fn new() -> Self {
Self { repeated: T::default(), values: None, len: 0 }
}
pub(super) fn push(&mut self, value: &T, capacity: usize) {
if let Some(values) = &mut self.values {
values.push(value.clone());
} else if self.len == 0 {
self.repeated = value.clone();
} else if *value != self.repeated {
let mut values = Vec::with_capacity(capacity);
values.resize(self.len, std::mem::take(&mut self.repeated));
values.push(value.clone());
self.values = Some(values);
}
self.len += 1;
}
pub(super) fn set(&mut self, index: usize, value: T) {
assert!(index < self.len, "series index out of bounds");
if let Some(values) = &mut self.values {
values[index] = value;
} else if value != self.repeated {
let mut values = vec![std::mem::take(&mut self.repeated); self.len];
values[index] = value;
self.values = Some(values);
}
}
}
impl<T> Index<usize> for RepeatedValues<T> {
type Output = T;
fn index(&self, index: usize) -> &T {
assert!(index < self.len, "series index out of bounds");
match &self.values {
Some(values) => &values[index],
None => &self.repeated,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bits(values: &[f64]) -> Vec<u64> {
values.iter().map(|value| value.to_bits()).collect()
}
#[test]
fn identical_prices_share_only_after_exact_bit_comparison() {
let reference = [10., -0., f64::from_bits(0x7ff8_0000_0000_0042), f64::INFINITY];
let mut column = ReferenceMatchedValues::Identical;
for (index, value) in reference.iter().copied().enumerate() {
column.push(value, &reference[..=index], reference.len());
}
assert!(matches!(column, ReferenceMatchedValues::Identical));
assert_eq!(column.values(&reference).as_ptr(), reference.as_ptr());
let prefix = prefix_sums(&reference);
assert_eq!(bits(column.prefix().values(&prefix)), bits(&prefix));
let original = column.clone();
column.set(1, 0., &reference);
assert!(matches!(column, ReferenceMatchedValues::Owned(_)));
assert_eq!(column.values(&reference)[1].to_bits(), 0_f64.to_bits());
assert_eq!(bits(original.values(&reference)), bits(&reference));
assert_eq!(bits(column.prefix().values(&prefix)), bits(&prefix_sums(column.values(&reference))));
}
#[test]
fn differing_prices_preserve_zero_nan_payloads_and_prior_rows() {
let reference = [10., 11., f64::from_bits(0x7ff8_0000_0000_0042), 13.];
for actual in [
[10., 0., reference[2], 13.],
[10., 11., f64::from_bits(0x7ff8_0000_0000_0043), 13.],
] {
let mut column = ReferenceMatchedValues::Identical;
for (index, value) in actual.iter().copied().enumerate() {
column.push(value, &reference[..=index], actual.len());
}
assert!(matches!(column, ReferenceMatchedValues::Owned(_)));
assert_eq!(bits(column.values(&reference)), bits(&actual));
}
}
#[test]
fn repeated_values_preserve_nonzero_values_and_copy_on_change() {
let mut column = RepeatedValues::new();
for _ in 0..128 { column.push(&7_u64, 128); }
assert!(column.values.is_none());
assert_eq!(column[127], 7);
column.set(0, 7);
assert!(column.values.is_none());
let mut changed = column.clone();
changed.set(64, 9);
assert_eq!(changed[64], 9);
assert_eq!(changed[63], 7);
assert_eq!(column[64], 7);
}
#[test]
fn optional_values_keep_none_distinct_from_empty_and_repeated_text() {
for repeated in [None, Some(String::new()), Some("continuous".to_string())] {
let mut column = RepeatedValues::new();
for _ in 0..12 { column.push(&repeated, 16); }
assert!(column.values.is_none());
assert_eq!(column[0], repeated);
column.push(&Some("closing".to_string()), 16);
assert_eq!(column[11], repeated);
assert_eq!(column[12].as_deref(), Some("closing"));
column.set(5, None);
assert_eq!(column[5], None);
assert_eq!(column[4], repeated);
}
}
#[test]
#[should_panic(expected = "series index out of bounds")]
fn repeated_values_reject_out_of_range_access() {
let column = RepeatedValues::<u64>::new();
let _ = column[0];
}
}
+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..]);
}
}
@@ -0,0 +1,108 @@
//! Configurable index-to-market-cap band. Values are CNY, not implicit yi.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IndexMarketCapPolicy {
pub schema_version: u32,
pub index_code: String,
pub field: String,
pub value_unit: String,
pub index_low: f64,
pub index_high: f64,
pub lower_at_low: f64,
pub lower_at_high: f64,
pub upper_at_low: f64,
pub upper_at_high: f64,
}
impl IndexMarketCapPolicy {
pub fn validate(&self) -> Result<(), String> {
if self.schema_version != 1 || self.value_unit != "CNY"
|| !matches!(self.field.as_str(), "market_cap" | "float_market_cap")
{ return Err("index_market_cap_contract_invalid".into()); }
let index = self.index_code.split_once('.').is_some_and(|(code, exchange)| {
(6..=12).contains(&code.len())
&& code.bytes().all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
&& matches!(exchange, "SH" | "SZ" | "CSI" | "CNI")
});
if !index { return Err("index_market_cap_index_invalid".into()); }
if [self.index_low,self.index_high,self.lower_at_low,self.lower_at_high,self.upper_at_low,self.upper_at_high]
.iter().any(|value| !value.is_finite() || *value <= 0.)
|| self.index_low >= self.index_high || self.lower_at_low > self.upper_at_low
|| self.lower_at_high > self.upper_at_high
{ return Err("index_market_cap_bounds_invalid".into()); }
Ok(())
}
pub fn band(&self, close: f64) -> Result<(f64, f64), String> {
self.validate()?;
if !close.is_finite() || close <= 0. { return Err("index_market_cap_close_invalid".into()); }
let t = (close.clamp(self.index_low,self.index_high) - self.index_low) / (self.index_high-self.index_low);
Ok((self.lower_at_low + t*(self.lower_at_high-self.lower_at_low),
self.upper_at_low + t*(self.upper_at_high-self.upper_at_low)))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IndexMarketCapRow { pub date: NaiveDate, pub close: f64 }
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub policy: IndexMarketCapPolicy,
pub official_dates: Vec<NaiveDate>,
pub index_code: String,
pub closes: Vec<IndexMarketCapRow>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Band { pub date: NaiveDate, pub index_close: f64, pub lower: f64, pub upper: f64 }
pub fn implementation_sha256() -> String { format!("{:x}", Sha256::digest(include_bytes!("stock_pool_market_cap.rs"))) }
pub fn evaluate(input: &Request) -> Result<Vec<Band>, String> {
input.policy.validate()?;
if input.index_code != input.policy.index_code || input.official_dates.is_empty()
|| input.official_dates.len() > 4000 || input.official_dates.len() != input.closes.len()
|| input.official_dates.windows(2).any(|pair| pair[0]>=pair[1])
|| input.closes.iter().zip(&input.official_dates).any(|(row, day)| row.date != *day)
{ return Err("index_market_cap_calendar_or_identity_mismatch".into()); }
input.closes.iter().map(|row| {
let (lower,upper)=input.policy.band(row.close)?;
Ok(Band{date:row.date,index_close:row.close,lower,upper})
}).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn policy()->IndexMarketCapPolicy {
serde_json::from_value(serde_json::json!({"schema_version":1,"index_code":"000300.SH","field":"market_cap","value_unit":"CNY",
"index_low":4000,"index_high":6000,"lower_at_low":2000000000_f64,"lower_at_high":3000000000_f64,
"upper_at_low":5000000000_f64,"upper_at_high":8000000000_f64})).unwrap()
}
#[test]
fn interpolates_declared_endpoints_and_clamps_without_business_defaults(){
assert_eq!(policy().band(3000.).unwrap(),(2e9,5e9));
assert_eq!(policy().band(5000.).unwrap(),(2.5e9,6.5e9));
assert_eq!(policy().band(7000.).unwrap(),(3e9,8e9));
let mut decreasing=policy();decreasing.lower_at_low=3e9;decreasing.lower_at_high=2e9;
assert_eq!(decreasing.band(5000.).unwrap(),(2.5e9,6.5e9));
assert!(policy().band(f64::NAN).is_err());
let mut invalid=policy();invalid.value_unit="亿元".into();assert!(invalid.validate().is_err());
invalid=policy();invalid.index_high=4000.;assert!(invalid.validate().is_err());
invalid=policy();invalid.lower_at_low=9e9;assert!(invalid.validate().is_err());
}
#[test]
fn missing_duplicate_or_mismatched_index_inputs_do_not_shrink_the_calendar(){
let day=NaiveDate::from_ymd_opt(2026,9,11).unwrap();
let mut input=Request{policy:policy(),official_dates:vec![day],index_code:"000300.SH".into(),closes:vec![IndexMarketCapRow{date:day,close:5000.}]};
assert_eq!(evaluate(&input).unwrap()[0].lower,2.5e9);
input.official_dates.push(day);assert!(evaluate(&input).is_err());input.official_dates.pop();
input.index_code="932000.CSI".into();assert!(evaluate(&input).is_err());
input.index_code="000300.SH".into();input.closes.clear();assert!(evaluate(&input).is_err());
}
}
@@ -0,0 +1,148 @@
//! Condition facts are distinct from the quote's per-observation fill capacity.
//! Only a complete, declared raw-minute prefix can prove a session total.
use std::collections::BTreeMap;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, Timelike};
use rust_decimal::Decimal;
use crate::data::IntradayExecutionQuote;
use crate::stock_pool_execution::{parse_stock_pool_condition, StockPoolExecutionRule};
pub fn requires_session_totals(rule: &StockPoolExecutionRule) -> bool {
[rule.buy_condition.as_str(), if rule.sell_trigger_mode == "condition" { rule.sell_condition.as_str() } else { "" }].into_iter().any(|condition| {
parse_stock_pool_condition(condition).is_some_and(|(_, field, _, _)| matches!(field.as_str(), "volume" | "amount"))
})
}
/// The cash-equity minute feed includes the opening observation and a separate
/// post-close segment. Trading eligibility remains owned by the dated rules.
fn next_minute(time: NaiveTime) -> Option<NaiveTime> {
let minute = time.hour() * 60 + time.minute();
let next = match minute {
570..=689 | 781..=899 | 906..=929 => minute + 1,
690 => 781,
900 => 906,
_ => return None,
};
NaiveTime::from_hms_opt(next / 60, next % 60, 0)
}
#[derive(Debug, Default)]
pub(crate) struct SessionTotalsCache {
pub date: Option<NaiveDate>,
pub symbols: BTreeMap<String, MinutePrefix>,
}
#[derive(Debug)]
pub(crate) struct MinutePrefix {
values: BTreeMap<NaiveTime, (Decimal, Decimal)>,
failure: String,
}
impl MinutePrefix {
pub fn build(date: NaiveDate, symbol: &str, quotes: &[IntradayExecutionQuote]) -> Self {
let mut values = BTreeMap::new();
let mut expected = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
let mut volume = 0_u64;
let mut amount = Decimal::ZERO;
let mut failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
for quote in quotes {
let time = quote.timestamp.time();
if quote.date != date || quote.timestamp.date() != date || quote.symbol != symbol {
failure = format!("stock_pool_session_prefix_identity_invalid:{symbol}:{date}");
break;
}
if time != expected {
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:expected={expected}:observed={time}");
break;
}
if quote.observation_kind != crate::data::QuoteObservationKind::MinuteBar {
failure = format!("stock_pool_session_prefix_basis_unverified:{symbol}:{date}:{time}");
break;
}
let Some(next_volume) = volume.checked_add(quote.volume_delta) else {
failure = format!("stock_pool_session_volume_overflow:{symbol}:{date}:{time}");
break;
};
let delta = if quote.amount_delta.is_finite() && quote.amount_delta >= 0.0 {
quote.amount_delta.to_string().parse::<Decimal>().ok()
} else { None };
let Some(next_amount) = delta.and_then(|delta| amount.checked_add(delta)) else {
failure = format!("stock_pool_session_amount_invalid:{symbol}:{date}:{time}");
break;
};
volume = next_volume;
amount = next_amount;
values.insert(time, (Decimal::from(volume), amount));
let Some(next) = next_minute(time) else { break };
expected = next;
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
}
Self { values, failure }
}
pub fn at(&self, at: NaiveDateTime) -> Result<(Decimal, Decimal), String> {
let time = at.time().with_second(0).unwrap().with_nanosecond(0).unwrap();
self.values.get(&time).copied().ok_or_else(|| self.failure.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn quote(hour: u32, minute: u32, volume: u64, amount: f64) -> IntradayExecutionQuote {
let date = NaiveDate::from_ymd_opt(2026, 9, 11).unwrap();
IntradayExecutionQuote { observation_kind: crate::data::QuoteObservationKind::MinuteBar, date, symbol: "000001.SZ".into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
last_price: 10., bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
volume_delta: volume, amount_delta: amount, trading_phase: Some("minute_execution_prices:raw-minute".into()) }
}
#[test]
fn totals_use_only_the_complete_observed_prefix_and_keep_decimal_amounts() {
let mut rows = vec![quote(9,30,100,10.01), quote(9,31,0,0.), quote(9,32,200,20.02)];
let prefix = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
assert_eq!(prefix.at(rows[1].timestamp).unwrap(), (100.into(), Decimal::new(1001,2)));
assert_eq!(prefix.at(rows[2].timestamp).unwrap(), (300.into(), Decimal::new(3003,2)));
rows[2].volume_delta = 999999;
rows[2].amount_delta = f64::NAN;
let changed = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
assert_eq!(changed.at(rows[1].timestamp).unwrap(), prefix.at(rows[1].timestamp).unwrap());
assert!(changed.at(rows[2].timestamp).unwrap_err().contains("amount_invalid"));
}
#[test]
fn sparse_unverified_and_overflowing_quotes_cannot_be_called_session_totals() {
let first = quote(9,30,100,1000.);
for rows in [vec![quote(9,31,100,1000.)], vec![first.clone(), quote(9,32,100,1000.)]] {
let prefix = MinutePrefix::build(first.date, "000001.SZ", &rows);
assert!(prefix.at(rows.last().unwrap().timestamp).unwrap_err().contains("prefix_missing"));
}
let mut unknown = first.clone(); unknown.observation_kind = Default::default();
assert!(MinutePrefix::build(first.date, "000001.SZ", &[unknown]).at(first.timestamp).unwrap_err().contains("basis_unverified"));
let rows = [quote(9,30,u64::MAX,0.), quote(9,31,1,0.)];
assert!(MinutePrefix::build(first.date, "000001.SZ", &rows).at(rows[1].timestamp).unwrap_err().contains("volume_overflow"));
}
#[test]
fn lunch_and_post_close_gaps_follow_the_minute_feed_segments() {
let mut rows = Vec::new(); let mut time = NaiveTime::from_hms_opt(9,30,0).unwrap();
loop {
rows.push(quote(time.hour(), time.minute(), 1, 0.01));
let Some(next) = next_minute(time) else { break }; time=next;
}
let prefix=MinutePrefix::build(rows[0].date,"000001.SZ",&rows);
assert_eq!(prefix.at(rows.last().unwrap().timestamp).unwrap(), (Decimal::from(rows.len()), Decimal::new(rows.len() as i64,2)));
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==12));
assert!(!rows.iter().any(|row| row.timestamp.time()==NaiveTime::from_hms_opt(13,0,0).unwrap()));
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==15 && (1..6).contains(&row.timestamp.time().minute())));
}
#[test]
#[ignore = "requires FIDC_SESSION_PREFIX_SOURCE_JSON from the frozen Source minute response"]
fn real_source_session_prefix_matches_observed_checkpoints() {
let path=std::env::var("FIDC_SESSION_PREFIX_SOURCE_JSON").expect("explicit Source evidence path");
let rows:Vec<IntradayExecutionQuote>=serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
let date=NaiveDate::from_ymd_opt(2026,9,8).unwrap();
assert_eq!(rows.len(),242);
let prefix=MinutePrefix::build(date,"000063.SZ",&rows);
for (hour,minute,volume,amount) in [(9,30,512700,17103672),(9,31,2296631,76576756),(9,32,2983531,99471024),(11,30,27868847,928167630),(13,1,28495518,948994890),(15,0,45625008,1518115100)] {
assert_eq!(prefix.at(date.and_hms_opt(hour,minute,0).unwrap()).unwrap(),(Decimal::from(volume),Decimal::from(amount)));
}
assert!(prefix.at(date.and_hms_opt(15,30,0).unwrap()).unwrap_err().contains("prefix_missing"),"one final aggregate is not a verified intraday prefix");
}
}
+345
View File
@@ -0,0 +1,345 @@
//! 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 StockPoolPositionActionBasis {
pub generation: String,
pub first_execution_date: NaiveDate,
pub quantity: 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>,
/// Signal progress, not a fill or holding-period fact. Kept across retries
/// and later execution sessions until a new generation supersedes it.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub position_action_bases: BTreeMap<String, StockPoolPositionActionBasis>,
}
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(),
position_action_bases: BTreeMap::new(),
}
}
}
impl StockPoolExecutionState {
pub fn validate(&self) -> Result<(), String> {
if self.schema_version != 1
|| self.entries.len() > 10000
|| self.removed_since.len() > 10000
|| self.position_action_bases.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())
.chain(self.position_action_bases.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());
}
if self.position_action_bases.values().any(|basis| {
basis.generation.trim().is_empty() || basis.quantity <= Decimal::ZERO
|| self.last_execution_date.is_none_or(|date| basis.first_execution_date > date)
}) {
return Err("stock_pool_execution_state_invalid_action_basis".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().filter(|row| !plan.position_action_bases.contains_key(&row.symbol)).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,
}),
)?.record_position_action_bases(generation, &plan.position_action_bases)
}
pub fn position_action_bases_for(&self, generation: &str) -> BTreeMap<String, Decimal> {
self.position_action_bases.iter()
.filter(|(_, basis)| basis.generation == generation)
.map(|(symbol, basis)| (symbol.clone(), basis.quantity))
.collect()
}
/// A verified split changes the share unit, not the intended reduction or
/// entry completion. Never infer a split from a changed holding quantity.
pub fn adjust_for_split(&self, symbol: &str, ratio: Decimal) -> Result<Self, String> {
self.validate()?;
if ratio <= Decimal::ZERO || normalize_stock_symbol(symbol).as_deref() != Some(symbol) {
return Err("stock_pool_execution_state_split_invalid".into());
}
let scale = |quantity: Decimal| quantity.checked_mul(ratio)
.map(|value| value.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero))
.ok_or_else(|| "stock_pool_execution_state_split_overflow".to_string());
let mut next = self.clone();
if let Some(entry) = next.entries.get_mut(symbol) {
if let Some(quantity) = entry.completion_quantity {
let quantity = scale(quantity)?;
entry.completion_quantity = (quantity > Decimal::ZERO).then_some(quantity);
}
}
if let Some(basis) = next.position_action_bases.get_mut(symbol) {
basis.quantity = scale(basis.quantity)?;
if basis.quantity == Decimal::ZERO { next.position_action_bases.remove(symbol); }
}
next.validate()?;
Ok(next)
}
pub fn record_position_action_bases(
&self,
generation: &str,
quantities: &BTreeMap<String, Decimal>,
) -> Result<Self, String> {
self.validate()?;
if generation.trim().is_empty() {
return Err("stock_pool_execution_state_action_generation_missing".into());
}
let first_execution_date = self.last_execution_date
.ok_or("stock_pool_execution_state_action_clock_missing")?;
let mut next = self.clone();
next.position_action_bases.retain(|_, basis| basis.generation == generation);
for (symbol, quantity) in quantities {
if let Some(basis) = next.position_action_bases.get(symbol) {
if basis.quantity != *quantity {
return Err(format!("stock_pool_execution_state_action_basis_changed:{symbol}"));
}
} else {
next.position_action_bases.insert(symbol.clone(), StockPoolPositionActionBasis {
generation: generation.into(), first_execution_date, quantity: *quantity,
});
}
}
next.validate()?;
Ok(next)
}
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.status == "AUTOMATIC_TRADE_PROTECTED" {
continue;
}
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)
}
}
+106 -11
View File
@@ -20,6 +20,18 @@ use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSe
pub trait Strategy {
fn name(&self) -> &str;
fn bind_runtime_position_configuration(
&mut self,
events: &[crate::position_exposure::PositionExposureEvent],
legacy: &BTreeMap<NaiveDate, i32>,
) -> Result<(), BacktestError> {
if !events.is_empty() || !legacy.is_empty() {
return Err(BacktestError::Execution(
"strategy does not implement runtime position configuration".into(),
));
}
Ok(())
}
fn initial_subscriptions(&self) -> BTreeSet<String> {
BTreeSet::new()
}
@@ -40,6 +52,12 @@ pub trait Strategy {
) -> Result<(), BacktestError> {
Ok(())
}
/// External, already executed manual activity. It is not a new strategy
/// order and must not be run through order generation or transaction costs.
fn on_observed_manual_execution(
&mut self,
_execution: &crate::manual_execution::ManualReplayApplication,
) -> Result<(), BacktestError> { Ok(()) }
fn schedule_rules(&self) -> Vec<ScheduleRule> {
Vec::new()
}
@@ -102,6 +120,7 @@ pub struct OpenOrderView {
pub avg_price: f64,
pub transaction_cost: f64,
pub limit_price: f64,
pub reserved_cash: Option<f64>,
pub reason: String,
}
@@ -497,6 +516,7 @@ impl StrategyContext<'_> {
.iter()
.filter(|order| order.side == OrderSide::Buy)
.map(|order| {
if let Some(reserved) = order.reserved_cash { return reserved; }
let price = if order.limit_price.is_finite() {
order.limit_price.max(0.0)
} else {
@@ -975,7 +995,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
}
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct StrategyDecision {
pub buy_denials: BTreeMap<String, String>,
pub rebalance: bool,
@@ -988,6 +1008,15 @@ pub struct StrategyDecision {
}
impl StrategyDecision {
pub(crate) fn is_portfolio_target_only(&self) -> bool {
(self.rebalance && self.order_intents.is_empty())
|| (self.order_intents.len() == 1
&& matches!(
self.order_intents[0].unwrapped(),
OrderIntent::StockPool { .. } | OrderIntent::TargetPortfolioSmart { .. }
))
}
pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet<String> {
let mut symbols = BTreeSet::new();
if self.rebalance {
@@ -1001,9 +1030,24 @@ impl StrategyDecision {
}
pub fn merge_from(&mut self, mut other: StrategyDecision) {
if self.is_portfolio_target_only() && other.is_portfolio_target_only() {
let mut previous = std::mem::replace(self, other);
previous
.diagnostics
.push("unsubmitted_portfolio_target_superseded".into());
self.notes.splice(0..0, previous.notes);
self.diagnostics.splice(0..0, previous.diagnostics);
return;
}
self.buy_denials.append(&mut other.buy_denials);
self.rebalance |= other.rebalance;
self.target_weights.append(&mut other.target_weights);
if other.rebalance {
// Rebalance targets are a complete portfolio, not an additive
// list. A newer unsent target replaces the earlier allocation.
self.rebalance = true;
self.target_weights = std::mem::take(&mut other.target_weights);
} else {
self.target_weights.append(&mut other.target_weights);
}
self.exit_symbols.append(&mut other.exit_symbols);
self.order_intents.append(&mut other.order_intents);
self.notes.append(&mut other.notes);
@@ -1023,13 +1067,59 @@ impl StrategyDecision {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg(test)]
mod decision_merge_tests {
use super::*;
#[test]
fn newer_complete_target_replaces_old_symbols_without_discarding_explicit_actions() {
let mut earlier = StrategyDecision {
rebalance: true,
target_weights: BTreeMap::from([("A".into(), 0.5), ("B".into(), 0.5)]),
exit_symbols: BTreeSet::from(["risk_exit".into()]),
order_intents: vec![OrderIntent::Shares {
symbol: "explicit".into(),
quantity: 100,
reason: "explicit action".into(),
}],
..Default::default()
};
earlier.merge_from(StrategyDecision {
rebalance: true,
target_weights: BTreeMap::from([("C".into(), 1.)]),
..Default::default()
});
assert_eq!(earlier.target_weights, BTreeMap::from([("C".into(), 1.)]));
assert!(earlier.rebalance);
assert!(earlier.exit_symbols.contains("risk_exit"));
assert_eq!(earlier.order_intents.len(), 1);
}
#[test]
fn explicit_empty_complete_target_replaces_old_allocation_but_empty_callback_does_not() {
let mut decision = StrategyDecision {
rebalance: true,
target_weights: BTreeMap::from([("A".into(), 1.)]),
..Default::default()
};
decision.merge_from(StrategyDecision::default());
assert_eq!(decision.target_weights.len(), 1);
decision.merge_from(StrategyDecision {
rebalance: true,
..Default::default()
});
assert!(decision.target_weights.is_empty());
assert!(decision.rebalance);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum AlgoOrderStyle {
Vwap,
Twap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum OrderTimeInForce {
Day,
Ioc,
@@ -1058,7 +1148,7 @@ impl OrderTimeInForce {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, serde::Serialize)]
pub enum TargetPortfolioOrderPricing {
LimitPrices(BTreeMap<String, f64>),
AlgoOrder {
@@ -1068,8 +1158,11 @@ pub enum TargetPortfolioOrderPricing {
},
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, serde::Serialize)]
pub enum OrderIntent {
StockPool {
contract: Box<crate::stock_pool_execution::FrozenStockPoolIntent>,
},
WithTimeInForce {
intent: Box<OrderIntent>,
time_in_force: OrderTimeInForce,
@@ -1231,6 +1324,7 @@ 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()); }
@@ -1311,6 +1405,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 { .. }
@@ -3179,7 +3274,7 @@ mod tests {
.enumerate()
.map(|(index, date)| DailyMarketSnapshot {
date: *date,
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: 10.0 + index as f64,
open: 10.0 + index as f64,
@@ -3205,21 +3300,21 @@ mod tests {
.iter()
.map(|date| DailyFactorSnapshot {
date: *date,
symbol: symbol.to_string(),
symbol: symbol.into(),
market_cap_bn: 10.0,
free_float_cap_bn: 9.0,
pe_ttm: 12.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
})
.collect::<Vec<_>>();
let candidate_rows = dates
.iter()
.map(|date| CandidateEligibility {
date: *date,
symbol: symbol.to_string(),
symbol: symbol.into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
+5 -1
View File
@@ -254,7 +254,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
},
ManualSection {
title: "corporate_actions.dividend_reinvestment".to_string(),
detail: "支持 corporate_actions.dividend_reinvestment(true)。开启后,现金分红到账会优先按 round lot 回补成同一只股票,零头保留为现金".to_string(),
detail: "历史兼容的回测账务再投模型:corporate_actions.dividend_reinvestment(true) 在分红结算时按调整后的参考价分配整手股数,零头留现金,费用为0;来源标记为 dividend_reinvestment,不是交易所委托或真实自动买入。新策略不应手工处理公司行为".to_string(),
},
ManualSection {
title: "execution.matching_type / execution.slippage".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(),
+4 -4
View File
@@ -83,7 +83,7 @@ impl SelectionContext<'_> {
for factor in self.data.factor_snapshot_rows_on(self.decision_date) {
if self
.dynamic_universe
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(&factor.symbol))
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(factor.symbol.as_str()))
{
continue;
}
@@ -307,7 +307,7 @@ mod tests {
fn market(symbol: &str, price: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date: d(),
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: Some("2025-01-02 10:00:00".to_string()),
day_open: price,
open: price,
@@ -333,7 +333,7 @@ mod tests {
fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date: d(),
symbol: symbol.to_string(),
symbol: symbol.into(),
market_cap_bn,
free_float_cap_bn: market_cap_bn,
pe_ttm: 10.0,
@@ -347,7 +347,7 @@ mod tests {
fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility {
CandidateEligibility {
date: d(),
symbol: symbol.to_string(),
symbol: symbol.into(),
is_st,
is_star_st: false,
is_new_listing: false,
@@ -0,0 +1,490 @@
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(),
}
}
#[test]
fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() {
for sell_during_lock in [false, true] {
let sale = if sell_during_lock {
("manual-sell", "Sell", "2026-09-16T01:31:00Z", "2026-09-16T01:31:01Z", "5", "0.5", 200)
} else {
("manual-sell", "Sell", "2026-09-14T01:31:00Z", "2026-09-14T01:31:01Z", "10", "0.5", 100)
};
let actions = [
("new-buy", "Buy", "2026-09-14T01:30:00Z", "2026-09-14T01:30:01Z", "10", "0.25", 100),
("late-buy", "Buy", "2026-09-11T06:00:00Z", "2026-09-14T01:30:02Z", "10", "0.75", 100),
sale,
].into_iter().enumerate().map(|(index, (id, side, executed, observed, price, fee, quantity))| {
let executed: chrono::DateTime<chrono::Utc> = executed.parse().unwrap();
let observed: chrono::DateTime<chrono::Utc> = observed.parse().unwrap();
let created = executed - chrono::Duration::seconds(1);
serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")],
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{
"orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity,
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
"tradeId":id,"observationEventId":id,"observationSequence":index+1,
"tradeDate":executed.date_naive(),"executedAt":executed,"observedAt":observed,
"feeObservationEventId":id,"feeObservationSequence":index+1,"feeObservedAt":observed,
"timestampPrecision":"second","quantity":quantity,"price":price,"totalFee":fee
}]
}]})
}).collect::<Vec<_>>();
let mut replay: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a",
"sourceContractSha256":"a".repeat(64),"contentSha256":"","observationCutoff":"2026-09-18T08:00:00Z","actions":actions,
})).unwrap();
replay.content_sha256 = replay.content_digest().unwrap();
let mut parts = data().snapshot_components();
for row in &mut parts.market {
if row.date >= d(15) {
row.day_open = 5.;
row.open = 5.;
row.high = 5.;
row.low = 5.;
row.close = 5.;
row.last_price = 5.;
row.bid1 = 5.;
row.ask1 = 5.;
row.prev_close = 5.;
row.upper_limit = 5.5;
row.lower_limit = 4.5;
}
}
parts.corporate_actions.push(fidc_core::CorporateAction {
date: d(15),
symbol: "000001.SZ".into(),
payable_date: None,
share_cash: 0.,
share_bonus: 1.,
share_gift: 0.,
issue_quantity: 0.,
issue_price: 0.,
reform: false,
adjust_factor: None,
successor_symbol: None,
successor_ratio: None,
successor_cash: None,
});
let data = DataSet::from_components_with_actions(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
parts.corporate_actions,
)
.unwrap();
let mut config = PlatformExprStrategyConfig::generic();
config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into();
config.rotation_enabled = false;
config.matching_type = MatchingType::CurrentBarClose;
config.volume_capacity_mode =
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.automatic_trade_protection = AutomaticTradeProtection {
buy_protection_days: 3,
sell_cooldown_days: 3,
max_holding_days: 1,
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d(16),
end_date: Some(d(17)),
}],
};
config.explicit_actions = vec![action("-200", "decision_date >= \"2026-09-14\"")];
let result = BacktestEngine::new(
data,
PlatformExprStrategy::new(config),
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose)
.with_volume_capacity_mode(
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit,
),
BacktestConfig {
initial_cash: 10000.,
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,
},
)
.with_observed_manual_executions(replay)
.unwrap()
.run()
.unwrap();
assert_eq!(result.manual_executions.len(), 3);
assert_eq!(result.manual_executions[2].quantity_after, if sell_during_lock { 200 } else { 100 });
assert_eq!(result.fills.len(), 1, "{:?}", result.fills);
assert_eq!(
(
result.fills[0].date,
result.fills[0].side,
result.fills[0].quantity,
result.fills[0].price
),
(d(18), OrderSide::Sell, 200, 5.)
);
assert!(result.fills[0].reason.contains("max_holding_days_exit"));
for day in [14, 15] {
for rule in ["buy_fill_protection", "sell_fill_cooldown"] {
if rule == "sell_fill_cooldown" && sell_during_lock { continue; }
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
&& audit.symbol == "000001.SZ" && audit.rule_code == rule && !audit.accepted), "day={day} rule={rule}");
}
}
for day in [16, 17] {
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
&& audit.symbol == "000001.SZ" && audit.rule_code == "automatic_trade_locked" && !audit.accepted));
}
assert!(
result
.daily_holdings
.iter()
.any(|row| row.date == d(15) && row.quantity == if sell_during_lock { 400 } else { 200 })
);
assert!(result.holdings_summary.is_empty());
assert!(
result
.equity_curve
.iter()
.all(|point| point.external_cash_flow == 0.)
);
}
}
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
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_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.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.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
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_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.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
);
}
+3 -3
View File
@@ -15,7 +15,7 @@ fn d(year: i32, month: u32, day: u32) -> NaiveDate {
fn candidate() -> CandidateEligibility {
CandidateEligibility {
date: d(2024, 1, 3),
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -31,7 +31,7 @@ fn candidate() -> CandidateEligibility {
fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date: d(2024, 1, 3),
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2024-01-03 10:18:00".to_string()),
day_open: open,
open,
@@ -256,7 +256,7 @@ fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() {
let snapshot = DailyMarketSnapshot {
date: d(2024, 4, 7),
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2024-04-07 10:18:00".to_string()),
day_open: 2.53,
open: 2.53,
+169 -18
View File
@@ -113,7 +113,7 @@ impl Strategy for BuyAndHoldStrategy {
fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: 10.0,
open: 10.0,
@@ -139,21 +139,21 @@ fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
}
}
fn stock_candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -177,6 +177,157 @@ fn benchmark_snapshot(date: NaiveDate) -> BenchmarkSnapshot {
}
}
#[test]
fn successor_conversion_depletes_older_source_lots_before_newer_successor_buys() {
struct ConvertedSale {
dates: [NaiveDate; 3],
seen: std::rc::Rc<std::cell::RefCell<Option<(Option<NaiveDate>, Option<NaiveDate>)>>>,
}
impl Strategy for ConvertedSale {
fn name(&self) -> &str {
"successor FIFO"
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
let (symbol, quantity) = if ctx.execution_date == self.dates[0] {
("000001.SZ", 100)
} else if ctx.execution_date == self.dates[1] {
("000002.SZ", 100)
} else {
let holding = ctx.portfolio.position("000002.SZ").unwrap();
*self.seen.borrow_mut() = Some((holding.opened_date(), holding.last_buy_date()));
("000002.SZ", -200)
};
Ok(StrategyDecision {
order_intents: vec![fidc_core::OrderIntent::Shares {
symbol: symbol.into(),
quantity,
reason: "dated lot test".into(),
}],
..Default::default()
})
}
}
let dates = [d(2026, 9, 11), d(2026, 9, 14), d(2026, 9, 15)];
let symbols = ["000001.SZ", "000002.SZ"];
let mut market = Vec::new();
let mut factors = Vec::new();
let mut candidates = Vec::new();
for date in dates {
for symbol in symbols {
let price = if symbol == symbols[0] {
10.
} else if date == dates[2] {
6.
} else {
20.
};
let mut quote = stock_market_snapshot(date);
quote.symbol = symbol.into();
quote.day_open = price;
quote.open = price;
quote.high = price;
quote.low = price;
quote.close = price;
quote.last_price = price;
quote.bid1 = price;
quote.ask1 = price;
quote.prev_close = price;
quote.upper_limit = price * 1.1;
quote.lower_limit = price * 0.9;
market.push(quote);
let mut factor = stock_factor_snapshot(date);
factor.symbol = symbol.into();
factors.push(factor);
let mut candidate = stock_candidate(date);
candidate.symbol = symbol.into();
candidates.push(candidate);
}
}
let data = DataSet::from_components_with_actions(
symbols
.into_iter()
.map(|symbol| Instrument {
symbol: symbol.into(),
name: symbol.into(),
board: "SZ".into(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".into(),
})
.collect(),
market,
factors,
candidates,
dates.map(benchmark_snapshot).into(),
vec![CorporateAction {
date: dates[2],
symbol: symbols[0].into(),
payable_date: None,
share_cash: 0.,
share_bonus: 0.,
share_gift: 0.,
issue_quantity: 0.,
issue_price: 0.,
reform: false,
adjust_factor: None,
successor_symbol: Some(symbols[1].into()),
successor_ratio: Some(2.),
successor_cash: Some(0.),
}],
)
.unwrap();
let seen = std::rc::Rc::new(std::cell::RefCell::new(None));
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_matching_type(fidc_core::MatchingType::NextBarOpen)
.with_volume_limit(false)
.with_liquidity_limit(false);
let result = BacktestEngine::new(
data,
ConvertedSale {
dates,
seen: seen.clone(),
},
broker,
BacktestConfig {
initial_cash: 10000.,
benchmark_code: "000300.SH".into(),
start_date: Some(dates[0]),
end_date: Some(dates[2]),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
)
.run()
.unwrap();
assert_eq!(*seen.borrow(), Some((Some(dates[0]), Some(dates[1]))));
assert_eq!(result.fills.len(), 3);
assert_eq!(result.fills[2].quantity, 200);
assert_eq!(result.fills[2].symbol, symbols[1]);
let remaining = result
.holdings_summary
.iter()
.find(|row| row.symbol == symbols[1])
.unwrap();
assert_eq!(remaining.quantity, 100);
assert_eq!(remaining.realized_pnl, 200.);
assert!(
result
.position_events
.iter()
.any(|event| event.symbol == symbols[0]
&& event.quantity_after == 0
&& event.reason.starts_with("successor_conversion"))
);
}
#[test]
fn engine_reinvests_dividend_receivable_in_round_lots() {
let buy_date = d(2025, 1, 1);
@@ -195,7 +346,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
vec![
DailyMarketSnapshot {
date: buy_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-01 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -218,7 +369,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
},
DailyMarketSnapshot {
date: ex_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -241,7 +392,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
},
DailyMarketSnapshot {
date: payable_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -266,42 +417,42 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
vec![
DailyFactorSnapshot {
date: buy_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: ex_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: payable_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
],
vec![
CandidateEligibility {
date: buy_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -314,7 +465,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
},
CandidateEligibility {
date: ex_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -327,7 +478,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
},
CandidateEligibility {
date: payable_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -394,7 +545,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
.with_minimum_commission(0.0),
ChinaEquityRuleHooks::default(),
PriceField::Open,
),
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
BacktestConfig {
initial_cash: 11_008.0,
benchmark_code: "000300.SH".to_string(),
@@ -475,7 +626,7 @@ fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
),
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
BacktestConfig {
initial_cash: 11_008.0,
benchmark_code: "000300.SH".to_string(),
@@ -2,10 +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::collections::BTreeSet;
use std::sync::{Arc, Mutex};
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -16,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,
@@ -90,10 +102,10 @@ impl Strategy for NoLoaderDecisionQuoteStrategy {
fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
DataSet::from_components(
Vec::new(),
fixture_instruments(),
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 15:00:00")),
day_open: 10.0,
open: 10.0,
@@ -116,7 +128,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
}],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -127,7 +139,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -151,14 +163,55 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
}
#[test]
fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
fn runtime_account_dependent_quote_scope_uses_the_actual_account() {
struct AccountDependentQuoteReader;
impl Strategy for AccountDependentQuoteReader {
fn name(&self) -> &str { "account_dependent_quote_reader" }
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> {
Ok(if ctx.portfolio.cash() < 50_000.0 {
BTreeSet::from(["000001.SZ".into()])
} else { BTreeSet::new() })
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, fidc_core::BacktestError> {
let loaded = ctx.data.execution_quotes_on(ctx.execution_date, "000001.SZ").iter().any(|quote|
quote.timestamp.time()==t(10,17,59) && quote.last_price==10.0);
assert_eq!(loaded, ctx.portfolio.cash() < 50_000.0,
"quote scope must match this account, not a fixed-capital planning account");
Ok(StrategyDecision::default())
}
}
let date = d(2026, 1, 5);
for initial_cash in [10_000.0, 100_000.0] {
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close,
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig {
initial_cash, benchmark_code:"000852.SH".into(),
start_date:Some(date), end_date:Some(date), decision_lag_trading_days:0,
execution_price_field:PriceField::Close,
};
let mut engine = BacktestEngine::new(single_day_quote_plan_data(date), AccountDependentQuoteReader, broker, config)
.with_execution_quote_loader(move |request| Ok(request.symbols.into_iter().map(|symbol| IntradayExecutionQuote {
observation_kind:Default::default(), 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".into()),
}).collect()));
engine.run().expect("account-dependent quote planning");
}
}
#[test]
fn engine_resolves_the_runtime_strategy_scope_when_a_loader_exists() {
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_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig {
initial_cash: 10_000.0,
@@ -174,17 +227,13 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
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 {
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date,
symbol,
timestamp: request.date.and_time(t(10, 17, 59)),
@@ -198,20 +247,19 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
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"
1,
"quote planning must use the actual run context"
);
assert_eq!(
*loader_calls.lock().expect("loader counter mutex"),
1,
"the supplied symbols must still pass through the normal quote loader"
0,
"an empty runtime scope must not fetch unrequested symbols"
);
}
@@ -223,7 +271,7 @@ fn engine_skips_decision_quote_symbol_plan_without_loader() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Close,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig {
initial_cash: 10_000.0,
@@ -253,11 +301,11 @@ 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,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -280,7 +328,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
},
DailyMarketSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5,
open: 10.5,
@@ -305,7 +353,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
vec![
DailyFactorSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -316,7 +364,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
},
DailyFactorSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -329,7 +377,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
vec![
CandidateEligibility {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -342,7 +390,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
},
CandidateEligibility {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -379,7 +427,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig {
@@ -399,7 +447,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
Ok(request
.symbols
.into_iter()
.map(|symbol| IntradayExecutionQuote {
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date,
symbol,
timestamp: request.date.and_time(t(10, 39, 59)),
@@ -423,11 +471,11 @@ 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,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -450,7 +498,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
},
DailyMarketSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5,
open: 10.5,
@@ -475,7 +523,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
vec![
DailyFactorSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -486,7 +534,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
},
DailyFactorSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -499,7 +547,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
vec![
CandidateEligibility {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -512,7 +560,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
},
CandidateEligibility {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -544,7 +592,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
],
Vec::new(),
vec![
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date: first,
symbol: "000001.SZ".to_string(),
timestamp: first.and_time(t(10, 39, 59)),
@@ -557,7 +605,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date: second,
symbol: "000001.SZ".to_string(),
timestamp: second.and_time(t(10, 39, 59)),
@@ -578,7 +626,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig {
@@ -658,11 +706,11 @@ 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,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -685,7 +733,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
},
DailyMarketSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5,
open: 10.5,
@@ -710,7 +758,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
vec![
DailyFactorSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -721,7 +769,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
},
DailyFactorSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -734,7 +782,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
vec![
CandidateEligibility {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -747,7 +795,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
},
CandidateEligibility {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -784,7 +832,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig {
@@ -814,7 +862,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
Ok(request
.symbols
.into_iter()
.map(|symbol| IntradayExecutionQuote {
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date,
symbol,
timestamp: request.date.and_time(start_time) - Duration::seconds(1),
+30 -30
View File
@@ -71,7 +71,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
vec![
DailyMarketSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -94,7 +94,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
},
DailyMarketSnapshot {
date: date1,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 5.0,
open: 5.0,
@@ -117,7 +117,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
},
DailyMarketSnapshot {
date: delist_date,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 5.05,
open: 5.05,
@@ -140,7 +140,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
},
DailyMarketSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: 5.1,
open: 5.1,
@@ -165,53 +165,53 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
vec![
DailyFactorSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: date1,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
market_cap_bn: 30.0,
free_float_cap_bn: 28.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: delist_date,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
market_cap_bn: 30.5,
free_float_cap_bn: 28.5,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
market_cap_bn: 31.0,
free_float_cap_bn: 29.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
],
vec![
CandidateEligibility {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -224,7 +224,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
},
CandidateEligibility {
date: date1,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -237,7 +237,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
},
CandidateEligibility {
date: delist_date,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -250,7 +250,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
},
CandidateEligibility {
date: date2,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -295,7 +295,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new(
data,
BuyThenHoldStrategy,
@@ -364,7 +364,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
vec![
DailyMarketSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -387,7 +387,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
},
DailyMarketSnapshot {
date: date1,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 20.0,
open: 20.0,
@@ -410,7 +410,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
},
DailyMarketSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 21.0,
open: 21.0,
@@ -435,42 +435,42 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
vec![
DailyFactorSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: date1,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
market_cap_bn: 30.0,
free_float_cap_bn: 28.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
market_cap_bn: 31.0,
free_float_cap_bn: 29.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
],
vec![
CandidateEligibility {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -483,7 +483,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
},
CandidateEligibility {
date: date1,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -496,7 +496,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
},
CandidateEligibility {
date: date2,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -548,7 +548,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new(
data,
BuyThenHoldStrategy,
@@ -0,0 +1,475 @@
use chrono::{NaiveDate, NaiveTime};
use fidc_core::{
BacktestConfig, BacktestEngine, BrokerSimulator, ChinaAShareCostModel, ChinaEquityRuleHooks,
DataSet, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision,
};
use std::{cell::RefCell, collections::BTreeSet, rc::Rc};
const SYMBOL: &str = "000001.SZ";
fn day(value: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 9, value).unwrap()
}
fn data() -> DataSet {
let days = [11, 14, 15].map(day);
DataSet::from_components_with_actions_and_quotes(
vec![fidc_core::Instrument {
symbol: SYMBOL.into(),
name: "fixture".into(),
board: "SZ".into(),
round_lot: 100,
listed_at: Some(day(1)),
delisted_at: None,
status: "active".into(),
}],
days.iter()
.map(|&date| {
let price = if date == day(11) { 10. } else { 8.95 };
fidc_core::DailyMarketSnapshot {
date,
symbol: SYMBOL.into(),
timestamp: Some(format!("{date} 15:00:00")),
day_open: price,
open: price,
high: price,
low: price,
close: price,
last_price: price,
bid1: price,
ask1: price,
prev_close: price,
volume: 100000,
minute_volume: 100000,
bid1_volume: 100000,
ask1_volume: 100000,
trading_phase: Some("continuous".into()),
paused: false,
upper_limit: price * 1.1,
lower_limit: price * 0.9,
price_tick: 0.01,
}
})
.collect(),
days.iter()
.map(|&date| fidc_core::DailyFactorSnapshot {
date,
symbol: SYMBOL.into(),
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(),
days.iter()
.map(|&date| fidc_core::CandidateEligibility {
date,
symbol: SYMBOL.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(),
days.iter()
.map(|&date| fidc_core::BenchmarkSnapshot {
date,
benchmark: "000300.SH".into(),
open: 100.,
close: 100.,
prev_close: 100.,
volume: 100000,
})
.collect(),
vec![fidc_core::CorporateAction {
date: day(14),
symbol: SYMBOL.into(),
payable_date: Some(day(14)),
share_cash: 1.05,
share_bonus: 0.,
share_gift: 0.,
issue_quantity: 0.,
issue_price: 0.,
reform: false,
adjust_factor: None,
successor_symbol: None,
successor_ratio: None,
successor_cash: None,
}],
[(9, 15), (9, 31)]
.into_iter()
.map(|(hour, minute)| fidc_core::IntradayExecutionQuote {
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
date: day(14),
symbol: SYMBOL.into(),
timestamp: day(14).and_hms_opt(hour, minute, 0).unwrap(),
last_price: 8.95,
bid1: 8.95,
ask1: 8.95,
bid1_volume: 100000,
ask1_volume: 100000,
volume_delta: 10000,
amount_delta: 89500.,
trading_phase: Some("continuous".into()),
})
.collect(),
)
.unwrap()
}
struct Hold {
seen: Rc<RefCell<Vec<(NaiveTime, u32)>>>,
}
impl Strategy for Hold {
fn name(&self) -> &str {
"accounting reinvestment contract"
}
fn initial_subscriptions(&self) -> BTreeSet<String> {
[SYMBOL.into()].into()
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(if ctx.execution_date == day(11) {
StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: SYMBOL.into(),
quantity: 1000,
reason: "initial".into(),
}],
..Default::default()
}
} else {
StrategyDecision::default()
})
}
fn on_minute(
&mut self,
ctx: &StrategyContext<'_>,
_: &fidc_core::IntradayExecutionQuote,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
self.seen.borrow_mut().push((
ctx.current_time().unwrap(),
ctx.portfolio
.position(SYMBOL)
.map_or(0, |position| position.quantity),
));
Ok(Default::default())
}
}
fn engine() -> BacktestEngine<Hold, ChinaAShareCostModel, ChinaEquityRuleHooks> {
BacktestEngine::new(
data(),
Hold {
seen: Rc::new(RefCell::new(Vec::new())),
},
BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default()
.with_commission_rate(0.0008)
.with_minimum_commission(0.),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_matching_type(MatchingType::NextBarOpen)
.with_volume_limit(false)
.with_liquidity_limit(false),
BacktestConfig {
initial_cash: 50000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(11)),
end_date: Some(day(15)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
)
.with_dividend_reinvestment(true)
}
#[test]
fn accounting_reinvestment_has_an_explicit_origin_clock_and_progress_delivery() {
let mut progress = Vec::new();
let result = engine()
.run_with_progress(|event| progress.push(event.clone()))
.unwrap();
let reinvest = result
.fills
.iter()
.find(|fill| fill.reason == "dividend_reinvestment")
.unwrap();
assert_eq!(
(
reinvest.quantity,
reinvest.price,
reinvest.commission,
reinvest.order_id
),
(100, 8.95, 0., None)
);
assert_eq!(
serde_json::to_value(reinvest).unwrap()["origin"],
"dividend_reinvestment"
);
assert_eq!(reinvest.execution_timestamp, day(14).and_hms_opt(0, 0, 0));
let received = progress.iter().find(|event| event.date == day(14)).unwrap();
assert!(
received
.fills
.iter()
.any(|fill| fill.reason == "dividend_reinvestment")
);
assert_eq!(
progress
.iter()
.map(|event| event.daily_fill_count)
.sum::<usize>(),
result.fills.len()
);
}
fn manual_source(delayed: bool) -> fidc_core::manual_execution::ManualExecutionReplay {
let observed = if delayed {
"2026-09-14T01:15:00Z"
} else {
"2026-09-11T06:00:01Z"
};
let mut source: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
"observationCutoff":"2026-09-15T08:00:00Z","actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],
"confirmedAt":"2026-09-11T05:59:59Z","confirmationObservedAt":"2026-09-11T05:59:59Z","outcome":"orders_terminal","orders":[{
"orderId":"manual-order","sourceAdapter":"paper","symbol":SYMBOL,"side":"Buy","quantity":1000,
"orderCreatedAt":"2026-09-11T05:59:59Z","terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
"tradeId":"manual-fill","observationEventId":"receipt","observationSequence":1,"feeObservationEventId":"receipt","feeObservationSequence":1,
"feeObservedAt":observed,"tradeDate":"2026-09-11","executedAt":"2026-09-11T06:00:00Z","observedAt":observed,
"timestampPrecision":"second","quantity":1000,"price":"10","totalFee":"1"
}]
}]}]
})).unwrap();
source.content_sha256 = source.content_digest().unwrap();
source
}
#[test]
fn delayed_receipt_before_market_open_reconciles_accounting_not_future_market_fills() {
let timely = engine()
.with_observed_manual_executions(manual_source(false))
.unwrap()
.run()
.unwrap();
let delayed = engine()
.with_observed_manual_executions(manual_source(true))
.unwrap()
.run()
.unwrap();
assert_eq!(delayed.holdings_summary[0].quantity, 2200);
assert_eq!(
delayed.holdings_summary[0].quantity,
timely.holdings_summary[0].quantity
);
assert_eq!(
delayed.equity_curve.last().unwrap().cash,
timely.equity_curve.last().unwrap().cash
);
assert_eq!(
delayed.manual_executions[0]
.corporate_adjustment
.as_ref()
.unwrap()
.corporate_cash_delta,
"155"
);
}
#[test]
fn weekend_receipts_and_morning_allocations_are_in_the_next_progress_batch() {
let mut source = manual_source(true);
let observed = "2026-09-12T02:00:00Z".parse().unwrap();
let order = &mut source.actions[0].orders[0];
order.terminal_observed_at = observed;
order.fills[0].observed_at = observed;
order.fills[0].fee_observed_at = observed;
source.content_sha256 = source.content_digest().unwrap();
let mut progress = Vec::new();
let result = engine()
.with_observed_manual_executions(source)
.unwrap()
.run_with_progress(|event| progress.push(event.clone()))
.unwrap();
let monday = progress.iter().find(|event| event.date == day(14)).unwrap();
assert_eq!(monday.daily_manual_fill_count, 1);
assert_eq!(monday.manual_executions[0].observed_at, observed);
assert!(
monday
.fills
.iter()
.any(|fill| fill.origin == fidc_core::FillOrigin::DividendReinvestment)
);
assert!(
monday
.process_events
.iter()
.any(|event| event.kind == fidc_core::ProcessEventKind::ManualExecutionObserved)
);
assert_eq!(
progress
.iter()
.map(|event| event.daily_fill_count)
.sum::<usize>(),
result.fills.len() + result.manual_executions.len()
);
}
fn exposure_event(id: &str, sequence: u64, at: &str, action: fidc_core::position_exposure::PositionExposureAction)
-> fidc_core::position_exposure::PositionExposureEvent {
fidc_core::position_exposure::PositionExposureEvent {
event_id: id.into(), sequence, effective_at: at.parse().unwrap(), allocation_weights_bps: None, action,
}
}
fn cleared_reinvestment_case(
events: Vec<fidc_core::position_exposure::PositionExposureEvent>,
legacy: std::collections::BTreeMap<NaiveDate, i32>,
extra_buy_delayed: Option<bool>,
) -> fidc_core::BacktestResult {
let mut parts = data().snapshot_components();
parts.corporate_actions[0].payable_date = Some(day(15));
let data = DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
parts.factors, parts.candidates, parts.benchmarks, parts.corporate_actions, parts.execution_quotes).unwrap();
let mut source = manual_source(false);
let mut sale = source.actions[0].clone();
sale.action_id = "clear".into(); sale.audit_event_ids = vec!["clear-audit".into()];
sale.confirmed_at = "2026-09-14T05:59:59Z".parse().unwrap();
sale.confirmation_observed_at = sale.confirmed_at;
let order = &mut sale.orders[0];
order.order_id = "clear-order".into(); order.side = fidc_core::OrderSide::Sell;
order.order_created_at = sale.confirmed_at;
order.terminal_observed_at = "2026-09-14T06:00:01Z".parse().unwrap();
let fill = &mut order.fills[0];
fill.trade_id = "clear-fill".into(); fill.observation_event_id = "clear-receipt".into();
fill.observation_sequence = 2; fill.fee_observation_event_id = "clear-receipt".into();
fill.fee_observation_sequence = 2; fill.trade_date = day(14);
fill.executed_at = "2026-09-14T06:00:00Z".parse().unwrap();
fill.observed_at = order.terminal_observed_at; fill.fee_observed_at = order.terminal_observed_at;
fill.price = "8.95".parse().unwrap();
source.actions.push(sale);
if let Some(delayed) = extra_buy_delayed {
let mut extra = source.actions[0].clone();
extra.action_id = "extra".into(); extra.audit_event_ids = vec!["extra-audit".into()];
extra.confirmed_at = "2026-09-11T06:00:59Z".parse().unwrap();
extra.confirmation_observed_at = extra.confirmed_at;
let order = &mut extra.orders[0];
order.order_id = "extra-order".into(); order.order_created_at = extra.confirmed_at;
order.terminal_observed_at = if delayed { "2026-09-15T01:15:00Z" } else { "2026-09-11T06:01:01Z" }.parse().unwrap();
let fill = &mut order.fills[0];
fill.trade_id = "extra-fill".into(); fill.observation_event_id = "extra-receipt".into();
fill.observation_sequence = if delayed { 3 } else { 2 };
fill.fee_observation_event_id = "extra-receipt".into(); fill.fee_observation_sequence = fill.observation_sequence;
fill.executed_at = "2026-09-11T06:01:00Z".parse().unwrap(); fill.observed_at = order.terminal_observed_at;
fill.fee_observed_at = order.terminal_observed_at;
if !delayed {
source.actions[1].orders[0].fills[0].observation_sequence = 3;
source.actions[1].orders[0].fills[0].fee_observation_sequence = 3;
}
source.actions.push(extra);
}
source.position_exposure_events = events;
source.legacy_position_exposure_bps = legacy;
source.content_sha256 = source.content_digest().unwrap();
let mut config = fidc_core::PlatformExprStrategyConfig::generic();
config.signal_symbol = SYMBOL.into(); config.benchmark_symbol = "000300.SH".into();
config.rotation_enabled = false; config.matching_type = MatchingType::CurrentBarClose;
BacktestEngine::new(data, fidc_core::PlatformExprStrategy::new(config),
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose).with_volume_limit(false).with_liquidity_limit(false),
BacktestConfig { initial_cash: 50000., benchmark_code: "000300.SH".into(), start_date: Some(day(11)),
end_date: Some(day(15)), decision_lag_trading_days: 0, execution_price_field: PriceField::Close })
.with_dividend_reinvestment(true).with_observed_manual_executions(source).unwrap().run().unwrap()
}
#[test]
fn an_effective_manual_zero_must_not_recreate_a_cleared_position_on_dividend_payment() {
use fidc_core::position_exposure::PositionExposureAction as Action;
let zero = exposure_event("zero", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 0 });
let result = cleared_reinvestment_case(vec![zero], Default::default(), None);
assert!(result.fills.is_empty(), "{:?}", result.fills);
assert!(result.holdings_summary.is_empty());
assert_eq!(result.equity_curve.last().unwrap().cash, 49998.);
assert!(result.equity_curve.last().unwrap().notes.contains("runtime_zero_exposure"));
}
#[test]
fn a_clear_without_a_manual_zero_keeps_the_declared_legacy_reinvestment_model() {
let result = cleared_reinvestment_case(vec![], Default::default(), None);
assert_eq!(result.fills.len(), 1);
assert_eq!((result.fills[0].quantity, result.fills[0].price, result.fills[0].commission), (100, 8.95, 0.));
assert_eq!(result.fills[0].gross_amount, 895.);
assert_eq!(result.fills[0].net_cash_flow, -895.);
assert!(result.order_events.is_empty());
}
#[test]
fn reinvestment_respects_zero_restore_same_instant_sequence_and_legacy_granularity() {
use fidc_core::position_exposure::PositionExposureAction as Action;
let before = "2026-09-14T07:00:00Z";
let settlement = "2026-09-14T16:00:00Z";
let later = "2026-09-15T01:31:00Z";
let zero = || exposure_event("zero", 1, before, Action::Scale { requested_bps: 0 });
let cases = vec![
(vec![zero()], Default::default(), false),
(vec![exposure_event("zero-at-settlement", 1, settlement, Action::Set { target_exposure_bps: 0 })], Default::default(), false),
(vec![exposure_event("later-zero", 1, later, Action::Set { target_exposure_bps: 0 })], Default::default(), true),
(vec![zero(), exposure_event("restore", 2, before, Action::Restore)], Default::default(), true),
(vec![exposure_event("restore", 1, before, Action::Restore), exposure_event("last-zero", 2, before, Action::Set { target_exposure_bps: 0 })], Default::default(), false),
(vec![exposure_event("restore", 2, before, Action::Restore), zero()], Default::default(), true),
(vec![zero(), exposure_event("later-restore", 2, later, Action::Restore)], Default::default(), false),
(vec![], std::collections::BTreeMap::from([(day(14), 0)]), false),
(vec![exposure_event("restore-legacy", 1, before, Action::Restore)], std::collections::BTreeMap::from([(day(14), 0)]), true),
(vec![exposure_event("positive", 1, before, Action::Set { target_exposure_bps: 3000 })], Default::default(), true),
];
for (events, legacy, allowed) in cases {
let result = cleared_reinvestment_case(events, legacy, None);
assert_eq!(result.fills.len(), usize::from(allowed));
assert_eq!(result.equity_curve.last().unwrap().cash, if allowed { 49103. } else { 49998. });
assert_eq!(result.holdings_summary.iter().map(|holding| holding.quantity).sum::<u32>(), if allowed {100} else {0});
assert!(result.order_events.is_empty());
}
}
#[test]
fn an_explicit_zero_member_weight_or_omission_blocks_only_that_reinvestment() {
use fidc_core::position_exposure::PositionExposureAction as Action;
for included in [false, true] {
for weight in [0, 10000] {
let mut event = exposure_event("allocation", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 5000 });
let mut weights = std::collections::BTreeMap::from([("000002.SZ".into(), if included {10000-weight} else {10000})]);
if included { weights.insert(SYMBOL.into(), weight); }
event.allocation_weights_bps = Some(weights);
let allowed = included && weight > 0;
let result = cleared_reinvestment_case(vec![event], Default::default(), None);
assert_eq!(result.fills.len(), usize::from(allowed));
if !allowed { assert!(result.equity_curve.last().unwrap().notes.contains("runtime_zero_allocation")); }
}
}
}
#[test]
fn late_corporate_replay_uses_the_same_zero_policy_and_retains_actual_manual_shares() {
use fidc_core::position_exposure::PositionExposureAction as Action;
let event = exposure_event("zero", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 0 });
let timely = cleared_reinvestment_case(vec![event.clone()], Default::default(), Some(false));
let late = cleared_reinvestment_case(vec![event], Default::default(), Some(true));
assert!(timely.fills.is_empty()); assert!(late.fills.is_empty());
assert_eq!(timely.equity_curve.last().unwrap().cash, 41047.);
assert_eq!(late.equity_curve.last().unwrap().cash, timely.equity_curve.last().unwrap().cash);
assert_eq!(late.holdings_summary[0].quantity, 1000);
assert_eq!(late.holdings_summary[0].quantity, timely.holdings_summary[0].quantity);
assert_eq!(late.manual_executions.last().unwrap().corporate_adjustment.as_ref().unwrap().corporate_cash_delta, "1050");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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"
}
@@ -17,7 +17,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
.iter()
.map(|date| DailyMarketSnapshot {
date: *date,
symbol: SYMBOL.to_string(),
symbol: SYMBOL.into(),
timestamp: None,
day_open: 10.0,
open: 10.0,
@@ -55,7 +55,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
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 {
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
date: *date,
symbol: SYMBOL.to_string(),
timestamp,
@@ -0,0 +1,734 @@
use chrono::{DateTime, NaiveDate, Utc};
use fidc_core::manual_execution::{MANUAL_REPLAY_SCHEMA, ManualExecutionReplay};
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, CorporateAction, DailyFactorSnapshot,
DailyMarketSnapshot, DataSet, Instrument, MatchingType, PriceField, Strategy,
};
fn date(day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 9, day).unwrap()
}
#[derive(Clone, Copy, Debug)]
enum Action {
Split,
Dividend,
Successor,
}
fn data(action: Action) -> DataSet {
data_with_successor_metadata(action, true)
}
fn data_with_successor_metadata(action: Action, include_successor: bool) -> DataSet {
let days = [10, 11, 14, 15].map(date);
let mut market = Vec::new();
let mut factors = Vec::new();
let mut candidates = Vec::new();
for day in days {
for symbol in ["000001.SZ", "000002.SZ"] {
if matches!(action, Action::Successor) && symbol == "000001.SZ" && day >= date(14) {
continue;
}
let price = if day < date(14)
|| (symbol == "000002.SZ" && !matches!(action, Action::Successor))
{
10.
} else if matches!(action, Action::Dividend) {
9.
} else {
5.
};
market.push(DailyMarketSnapshot {
date: day,
symbol: symbol.into(),
timestamp: Some(format!("{day} 15:00:00")),
day_open: price,
open: price,
high: price,
low: price,
close: price,
last_price: price,
bid1: price,
ask1: price,
prev_close: price,
volume: 100000,
minute_volume: 100000,
bid1_volume: 100000,
ask1_volume: 100000,
trading_phase: Some("continuous".into()),
paused: false,
upper_limit: price * 1.1,
lower_limit: price * 0.9,
price_tick: 0.01,
});
factors.push(DailyFactorSnapshot {
date: day,
symbol: symbol.into(),
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(),
});
candidates.push(CandidateEligibility {
date: day,
symbol: symbol.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,
});
}
}
DataSet::from_components_with_actions(
["000001.SZ", "000002.SZ"]
.into_iter()
.filter(|symbol| include_successor || *symbol != "000002.SZ")
.map(|symbol| Instrument {
symbol: symbol.into(),
name: symbol.into(),
board: "SZ".into(),
round_lot: 100,
listed_at: Some(date(1)),
delisted_at: (matches!(action, Action::Successor) && symbol == "000001.SZ")
.then_some(date(14)),
status: "active".into(),
})
.collect(),
market,
factors,
candidates,
days.map(|day| BenchmarkSnapshot {
date: day,
benchmark: "000300.SH".into(),
open: 100.,
close: 100.,
prev_close: 100.,
volume: 100000,
})
.into(),
vec![CorporateAction {
date: date(14),
symbol: "000001.SZ".into(),
payable_date: Some(date(14)),
share_cash: if matches!(action, Action::Dividend) {
1.
} else {
0.
},
share_bonus: if matches!(action, Action::Split) {
1.
} else {
0.
},
share_gift: 0.,
issue_quantity: 0.,
issue_price: 0.,
reform: false,
adjust_factor: None,
successor_symbol: matches!(action, Action::Successor).then(|| "000002.SZ".into()),
successor_ratio: matches!(action, Action::Successor).then_some(2.),
successor_cash: matches!(action, Action::Successor).then_some(0.5),
}],
)
.unwrap()
}
fn source(delayed: bool, sell: bool) -> ManualExecutionReplay {
let trades = if sell {
vec![
("initial-buy", "Buy", 10, 200, false),
("sale", "Sell", 11, 100, delayed),
]
} else {
vec![("buy", "Buy", 11, 100, delayed)]
};
let actions = trades.into_iter().enumerate().map(|(index, (id, side, day, quantity, late))| {
let executed = format!("2026-09-{day:02}T06:00:00Z").parse::<DateTime<Utc>>().unwrap();
let observed = if late { "2026-09-15T05:00:00Z".parse().unwrap() } else { executed + chrono::Duration::seconds(1) };
let created = executed - chrono::Duration::seconds(1);
serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")],
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{
"orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity,
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
"tradeId":id,"observationEventId":id,"observationSequence":index+1,"tradeDate":date(day),
"executedAt":executed,"observedAt":observed,"feeObservationEventId":id,"feeObservationSequence":index+1,
"feeObservedAt":observed,"timestampPrecision":"second","quantity":quantity,"price":"10","totalFee":"1"
}]
}]})
}).collect::<Vec<_>>();
let mut source: ManualExecutionReplay = serde_json::from_value(serde_json::json!({
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),
"contentSha256":"","observationCutoff":"2026-09-15T08:00:00Z","actions":actions,
})).unwrap();
source.content_sha256 = source.content_digest().unwrap();
source.validate().unwrap();
source
}
struct Hold;
impl Strategy for Hold {
fn name(&self) -> &str {
"manual corporate observation"
}
fn requires_minute_callbacks(&self) -> bool {
false
}
}
fn run_custom<S: Strategy>(
data: DataSet,
source: ManualExecutionReplay,
strategy: S,
cash_dividends: bool,
adjust_cost: bool,
) -> Result<fidc_core::BacktestResult, fidc_core::BacktestError> {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::NextBarOpen)
.with_volume_limit(false)
.with_liquidity_limit(false);
BacktestEngine::new(
data,
strategy,
broker,
BacktestConfig {
initial_cash: 10000.,
benchmark_code: "000300.SH".into(),
start_date: Some(date(10)),
end_date: Some(date(15)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
)
.with_cash_dividends(cash_dividends)
.with_cash_dividend_cost_basis_adjustment(adjust_cost)
.with_observed_manual_executions(source)
.unwrap()
.run()
}
fn run(
action: Action,
delayed: bool,
sell: bool,
) -> Result<fidc_core::BacktestResult, fidc_core::BacktestError> {
run_custom(data(action), source(delayed, sell), Hold, true, true)
}
#[test]
fn delayed_buy_does_not_lose_corporate_entitlements() {
for action in [Action::Split, Action::Dividend, Action::Successor] {
let timely = run(action, false, false).unwrap();
let delayed = run(action, true, false).unwrap();
let project = |result: &fidc_core::BacktestResult| {
(
result.equity_curve.last().unwrap().cash,
result.equity_curve.last().unwrap().total_equity,
result
.holdings_summary
.iter()
.map(|row| (row.symbol.clone(), row.quantity))
.collect::<Vec<_>>(),
)
};
assert_eq!(project(&delayed), project(&timely), "{action:?}");
assert_eq!(delayed.manual_executions.len(), 1);
assert!(delayed.fills.is_empty());
if matches!(action, Action::Successor)
&& let Ok(directory) = std::env::var("FIDC_CORPORATE_QA_OUTPUT")
{
use std::io::Write;
let path = std::path::Path::new(&directory).join("corporate-successor-result.json");
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path).unwrap();
file.write_all(&serde_json::to_vec(&serde_json::json!({
"source":delayed.manual_execution_source.as_deref(), "applications":delayed.manual_executions,
})).unwrap()).unwrap();
}
}
}
#[test]
fn delayed_sale_does_not_keep_unearned_corporate_entitlements() {
for action in [Action::Split, Action::Dividend, Action::Successor] {
let timely = run(action, false, true).unwrap();
let delayed = run(action, true, true).unwrap();
let project = |result: &fidc_core::BacktestResult| {
(
result.equity_curve.last().unwrap().cash,
result.equity_curve.last().unwrap().total_equity,
result
.holdings_summary
.iter()
.map(|row| (row.symbol.clone(), row.quantity))
.collect::<Vec<_>>(),
)
};
assert_eq!(project(&delayed), project(&timely), "{action:?}");
assert_eq!(delayed.manual_executions.len(), 2);
assert!(delayed.fills.is_empty());
}
}
#[test]
fn paper_and_broker_observations_require_the_same_frozen_successor_scope() {
for adapter in ["paper", "gt", "qmt"] {
for delayed in [false, true] {
for sell in [false, true] {
let mut replay = source(delayed, sell);
for action in &mut replay.actions {
for order in &mut action.orders { order.source_adapter = Some(adapter.into()); }
}
replay.content_sha256 = replay.content_digest().unwrap();
replay.validate().unwrap();
let complete = run_custom(data(Action::Successor), replay.clone(), Hold, true, true).unwrap();
assert_eq!(complete.holdings_summary[0].symbol, "000002.SZ");
assert_eq!(complete.holdings_summary[0].quantity, 200);
assert!(complete.fills.is_empty());
assert!(complete.order_events.is_empty());
let error = run_custom(data_with_successor_metadata(Action::Successor, false),
replay, Hold, true, true).unwrap_err();
assert!(error.to_string().contains("successor_instrument_missing"),
"{adapter} delayed={delayed} sell={sell}: {error}");
}
}
}
}
fn protected_successor_run(delayed: bool, locked: bool, amount: i32)
-> fidc_core::BacktestResult {
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
sell_cooldown_days: if locked { 0 } else { 3 },
locks: if locked { vec![fidc_core::holding_policy::AutomaticTradeLock {
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(15)),
}] } else { vec![] }, ..Default::default()
};
protected_successor_case(delayed, policy, amount, "partial")
}
fn protected_successor_case(delayed: bool,
policy: fidc_core::holding_policy::AutomaticTradeProtection, amount: i32, scenario: &str)
-> fidc_core::BacktestResult {
let mut config = fidc_core::PlatformExprStrategyConfig::generic();
config.signal_symbol = "000002.SZ".into();
config.benchmark_symbol = "000300.SH".into();
config.rotation_enabled = false;
config.matching_type = MatchingType::CurrentBarClose;
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.explicit_action_schedule = Some(fidc_core::PlatformRebalanceSchedule {
frequency: fidc_core::PlatformScheduleFrequency::Daily,
time_rule: Some(fidc_core::ScheduleTimeRule::physical_time(14, 30)),
});
config.automatic_trade_protection = policy;
config.explicit_actions = vec![fidc_core::PlatformTradeAction::Order {
kind: fidc_core::PlatformExplicitOrderKind::Shares, symbol: "000002.SZ".into(),
amount_expr: amount.to_string(), when_expr: Some("decision_date == \"2026-09-15\"".into()),
limit_price_expr: None, time_in_force: None, start_time_expr: None, end_time_expr: None,
reason: "configured_successor_action".into(),
}];
let data = successor_execution_data();
let mut replay = source(delayed, true);
if scenario == "sold_before" {
let order = &mut replay.actions[1].orders[0];
order.quantity = 200; order.fills[0].quantity = 200;
} else if scenario == "cleared_after" {
let mut row = serde_json::to_value(&replay.actions[1]).unwrap();
let at = "2026-09-15T05:30:00Z";
let receipt = "2026-09-15T05:30:01Z";
row["actionId"] = "clear".into(); row["auditEventIds"] = serde_json::json!(["audit-clear"]);
row["confirmedAt"] = at.into(); row["confirmationObservedAt"] = at.into();
let order = &mut row["orders"][0];
order["orderId"] = "clear-order".into(); order["brokerOrderId"] = "clear-order".into();
order["symbol"] = "000002.SZ".into(); order["quantity"] = 200.into();
order["orderCreatedAt"] = at.into(); order["terminalObservedAt"] = receipt.into();
let fill = &mut order["fills"][0];
fill["tradeId"] = "clear-trade".into(); fill["observationEventId"] = "clear-receipt".into();
fill["observationSequence"] = 3.into(); fill["tradeDate"] = "2026-09-15".into();
fill["executedAt"] = at.into(); fill["observedAt"] = receipt.into();
fill["feeObservationEventId"] = "clear-receipt".into(); fill["feeObservationSequence"] = 3.into();
fill["feeObservedAt"] = receipt.into(); fill["price"] = "5".into(); fill["quantity"] = 200.into();
replay.actions.push(serde_json::from_value(row).unwrap());
}
replay.content_sha256 = replay.content_digest().unwrap();
BacktestEngine::new(data, fidc_core::PlatformExprStrategy::new(config),
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose)
.with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
BacktestConfig { initial_cash: 10000., benchmark_code: "000300.SH".into(),
start_date: Some(date(10)), end_date: Some(date(15)), decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
}).with_observed_manual_executions(replay).unwrap().run().unwrap()
}
fn successor_execution_data() -> DataSet {
let parts = data(Action::Successor).snapshot_components();
DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
parts.factors, parts.candidates, parts.benchmarks, parts.corporate_actions,
[30, 31].into_iter().map(|minute| fidc_core::IntradayExecutionQuote {
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
date: date(15), symbol: "000002.SZ".into(), timestamp: date(15).and_hms_opt(14,minute,0).unwrap(),
last_price: 5., bid1: 5., ask1: 5., bid1_volume: 100000, ask1_volume: 100000,
volume_delta: 100000, amount_delta: 500000., trading_phase: Some("continuous".into()),
}).collect()).unwrap()
}
#[test]
fn native_pool_rebalance_applies_inherited_protection_without_rewriting_its_target() {
use fidc_core::stock_pool_execution as pool;
struct NativePool { locked: bool, expires: u32, exposure: i32 }
impl Strategy for NativePool {
fn name(&self) -> &str { "native pool successor protection" }
fn requires_minute_callbacks(&self) -> bool { false }
fn schedule_rules(&self) -> Vec<fidc_core::ScheduleRule> {
vec![fidc_core::ScheduleRule::daily("pool", fidc_core::ScheduleStage::OnDay)
.with_time_rule(fidc_core::ScheduleTimeRule::physical_time(14,30))]
}
fn on_scheduled(&mut self, ctx: &fidc_core::StrategyContext<'_>, _: &fidc_core::ScheduleRule)
-> Result<fidc_core::StrategyDecision, fidc_core::BacktestError> {
if ctx.execution_date != date(15) { return Ok(Default::default()); }
let symbols = vec!["000002.SZ".to_owned()];
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
sell_cooldown_days: if self.locked { 0 } else { 3 },
locks: if self.locked { vec![fidc_core::holding_policy::AutomaticTradeLock {
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(self.expires)),
}] } else { vec![] }, ..Default::default()
};
let contract = pool::FrozenStockPoolIntent {
pool_id: "pool".into(), signal_date: date(15), frozen_equity: 10000.into(),
selection: pool::StockPoolSelection { trade_date: date(15), requested_symbols: symbols.clone(),
normal_trading_symbols: symbols.clone(), risk_eligible_symbols: symbols.clone(), final_symbols: symbols,
exclusion_reasons: Default::default(), inherited_from_generation: None, explicit_empty: false,
generation: Some("latest".into()),
}, members: vec![pool::StockPoolMemberSpec { symbol: "000002.SZ".into(), requested_order: 0,
recommendation_reason: String::new(), target_weight_bps: None, stop_loss: None, take_profit: None }],
rule: pool::StockPoolExecutionRule { pricing_mode: pool::POOL_PRICE_FIRST_TICK.into(),
window_start: "14:30".into(), window_end: "15:00".into(), automatic_trade_protection: policy,
..Default::default() }, constraints: pool::StockPoolDecisionConstraints {
target_holding_count: Some(1), ..Default::default() },
invest_ratio_bps: self.exposure, reserve_cash: 0.into(), out_of_pool_policy: "hold".into(), generation: "latest".into(),
};
Ok(fidc_core::StrategyDecision { order_intents: vec![fidc_core::OrderIntent::StockPool { contract: Box::new(contract) }], ..Default::default() })
}
}
for delayed in [false, true] {
for (locked, expires, exposure) in [(false, 15, 10000), (true, 15, 10000), (true, 15, 0), (true, 14, 10000)] {
let result = BacktestEngine::new(successor_execution_data(), NativePool { locked, expires, exposure },
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(14,30,0).unwrap())
.with_volume_limit(false).with_liquidity_limit(false),
BacktestConfig { initial_cash: 10000., benchmark_code: "000300.SH".into(), start_date: Some(date(10)),
end_date: Some(date(15)), decision_lag_trading_days: 0, execution_price_field: PriceField::Last })
.with_observed_manual_executions(source(delayed, true)).unwrap().run().unwrap();
if expires == 14 {
assert!(!result.fills.is_empty(), "positive control {:?}", result.order_events);
} else {
assert!(result.fills.is_empty(), "delayed={delayed} locked={locked} exposure={exposure}: {:?}", result.fills);
assert!(result.order_events.is_empty());
assert_eq!(result.holdings_summary[0].quantity, 200);
}
}
}
}
#[test]
fn successor_keeps_sell_cooldown_for_timely_and_delayed_receipts() {
for delayed in [false, true] {
let result = protected_successor_run(delayed, false, 100);
assert!(result.fills.is_empty(), "delayed={delayed}: {:?}", result.fills);
assert!(result.order_events.is_empty(), "delayed={delayed}: {:?}", result.order_events);
assert!(result.risk_decisions.iter().any(|row| row.symbol == "000002.SZ"
&& row.date == date(15) && !row.accepted && row.rule_code == "sell_fill_cooldown"),
"orders={:?} risk={:?} notes={:?}", result.order_events, result.risk_decisions,
result.equity_curve.iter().map(|row| (&row.date, &row.notes)).collect::<Vec<_>>());
assert_eq!(result.holdings_summary[0].quantity, 200);
}
}
#[test]
fn converted_holding_does_not_lose_its_configured_date_lock() {
for delayed in [false, true] {
for amount in [-100, 100] {
let result = protected_successor_run(delayed, true, amount);
assert!(result.fills.is_empty(), "delayed={delayed} amount={amount}: {:?}", result.fills);
assert!(result.order_events.is_empty(), "delayed={delayed} amount={amount}: {:?}", result.order_events);
assert!(result.risk_decisions.iter().any(|row| row.symbol == "000002.SZ"
&& row.date == date(15) && !row.accepted && row.rule_code == "automatic_trade_locked"),
"orders={:?} risk={:?} notes={:?}", result.order_events, result.risk_decisions,
result.equity_curve.iter().map(|row| (&row.date, &row.notes)).collect::<Vec<_>>());
assert_eq!(result.holdings_summary[0].quantity, 200);
}
}
}
#[test]
fn successor_lock_expires_on_the_original_configured_date_not_the_conversion_date() {
for delayed in [false, true] {
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
locks: vec![fidc_core::holding_policy::AutomaticTradeLock {
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(14)),
}], ..Default::default()
};
let result = protected_successor_case(delayed, policy, 100, "partial");
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].quantity, 100);
assert_eq!(result.holdings_summary[0].quantity, 300);
}
}
#[test]
fn lock_survives_a_manual_clear_after_conversion_but_not_a_disproved_conversion() {
for delayed in [false, true] {
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
locks: vec![fidc_core::holding_policy::AutomaticTradeLock {
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(15)),
}], ..Default::default()
};
let cleared = protected_successor_case(delayed, policy.clone(), 100, "cleared_after");
assert!(cleared.fills.is_empty());
assert!(cleared.order_events.is_empty());
assert!(cleared.holdings_summary.is_empty());
assert_eq!(cleared.manual_executions.len(), 3);
let unconverted = protected_successor_case(delayed, policy, 100, "sold_before");
assert_eq!(unconverted.fills.len(), 1);
assert_eq!(unconverted.holdings_summary[0].quantity, 100);
}
}
#[test]
fn corporate_replay_preserves_issued_orders_cash_flows_financing_and_charged_fees() {
struct ExistingActivity {
receiving_days: usize,
}
impl Strategy for ExistingActivity {
fn name(&self) -> &str {
"corporate replay with original activity"
}
fn requires_minute_callbacks(&self) -> bool {
false
}
fn on_day(
&mut self,
ctx: &fidc_core::StrategyContext<'_>,
) -> Result<fidc_core::StrategyDecision, fidc_core::BacktestError> {
use fidc_core::OrderIntent;
let order_intents = if ctx.execution_date == date(10) {
vec![
OrderIntent::DepositWithdraw {
amount: 500.,
receiving_days: self.receiving_days,
reason: "original deposit".into(),
},
OrderIntent::FinanceRepay {
amount: 200.,
reason: "original financing".into(),
},
OrderIntent::SetManagementFeeRate {
rate: 0.001,
reason: "original fee policy".into(),
},
]
} else if ctx.execution_date == date(11) {
vec![OrderIntent::Shares {
symbol: "000002.SZ".into(),
quantity: 100,
reason: "unrelated stock".into(),
}]
} else if ctx.execution_date == date(14) {
vec![OrderIntent::Shares {
symbol: "000001.SZ".into(),
quantity: 100,
reason: "already issued after corporate action".into(),
}]
} else {
vec![]
};
Ok(fidc_core::StrategyDecision {
order_intents,
..Default::default()
})
}
fn management_fee(
&mut self,
_: &fidc_core::StrategyContext<'_>,
_: f64,
) -> Result<Option<f64>, fidc_core::BacktestError> {
Ok(Some(0.25))
}
}
for receiving_days in [0, 1] {
for sell in [false, true] {
let timely = run_custom(
data(Action::Split),
source(false, sell),
ExistingActivity { receiving_days },
true,
true,
)
.unwrap();
let delayed = run_custom(
data(Action::Split),
source(true, sell),
ExistingActivity { receiving_days },
true,
true,
)
.unwrap();
assert_eq!(
serde_json::to_value(&timely.fills).unwrap(),
serde_json::to_value(&delayed.fills).unwrap()
);
assert_eq!(delayed.fills.len(), 2);
assert_eq!(
delayed.equity_curve.last().unwrap().cash,
timely.equity_curve.last().unwrap().cash
);
assert_eq!(
delayed.equity_curve.last().unwrap().total_equity,
timely.equity_curve.last().unwrap().total_equity
);
assert_eq!(
delayed
.equity_curve
.iter()
.map(|row| row.external_cash_flow)
.sum::<f64>(),
500.
);
assert_eq!(delayed.manual_executions.len(), if sell { 2 } else { 1 });
}
}
}
#[test]
fn pending_and_disabled_dividends_keep_the_configured_cash_and_cost_contract() {
for paid in [false, true] {
for enabled in [false, true] {
for adjust_cost in [false, true] {
let fixture = || {
let mut parts = data(Action::Dividend).snapshot_components();
parts.corporate_actions[0].payable_date =
Some(date(if paid { 14 } else { 16 }));
DataSet::from_components_with_actions(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
parts.corporate_actions,
)
.unwrap()
};
let timely =
run_custom(fixture(), source(false, false), Hold, enabled, adjust_cost)
.unwrap();
let delayed =
run_custom(fixture(), source(true, false), Hold, enabled, adjust_cost).unwrap();
assert_eq!(
delayed.equity_curve.last().unwrap().cash,
timely.equity_curve.last().unwrap().cash
);
let financial = |result: &fidc_core::BacktestResult| {
result
.holdings_summary
.iter()
.map(|row| {
(
row.symbol.clone(),
row.quantity,
row.average_cost,
row.last_price,
row.market_value,
row.unrealized_pnl,
row.realized_pnl,
row.pnl,
row.dividend_receivable,
)
})
.collect::<Vec<_>>()
};
// Receipt-day turnover is deliberately different when the
// dividend option is disabled and no adjustment is required.
assert_eq!(financial(&delayed), financial(&timely));
assert_eq!(
delayed.manual_executions[0].corporate_adjustment.is_some(),
enabled
);
if enabled && !paid {
assert_eq!(delayed.terminal_audit.cash_receivable_count, 1);
}
}
}
}
}
#[test]
fn late_fill_replays_aggregate_split_rounding_not_an_independent_rounded_fragment() {
let fixture = || {
let mut parts = data(Action::Split).snapshot_components();
parts.corporate_actions[0].share_bonus = 0.125;
for row in &mut parts.market {
if row.symbol == "000001.SZ" && row.date >= date(14) {
row.day_open = 8.89;
row.open = 8.89;
row.close = 8.89;
row.last_price = 8.89;
row.high = 8.89;
row.low = 8.89;
row.prev_close = 8.89;
row.bid1 = 8.89;
row.ask1 = 8.89;
row.upper_limit = 9.78;
row.lower_limit = 8.;
}
}
DataSet::from_components_with_actions(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
parts.corporate_actions,
)
.unwrap()
};
let input = |delayed| {
let mut value = source(delayed, true);
value.actions[0].orders[0].quantity = 100;
value.actions[0].orders[0].fills[0].quantity = 100;
value.actions[1].orders[0].side = fidc_core::OrderSide::Buy;
value.content_sha256 = value.content_digest().unwrap();
value
};
let timely = run_custom(fixture(), input(false), Hold, true, true).unwrap();
let delayed = run_custom(fixture(), input(true), Hold, true, true).unwrap();
assert_eq!(timely.holdings_summary[0].quantity, 225);
assert_eq!(delayed.holdings_summary[0].quantity, 225);
assert_eq!(
delayed.equity_curve.last().unwrap().total_equity,
timely.equity_curve.last().unwrap().total_equity
);
assert_eq!(
delayed.manual_executions[1]
.corporate_adjustment
.as_ref()
.unwrap()
.positions["000001.SZ"]
.quantity_before,
113
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,316 @@
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_exit_basis_is_immutable_restart_safe_and_scoped_to_the_signal() {
let original = StockPoolExecutionState::default()
.observe(day(11), day(11), &[day(11), day(14)], &[member()], &[held(1000, 1000)]).unwrap();
let basis = BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))]);
let saved = original.record_position_action_bases("sell-signal", &basis).unwrap();
assert!(original.position_action_bases.is_empty(), "a preview must not mutate its input");
let restored: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&saved).unwrap()).unwrap();
let next_day = restored.observe(day(11), day(14), &[day(11), day(14)], &[member()], &[held(500, 500)]).unwrap();
assert_eq!(next_day.position_action_bases_for("sell-signal"), basis);
assert!(next_day.position_action_bases_for("new-signal").is_empty());
assert!(next_day.record_position_action_bases("sell-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap_err().contains("basis_changed"));
let new_signal = next_day.record_position_action_bases("new-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap();
assert!(new_signal.position_action_bases_for("sell-signal").is_empty());
assert_eq!(new_signal.position_action_bases_for("new-signal")["000001.SZ"], Decimal::from(500));
for invalid in [Decimal::ZERO, Decimal::NEGATIVE_ONE] {
assert!(original.record_position_action_bases("signal", &BTreeMap::from([("000001.SZ".into(), invalid)])).is_err());
}
assert!(original.record_position_action_bases(" ", &basis).is_err());
}
#[test]
fn verified_split_adjusts_exit_basis_and_entry_completion_not_generation() {
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &[day(11)], &[member()], &[]).unwrap();
let entry_plan = plan(&initial, day(11), &[member()], &[], 10000, "hold");
let entered = initial.record_plan(day(11), "entry", &entry_plan).unwrap();
let saved = entered.record_position_action_bases("sell", &BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))])).unwrap();
let adjusted = saved.adjust_for_split("000001.SZ", Decimal::new(15,1)).unwrap();
assert_eq!(adjusted.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1500));
assert_eq!(adjusted.entries["000001.SZ"].completion_quantity, Some(Decimal::from(1500)));
assert_eq!(adjusted.position_action_bases["000001.SZ"].first_execution_date, day(11));
assert_eq!(saved.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1000));
assert!(saved.adjust_for_split("000001.SZ", Decimal::ZERO).is_err());
}
#[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());
}
+3 -3
View File
@@ -35,7 +35,7 @@ fn market(
) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date: d(date),
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: None,
day_open: open,
open,
@@ -66,7 +66,7 @@ fn factor(
) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date: d(date),
symbol: symbol.to_string(),
symbol: symbol.into(),
market_cap_bn,
free_float_cap_bn,
pe_ttm: 18.0,
@@ -87,7 +87,7 @@ fn candidate(
) -> CandidateEligibility {
CandidateEligibility {
date: d(date),
symbol: symbol.to_string(),
symbol: symbol.into(),
is_st: false,
is_star_st: false,
is_new_listing,
+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,26 @@
# 策略级自动交易保护
## 统一合同
`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、券商和风控。
- 已校验且实际发生的持仓换股继承原保护期限与日期锁,不因改代码解锁或重计时;无实际转换的目录映射不继承。换股后手工清仓仍受原有效日期锁;确认换股前已清仓时不保留推定关系。原生策略、股票池与ETF顺延消费的修复及未发布边界见 `successor-protection-20260914.md`
- 保留的真实持仓继续占用资金与席位,不把未完成卖出当现金。最长持有退出先形成唯一最终目标,不能叠加一笔策略部分卖出和一笔框架全量卖出。
- 在线上下文重建必须注入已经校验的真实成交/持仓快照,不能把重建日或旧行情日当建仓日。期货或股票期货混合账户未纳入本合同,显式拒绝。
## 根因补充修复
组合 `decision_date == "2026-09-11" && symbol == "000001.SZ"` 会落到字符串表达式路径。旧代码遗漏日期等内建标识符的保留登记,又按“额外因子”注入NaN,覆盖同名真实日期,造成选股错误。现登记全部已注入内建字段,并禁止额外因子覆盖已存在的作用域变量。单独数字VM日期测试不足以发现该问题,新增日期+证券混合选择回归。
## 验证与边界
原生完整回测测试验证:显式策略真实模拟成交日启动3日保护/禁买、日期锁定零委托、最长持有让位于保护、锁定持仓占据资金与席位、解锁后才按候选顺序买入;序列化和别名归一不改max_holding_days字段,冲突策略拒绝。现有534核心用例通过(6个既有忽略项)。这些是隔离内核测试,不是GT实际成交验收。
@@ -0,0 +1,55 @@
# 回报上下文、盘前意图与尚未提交的目标
2026-09-14。本轮已配套发布177annotated tag `v2026.9.14.5`。Engine81acc54 / Service e81bf47 / Trading94f99d2;完整股票池Goal继续,不据本阶段关闭。
## 已复现问题
1. `on_process_event`总是收到`active_datetime=None`及空委托/成交数组。10:00账本已有100股,但Trade/PostMinute回调的成交数量仍为0;不能靠普通`on_minute`已修复就认为通知链也完整。
2. 15:05盘后成交后,PreAfterTrading仍被标为15:00;跨日模式的PostOnDay又使用信号日描述执行日已发生的成交。
3. BeforeTrading调度只处理订阅、账户和期货指令,剩余股票买卖/撤改意图没有后续消费。简单在开盘调用普通broker执行还会让旧挂单先成交再撤单。
4. 合并完整目标时只追加权重会保留旧证券;更重要的是,不能先提交盘前旧组合,之后才计算同一窗口的新目标,否则T+1可能使错误买入无法纠正。
5. 策略计算前的空broker调用也会恢复上一目标的未提交买入腿。反例中原持仓100股,09:30卖25股、13:00卖剩余75股;若此时先恢复旧买入,已经准备将新目标设为0%的策略仍会买入另一股票3000股。
## 本轮处理
- 事件通知显式携带当前可见的委托、成交与回调时钟,移动已完成记录后再通知,不按每个回调复制整段历史。上下文是通知时已完成批次的最新状态,不冒充每一历史通知发生瞬间的账本快照。
- 信号计算回调保留信号日;账户/委托通知使用实际执行日与物理时钟。默认收盘和结算不早于已处理时刻及当前适用的盘后结束点,管理费回调沿用同一完成时钟。
- 盘前撤改走明确的非撮合控制阶段,保持原订单ID和实际已成交量;该入口拒绝买卖目标,不会顺带撮合旧单。普通显式买卖按原配置窗口执行,后续回调读取撤改后的真实活动订单。
- 盘前与集合竞价的显式命令保留各自批次及约束。纯完整组合(完整rebalance或单一StockPool/TargetPortfolioSmart)可以被更新的完整意图替换;空回调不等于清仓,显式空完整目标才清仓。被替换意图的旧买入限制不能污染新完整目标。
- 尚未提交的完整目标保留到当前窗口日度策略算完;新执行意图优先,只有没有新执行意图时才使用前面的目标。已提交挂单可以先更新实际成交,但策略计算前不恢复旧的未提交买入腿,之后再由正常执行路径处理当前意图。
- 订阅/账户/直接期货指令通知同样获得完成后的历史;本轮不改变期货成交、会话或费用规则。
## 回归证据
- 通知链:09:30为空、10:00/10:01均看到100股及1笔实际成交,Trade通知可找到相同订单。
- 盘后:15:05成交后的默认收盘/结算和管理费通知不倒退;next-open保持独立信号日和执行日。
- 盘前:09:00生成100股命令,分别只在09:30/13:00配置窗口成交;保留备注/诊断。跨日撤销原GTC订单后,新订单只成交100股,未让旧单先成交。
- 完整目标:盘前A、集合竞价B、日度A或显式空目标,最终只采用有效最新目标;日度无新信号时保持B。显式逐股命令不会被目标合并丢弃。
- 恢复顺序:开启正常旧恢复的单点负向对照确实多买3000股;恢复BeforeStrategy阶段后,只有原股票同一卖单的25+75股成交,无新增买入,最终持仓为空。
- 本机Core834项通过(9项原有ignore),Trading613、最新main Runner446/API119通过。外部数据库及平台ignore不当作通过。
精确只读快照在Linux通过Core834及Trading613。旧二进制先独立归档,构建保持1GiB磁盘余量;本轮未再次删除缓存或业务文件。
## 发布与真实历史复验
已推送annotated tag `v2026.9.14.5`对应Engine `81acc5422878abc855fca72b35766ffad6159200`、Service `e81bf47806f5ac4ae4798bb5f5955a56638f754c`、Trading `94f99d20f49f6cd1810996706cb94f610c302385`。回测API/Runner于06:15:53 CST切换,五交易单元06:21:09切换,06:22实际运行文件和业务事实复核通过。
三组冻结合同共六次独立原生A/B,完整Canonical及equity/orders/trades/holdings逐行一致;再通过生产HTTP各提交一次,结果分别匹配原生候选,旧记录未改写:
| 案例 | 生产回测ID | 成交 / 持仓 | 期末权益 |
| --- | --- | --- | ---: |
| 手选优先四证券 | btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20 | 10 / 4 | 9706248.648662 |
| 自动优先四证券 | btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237 | 10 / 4 | 9706248.648662 |
| 许总24只原v3 | btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4 | 51 / 21 | 9685563.876924999 |
重复目标委托0。三条新记录各5个交易日事件落库,持久事件27/18/32条,唯一键计数分别相同;旧流式样本仍27条/5日。仍为原合同下的日终容量审计,不外推实时盘口成交能力;首次Source准备和后续快速返回也不作为性能优化证明。
API SHA `dea170902d77734d0a77c4da7dad71a70b33f76467e0608675dfbcc9d35d67fc`Runner SHA `b1d93215deb275fbec6217c6b9afbf717d5649600716bf4f3a1bf5d1cfa69731`,运行实现身份 `fed10e9fa61836aa271921f5d58490054210d83da935cad5de11cfacab45c13e`。API发布目录`/srv/fidc/canonical/run/backtest-api/releases/callback-81acc54-7w1zx9fb`,回退目录`/srv/fidc/canonical/run/build/callback-rollback-j7oje2tz`;交易回退`holding-protection-rollback-czuric4r`
六服务实际SHA与manifest吻合,新增ERROR0。3Paper/0Live、配置、旧活动单、3个未确认Paper预览、迁移、影子配置0及disabled未变;发布后Paper/Live新订单0,未发送真实通知、委托或撤单。Source d5/PID1700096与UI6a2/PID3089476未重启,研究/信号暂停不变。177维护中的Engine9a54156完整保留,实际构建使用81acc54/e81bf47及81acc54/94f99d2的只读Git快照。
原始回放/HTTP证据`/srv/fidc/canonical/run/research/stock-pool-callback-20260914/`;发布和最终审计`/tmp/fidc-callback-{candidate,api-release,trading-release,final-audit}-20260914.json`;非敏感汇总在`docs/evidence/callback-target-20260914/acceptance.json`
## 继续范围
显式逐笔手工影子回放仍未完成,四类手工来源继续拒绝纯比例影子;原始撤单意图时刻不能用网关回报时刻冒充。还需继续检查会话外调度产生的未提交意图、完整阶段日历与其余参数/生命周期/适配器矩阵。Source冻结、研究/信号暂停、现有任务配置和真实路由不改。
@@ -0,0 +1,191 @@
{
"verified_at": "2026-09-13T22:22:38.597836+00:00",
"tag": "v2026.9.14.5",
"processes": {
"fidc-backtest-service-highmem177.service": {
"pid": 3692551,
"sha256": "dea170902d77734d0a77c4da7dad71a70b33f76467e0608675dfbcc9d35d67fc",
"journal_since": "2026-09-13T22:15:53.225719+00:00",
"journal_lines": 54,
"error_lines": 0
},
"fidc-trading-control-highmem177.service": {
"pid": 3697497,
"sha256": "a8f62ba74caf7ce2f5ba9cc6f67f41c844dee3747852611051c8dfb7b36295a3",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-market-data-highmem177.service": {
"pid": 3697498,
"sha256": "89395ab4c9e11274f171f4386f88949ce616fe45d15aae9a829216d53ed11db7",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-strategy-runtime-highmem177.service": {
"pid": 3697688,
"sha256": "041a0103d2c46c55221d169965ece9fdacee3905abc0d46edd9c8a2a86f6cd54",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-paper-trading-highmem177.service": {
"pid": 3697792,
"sha256": "8460008f712810f7d3876b9f2274aef88f82d46cd33e1593dbd0361f6d158b75",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 6,
"error_lines": 0
},
"fidc-live-trading-highmem177.service": {
"pid": 3697762,
"sha256": "e89d6ba68655a5d4a164793ad67a9003cf2c49733063018497339930feafaac3",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 6,
"error_lines": 0
}
},
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"loaded_at": "2026-09-12T03:57:06.665536+00:00",
"source_stale": false,
"loaded_server_sha256": "ef827ce6b95e0ea63047a0068af2677633716e0a5d63cf350de6c91a3413e352"
},
"source_checkouts": {
"fidc-backtest-engine": {
"head": "9a54156df94cfbf11a1e6335ec6ef5449bd6ac17",
"runtime_commit": "81acc5422878abc855fca72b35766ffad6159200",
"tracked_dirty": false
},
"fidc-backtest-service": {
"head": "5ec8dc86d99736a0c0140440bd039d11e118c1c6",
"runtime_commit": "e81bf47806f5ac4ae4798bb5f5955a56638f754c",
"tracked_dirty": false
},
"fidc-trading-platform": {
"head": "94f99d20f49f6cd1810996706cb94f610c302385",
"runtime_commit": "94f99d20f49f6cd1810996706cb94f610c302385",
"tracked_dirty": false
},
"omniquant": {
"head": "6a2b2604b40505fa754453307c517fef60743426",
"runtime_commit": "6a2b2604b40505fa754453307c517fef60743426",
"tracked_dirty": false
}
},
"ui_unchanged": {
"commit": "6a2b2604b40505fa754453307c517fef60743426",
"pid": 3089476
},
"http_cases": [
{
"name": "manual_first",
"run_id": "btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20",
"status": "succeeded",
"canonical_sha256": "0830216850b64d6e83291e072b31a9989f179915ee3341a75a77c73d1f9081a3",
"trade_count": 10,
"holding_count": 4,
"final_equity": 9706248.648662,
"old_result_unchanged": true
},
{
"name": "automatic_first",
"run_id": "btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237",
"status": "succeeded",
"canonical_sha256": "c75cabcc03760f415bb664d20060e81c620d7a0201dd348ea71f75c932571de7",
"trade_count": 10,
"holding_count": 4,
"final_equity": 9706248.648662,
"old_result_unchanged": true
},
{
"name": "stock24",
"run_id": "btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4",
"status": "succeeded",
"canonical_sha256": "270b403542ab41290c3d6e027b89cdab24dd41a8e2851d8786b33daa51e0051f",
"trade_count": 51,
"holding_count": 21,
"final_equity": 9685563.876924999,
"old_result_unchanged": true
}
],
"durable_events": [
{
"run_id": "btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20",
"count": 27,
"unique_keys": 27,
"days": 5
},
{
"run_id": "btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237",
"count": 18,
"unique_keys": 18,
"days": 5
},
{
"run_id": "btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4",
"count": 32,
"unique_keys": 32,
"days": 5
},
{
"run_id": "btr_req_a3c3dfe5cd81e27e565064a65665f561c60adebdc6c9c9b4",
"count": 27,
"unique_keys": 27,
"days": 5
}
],
"trading_state": {
"paper": {
"configuration": {
"count": 3,
"hash": "93f3224edef59c381164e0236529dacc"
},
"active": {
"claims": 0,
"orders": 0
}
},
"live": {
"configuration": {
"count": 0,
"hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"active": {
"claims": 0,
"orders": 1,
"today_orders": 0,
"orders_hash": "d4b56fbf3a541a41a383ad4e48891bb8",
"route_mode": "disabled"
}
}
},
"manual_facts_unchanged": {
"paper": {
"shadow_configurations": 0,
"shadow_runs": 0,
"manual_count": 3,
"manual_hash": "82572901ac0b5fdb4d8b984f71e1763d",
"migrations_hash": "21d711b2ee52d2d66a8be4e99b179190",
"new_orders": 0
},
"live": {
"shadow_configurations": 0,
"shadow_runs": 0,
"manual_count": 0,
"manual_hash": "d41d8cd98f00b204e9800998ecf8427e",
"migrations_hash": "610528d4f350309379c9398c4ea43f66",
"new_orders": 0
}
},
"broker_submission": false,
"linux_core_tests": {
"passed": 834,
"failed": 0,
"ignored": 9,
"log": "/srv/fidc/canonical/run/fidc-private/evidence/callback-candidate-6qumwky7/linux-core-tests.log"
},
"scope": "Callback and pending-target release verification; historical simulation only. Full manual shadow replay remains incomplete.",
"native_replays": 6
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,258 @@
{
"verified_at": "2026-09-13T20:22:24.750379+00:00",
"tag": "v2026.9.14.4",
"processes": {
"fidc-backtest-service-highmem177.service": {
"pid": 3612875,
"sha256": "4e9f142be0ae3f9ca8e1c126507d4a9905cde4b69859df4544472afd1bda1ff2",
"journal_since": "2026-09-13T20:14:17.444770+00:00",
"journal_lines": 54,
"error_lines": 0
},
"fidc-trading-control-highmem177.service": {
"pid": 3617963,
"sha256": "cd587928591fef952f2e98b47aa338a1702def5edf016a7da9751296667f6674",
"journal_since": "2026-09-13T20:19:57.821843+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-market-data-highmem177.service": {
"pid": 3617964,
"sha256": "2efb1d3ad7d510cf85e6047dd6d1981d0a30d768ff3d33c842adc52211002bdc",
"journal_since": "2026-09-13T20:19:57.821843+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-strategy-runtime-highmem177.service": {
"pid": 3618140,
"sha256": "3d7f3f2e8756e7f3439075344fe9c8bc0b55df33e7e6f251282712274c00339d",
"journal_since": "2026-09-13T20:19:57.821843+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-paper-trading-highmem177.service": {
"pid": 3618260,
"sha256": "0383b1d6cc7b3c48c6902dd7fd4760a38698c1916e0eaff63be26fe3f6b1a2ab",
"journal_since": "2026-09-13T20:19:57.821843+00:00",
"journal_lines": 6,
"error_lines": 0
},
"fidc-live-trading-highmem177.service": {
"pid": 3618246,
"sha256": "583f52e204aeb416574ee17daa20cebed49e0659a194c81e8072a9249824e48e",
"journal_since": "2026-09-13T20:19:57.821843+00:00",
"journal_lines": 6,
"error_lines": 0
}
},
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"loaded_at": "2026-09-12T03:57:06.665536+00:00",
"source_stale": false,
"loaded_server_sha256": "ef827ce6b95e0ea63047a0068af2677633716e0a5d63cf350de6c91a3413e352"
},
"source_checkouts": {
"fidc-backtest-engine": {
"head": "9a54156df94cfbf11a1e6335ec6ef5449bd6ac17",
"runtime_commit": "237ee15a518a668297959509daffc4b88995f310",
"tracked_dirty": false
},
"fidc-backtest-service": {
"head": "5ec8dc86d99736a0c0140440bd039d11e118c1c6",
"runtime_commit": "e81bf47806f5ac4ae4798bb5f5955a56638f754c",
"tracked_dirty": false
},
"fidc-trading-platform": {
"head": "dab98e0cc09793df15b8c72841a6dc7e9a58a208",
"runtime_commit": "dab98e0cc09793df15b8c72841a6dc7e9a58a208",
"tracked_dirty": false
},
"omniquant": {
"head": "6a2b2604b40505fa754453307c517fef60743426",
"runtime_commit": "6a2b2604b40505fa754453307c517fef60743426",
"tracked_dirty": false
}
},
"ui_unchanged": {
"commit": "6a2b2604b40505fa754453307c517fef60743426",
"pid": 3089476
},
"http_cases": [
{
"name": "manual_first",
"run_id": "btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303",
"status": "succeeded",
"canonical_sha256": "0830216850b64d6e83291e072b31a9989f179915ee3341a75a77c73d1f9081a3",
"trade_count": 10,
"holding_count": 4,
"final_equity": 9706248.648662,
"old_result_unchanged": true
},
{
"name": "automatic_first",
"run_id": "btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054",
"status": "succeeded",
"canonical_sha256": "c75cabcc03760f415bb664d20060e81c620d7a0201dd348ea71f75c932571de7",
"trade_count": 10,
"holding_count": 4,
"final_equity": 9706248.648662,
"old_result_unchanged": true
},
{
"name": "stock24",
"run_id": "btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74",
"status": "succeeded",
"canonical_sha256": "270b403542ab41290c3d6e027b89cdab24dd41a8e2851d8786b33daa51e0051f",
"trade_count": 51,
"holding_count": 21,
"final_equity": 9685563.876924999,
"old_result_unchanged": true
}
],
"durable_events": [
{
"run_id": "btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303",
"count": 27,
"unique_keys": 27,
"days": 5
},
{
"run_id": "btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054",
"count": 18,
"unique_keys": 18,
"days": 5
},
{
"run_id": "btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74",
"count": 32,
"unique_keys": 32,
"days": 5
},
{
"run_id": "btr_req_a3c3dfe5cd81e27e565064a65665f561c60adebdc6c9c9b4",
"count": 27,
"unique_keys": 27,
"days": 5
}
],
"trading_state": {
"paper": {
"configuration": {
"count": 3,
"hash": "93f3224edef59c381164e0236529dacc"
},
"active": {
"claims": 0,
"orders": 0
}
},
"live": {
"configuration": {
"count": 0,
"hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"active": {
"claims": 0,
"orders": 1,
"today_orders": 0,
"orders_hash": "d4b56fbf3a541a41a383ad4e48891bb8",
"route_mode": "disabled"
}
}
},
"manual_facts_unchanged": {
"paper": {
"shadow_configurations": 0,
"shadow_runs": 0,
"manual_count": 3,
"manual_hash": "82572901ac0b5fdb4d8b984f71e1763d",
"migrations_hash": "21d711b2ee52d2d66a8be4e99b179190",
"new_orders": 0
},
"live": {
"shadow_configurations": 0,
"shadow_runs": 0,
"manual_count": 0,
"manual_hash": "d41d8cd98f00b204e9800998ecf8427e",
"migrations_hash": "610528d4f350309379c9398c4ea43f66",
"new_orders": 0
}
},
"broker_submission": false,
"linux_core_tests": {
"passed": 822,
"failed": 0,
"ignored": 9,
"log": "/srv/fidc/canonical/run/fidc-private/evidence/clock-candidate-gqx8g70l/linux-core-tests.log"
},
"cleanup": {
"apply": true,
"deleted": [
{
"path": "/srv/fidc/canonical/build/holding-protection-stage-wywd2682/fidc-trading-platform/debug/incremental",
"kind": "incremental_compiler_state",
"bytes": 9553190912,
"device": 2101,
"inode": 39877787,
"mtime_ns": 1789318996850462500,
"links": 176,
"size_bytes": 12288
},
{
"path": "/srv/fidc/canonical/build/target-backtest/debug/incremental",
"kind": "incremental_compiler_state",
"bytes": 2103459840,
"device": 2101,
"inode": 29904450,
"mtime_ns": 1789318494701447400,
"links": 46,
"size_bytes": 4096
},
{
"path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_backtest_service-46310ff8aeeb4040",
"kind": "superseded_test_binary",
"bytes": 398401536,
"device": 2101,
"inode": 29934330,
"mtime_ns": 1789117975169420000,
"links": 1,
"size_bytes": 399242488
},
{
"path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_core-18c9b2429fdf6026",
"kind": "superseded_test_binary",
"bytes": 207015936,
"device": 2101,
"inode": 29918792,
"mtime_ns": 1789166943885717800,
"links": 1,
"size_bytes": 207144208
},
{
"path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_core-42f704a330411730",
"kind": "superseded_test_binary",
"bytes": 191205376,
"device": 2101,
"inode": 29933759,
"mtime_ns": 1789117542401109800,
"links": 1,
"size_bytes": 191333360
}
],
"reclaimed_allocated_bytes": 12453273600,
"before": {
"total": 1749269057536,
"used": 1659299954688,
"free": 1035599872
},
"after": {
"total": 1749269057536,
"used": 1647166930944,
"free": 13168623616
},
"observed_free_change": 12133023744
},
"scope": "Intraday clock release verification; historical simulation only, not a performance or real broker liquidity acceptance.",
"native_replays": 6
}
@@ -0,0 +1,40 @@
{
"source": "/srv/fidc/programs/runtime-snapshots/lot-lifecycle-20260914-AEGDYL",
"build": "/srv/fidc/canonical/build/lot-lifecycle-20260914-AEGDYL",
"commits": {
"fidc-backtest-engine": "b4c68be29b98dc814fa75641f629dd18e1dd803b",
"fidc-backtest-service": "49f280075e6b9dce2ef149fc190cfe663411b905",
"fidc-trading-platform": "ae83fd30f56a5420a235022b0a169aaf0bae55cf"
},
"source_unchanged": true,
"results": [
{
"repository": "fidc-backtest-engine",
"exit_code": 0,
"elapsed_seconds": 60.008,
"passed": 889,
"failed": 0,
"ignored": 9,
"log_sha256": "7d0760a024a564cd2c18b7bf3b41ab8ba6f95d7e259d49a43191f0ebe666989e"
},
{
"repository": "fidc-trading-platform",
"exit_code": 0,
"elapsed_seconds": 102.011,
"passed": 625,
"failed": 0,
"ignored": 63,
"log_sha256": "ab55da8d47425a0acc6c33dc4bc352f463ad4e1aac887b0169a90f18cb6763cb"
},
{
"repository": "fidc-backtest-service",
"exit_code": 0,
"elapsed_seconds": 118.011,
"passed": 589,
"failed": 0,
"ignored": 16,
"log_sha256": "ff6946b18567dad6dab41b6c36f65ea1181d7ee6b7d53b6ceb568b1dedb7abae"
}
],
"available_bytes": 94029492224
}
@@ -0,0 +1,61 @@
{
"observed_date": "2026-09-14",
"status": "tested_not_deployed",
"source_snapshot_manifest_sha256": "5560614fa2ce2b0c905c44011ff4147df1fe7c04be93cd3c1410cd16fca025de",
"immutable_source_file_count": 962,
"test_scope": "fidc-lot-lifecycle-check-20260914-AEGDYL.scope",
"test_cpu_quota_percent": 400,
"test_memory_max_gib": 12,
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
"source_runtime_pid": 1700096,
"source_runtime_changed": false,
"runtime_services": [
{
"service": "omniquant-highmem177.service",
"pid": 987464,
"state": "active",
"version": "v2026.9.14.8"
},
{
"service": "fidc-backtest-service-highmem177.service",
"pid": 4108679,
"state": "active",
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
},
{
"service": "fidc-paper-trading-highmem177.service",
"pid": 3765829,
"state": "active",
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
},
{
"service": "fidc-live-trading-highmem177.service",
"pid": 3765783,
"state": "active",
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
},
{
"service": "fidc-strategy-runtime-highmem177.service",
"pid": 3765705,
"state": "active",
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
},
{
"service": "fidc-trading-control-highmem177.service",
"pid": 3765532,
"state": "active",
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
}
],
"engine_development_checkout": "b4c68be29b98dc814fa75641f629dd18e1dd803b",
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
"production_order_or_cancel_requests_by_this_verification": 0,
"production_task_configuration_writes_by_this_verification": 0,
"remaining": [
"Source freeze authorization and physical partition validation",
"Actual Source and Runner shadow joint acceptance",
"Unaccepted Arrow performance candidate gate",
"Delayed observations spanning already applied corporate actions and remaining cross-mode matrix"
],
"source_runtime_tracked_clean": true
}
@@ -0,0 +1,40 @@
{
"source": "/srv/fidc/programs/runtime-snapshots/corporate-replay-20260914-ZAnvlO",
"build": "/srv/fidc/canonical/build/corporate-replay-20260914-ZAnvlO",
"commits": {
"fidc-backtest-engine": "05f1cbbe00ffe75b72de8a24cea6a4e40e8142b0",
"fidc-backtest-service": "879a5a7fe07620415b00023f4628cc81e45903fb",
"fidc-trading-platform": "559f5b1d24d98a67606e859a1f2105bee4c0bc01"
},
"source_unchanged": true,
"results": [
{
"repository": "fidc-backtest-engine",
"exit_code": 0,
"elapsed_seconds": 58.007,
"passed": 895,
"failed": 0,
"ignored": 9,
"log_sha256": "d50fc04699eaa39399016e3b9f470e9adf3e8da0d968ada2e212ef606bdfb37a"
},
{
"repository": "fidc-trading-platform",
"exit_code": 0,
"elapsed_seconds": 94.009,
"passed": 625,
"failed": 0,
"ignored": 63,
"log_sha256": "eafd3d792dc54ff90efaae9aa1e9e0c15bb191f8b679585c982b8c1bab3211ad"
},
{
"repository": "fidc-backtest-service",
"exit_code": 0,
"elapsed_seconds": 148.015,
"passed": 591,
"failed": 0,
"ignored": 16,
"log_sha256": "2ae31905c43697177af50825270d4f7830361d6e1d5e3d22a26179dd726abe36"
}
],
"available_bytes": 90635833344
}
@@ -0,0 +1,61 @@
{
"observed_date": "2026-09-14",
"status": "tested_not_deployed",
"source_snapshot_manifest_sha256": "9b61a3586248c80bac30ce2107c9323a4d9a1ac7bdf29a42d3c423bd5c615ec4",
"immutable_source_file_count": 972,
"test_scope": "fidc-corporate-replay-check-20260914-ZAnvlO.scope",
"test_cpu_quota_percent": 400,
"test_memory_max_gib": 12,
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
"source_runtime_pid": 1700096,
"source_runtime_changed": false,
"runtime_services": [
{
"service": "omniquant-highmem177.service",
"pid": 987464,
"state": "active",
"version": "v2026.9.14.8"
},
{
"service": "fidc-backtest-service-highmem177.service",
"pid": 4108679,
"state": "active",
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
},
{
"service": "fidc-paper-trading-highmem177.service",
"pid": 3765829,
"state": "active",
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
},
{
"service": "fidc-live-trading-highmem177.service",
"pid": 3765783,
"state": "active",
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
},
{
"service": "fidc-strategy-runtime-highmem177.service",
"pid": 3765705,
"state": "active",
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
},
{
"service": "fidc-trading-control-highmem177.service",
"pid": 3765532,
"state": "active",
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
}
],
"engine_development_checkout": "05f1cbbe00ffe75b72de8a24cea6a4e40e8142b0",
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
"production_order_or_cancel_requests_by_this_verification": 0,
"production_task_configuration_writes_by_this_verification": 0,
"remaining": [
"Source freeze authorization and physical partition validation",
"Actual Source and Runner shadow joint acceptance",
"Unaccepted Arrow performance candidate gate",
"Dividend reinvestment execution clocks, successor data scope, target state and cross-mode ETF matrix"
],
"source_runtime_tracked_clean": true
}
@@ -0,0 +1,40 @@
{
"source": "/srv/fidc/programs/runtime-snapshots/zero-reinvestment-20260915-Yovt6m",
"build": "/srv/fidc/canonical/build/zero-reinvestment-20260915-Yovt6m",
"commits": {
"fidc-backtest-engine": "818552bc969e0ea40081f0770c1d97db574a1981",
"fidc-backtest-service": "234d85bdb8ed12b552ba8e9eff47fddc5e683acc",
"fidc-trading-platform": "d7d492541e60eb16e58dbff74b664b8ac85f6ccf"
},
"source_unchanged": true,
"results": [
{
"repository": "fidc-backtest-engine",
"exit_code": 0,
"elapsed_seconds": 56.008,
"passed": 919,
"failed": 0,
"ignored": 9,
"log_sha256": "87a4daa4db1c0f735399ce243feff8e514e5f75f72b0dbfdb69da0c21d02f2b4"
},
{
"repository": "fidc-trading-platform",
"exit_code": 0,
"elapsed_seconds": 90.029,
"passed": 625,
"failed": 0,
"ignored": 63,
"log_sha256": "e7f15eb7463f1cc33b7122020549af082d2070ad38392d0918c9c7309a68cc26"
},
{
"repository": "fidc-backtest-service",
"exit_code": 0,
"elapsed_seconds": 112.014,
"passed": 594,
"failed": 0,
"ignored": 16,
"log_sha256": "4faa52e9a87056c192341622d0273942f3daf963cd75a381ad913c76d47aed29"
}
],
"available_bytes": 77131395072
}
@@ -0,0 +1,76 @@
{
"date": "2026-09-15",
"baseline_engine": "534ab42906c7e7a4e253b7791cc143dec894a7f2",
"candidate_engine": "818552bc969e0ea40081f0770c1d97db574a1981",
"reproduced": {
"manual_zero_effective_before_payment": true,
"original_position_cleared": true,
"unexpected_accounting_allocation": {
"symbol": "000001.SZ",
"quantity": 100,
"price": 8.95
},
"original_gross_output": 894.9999999999999,
"actual_posted_amount": 895
},
"verified": {
"global_set_and_scale_zero_leave_cash": true,
"zero_or_excluded_manual_member_leaves_cash": true,
"same_instant_uses_sequence": true,
"input_array_order_does_not_change_result": true,
"future_settings_are_not_backdated": true,
"restore_does_not_mean_forced_full_exposure": true,
"legacy_day_granularity_preserved": true,
"normal_settlement_and_late_replay_share_control": true,
"actual_manual_fills_and_cash_entitlements_not_erased": true,
"invalid_cash_is_rejected_atomically": true,
"unused_reinvestment_reference_not_read_under_zero": true,
"no_control_keeps_legacy_price_quantity_and_fees": true
},
"exact_outcomes": {
"cleared_with_zero": {
"cash": 49998,
"quantity": 0
},
"cleared_without_zero": {
"cash": 49103,
"quantity": 100,
"price": 8.95,
"fee": 0,
"gross": 895
},
"actual_extra_buy_timely_and_late": {
"cash": 41047,
"quantity": 1000,
"corporate_cash_adjustment": 1050,
"optional_allocations": 0
}
},
"local_tests": {
"core": {
"passed": 919,
"ignored": 9
},
"trading": {
"passed": 625,
"ignored": 63
},
"runner": {
"passed": 463,
"ignored": 9
},
"api": {
"passed": 129,
"ignored": 7
}
},
"new_test_functions": 6,
"production_runtime_modified": false,
"production_order_or_cancel_requests": 0,
"limits": [
"isolated replay not actual Source/Runner acceptance",
"does not evaluate arbitrary strategy expressions at settlement",
"Source runtime freeze unchanged",
"no new production UI or broker validation"
]
}
@@ -0,0 +1,53 @@
{
"observed_at": "2026-09-14T16:34:44.320112+00:00",
"status": "tested_not_deployed",
"runtime_services": [
{
"service": "omniquant-highmem177.service",
"pid": 987464,
"state": "active"
},
{
"service": "fidc-backtest-service-highmem177.service",
"pid": 4108679,
"state": "active",
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
},
{
"service": "fidc-paper-trading-highmem177.service",
"pid": 3765829,
"state": "active",
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
},
{
"service": "fidc-live-trading-highmem177.service",
"pid": 3765783,
"state": "active",
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
},
{
"service": "fidc-strategy-runtime-highmem177.service",
"pid": 3765705,
"state": "active",
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
},
{
"service": "fidc-trading-control-highmem177.service",
"pid": 3765532,
"state": "active",
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
}
],
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
"source_runtime_tracked_clean": true,
"engine_development_checkout": "818552bc969e0ea40081f0770c1d97db574a1981",
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
"immutable_source_file_count": 989,
"source_snapshot_manifest_sha256": "9ffdc1bc86e1b4b085caa694e921fc230ce0f09c57d34254058ae28e631d5bb3",
"test_scope": "fidc-zero-reinvestment-check-20260915-Yovt6m.scope",
"test_cpu_quota_percent": 400,
"test_memory_max_gib": 12,
"source_runtime_modified": false,
"production_order_or_cancel_requests_by_this_verification": 0,
"production_task_configuration_writes_by_this_verification": 0
}
@@ -0,0 +1,40 @@
{
"source": "/srv/fidc/programs/runtime-snapshots/opening-clock-20260914-UUx5ru",
"build": "/srv/fidc/canonical/build/opening-clock-20260914-UUx5ru",
"commits": {
"fidc-backtest-engine": "13c89e8d59f21df6280d722f369d0ec8d9a6457e",
"fidc-backtest-service": "49f280075e6b9dce2ef149fc190cfe663411b905",
"fidc-trading-platform": "ae83fd30f56a5420a235022b0a169aaf0bae55cf"
},
"source_unchanged": true,
"results": [
{
"repository": "fidc-backtest-engine",
"exit_code": 0,
"elapsed_seconds": 56.007,
"passed": 885,
"failed": 0,
"ignored": 9,
"log_sha256": "9a887a23fc656d0f448502ecb2fca5fcfd82db15346f567e6a8b0bcaa88180d7"
},
{
"repository": "fidc-trading-platform",
"exit_code": 0,
"elapsed_seconds": 90.054,
"passed": 625,
"failed": 0,
"ignored": 63,
"log_sha256": "048a7690ce874bccc58e3111decd183c3f88f649397c59cdfbaa3e4533662589"
},
{
"repository": "fidc-backtest-service",
"exit_code": 0,
"elapsed_seconds": 118.011,
"passed": 589,
"failed": 0,
"ignored": 16,
"log_sha256": "ec4f51b30dd69d9c86812cbb1b1e310c277fdfcf29465978b8d87eb83f40204c"
}
],
"available_bytes": 97373085696
}
@@ -0,0 +1,60 @@
{
"observed_date": "2026-09-14",
"status": "tested_not_deployed",
"source_snapshot_manifest_sha256": "bf01cd040bdcbeaa0c81a93153eb8e505dd7dcb8c761731f7f3ed81b1b2b6b67",
"immutable_source_file_count": 959,
"test_scope": "fidc-opening-clock-check-20260914-UUx5ru.scope",
"test_cpu_quota_percent": 400,
"test_memory_max_gib": 12,
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
"source_runtime_pid": 1700096,
"source_runtime_changed": false,
"runtime_services": [
{
"service": "omniquant-highmem177.service",
"pid": 987464,
"state": "active",
"version": "v2026.9.14.8"
},
{
"service": "fidc-backtest-service-highmem177.service",
"pid": 4108679,
"state": "active",
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
},
{
"service": "fidc-paper-trading-highmem177.service",
"pid": 3765829,
"state": "active",
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
},
{
"service": "fidc-live-trading-highmem177.service",
"pid": 3765783,
"state": "active",
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
},
{
"service": "fidc-strategy-runtime-highmem177.service",
"pid": 3765705,
"state": "active",
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
},
{
"service": "fidc-trading-control-highmem177.service",
"pid": 3765532,
"state": "active",
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
}
],
"engine_development_checkout": "13c89e8d59f21df6280d722f369d0ec8d9a6457e",
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
"production_order_or_cancel_requests_by_this_verification": 0,
"production_task_configuration_writes_by_this_verification": 0,
"remaining": [
"Source freeze authorization and physical partition validation",
"Actual Source and Runner shadow joint acceptance",
"Unaccepted Arrow performance candidate gate",
"Remaining corporate-action, holding-protection and full parameter matrix"
]
}
@@ -0,0 +1,40 @@
{
"source": "/srv/fidc/programs/runtime-snapshots/reinvestment-20260914-dBw0O5",
"build": "/srv/fidc/canonical/build/reinvestment-20260914-dBw0O5",
"commits": {
"fidc-backtest-engine": "984f9d308dbddec8134b8aed249f82b962839505",
"fidc-backtest-service": "234d85bdb8ed12b552ba8e9eff47fddc5e683acc",
"fidc-trading-platform": "d7d492541e60eb16e58dbff74b664b8ac85f6ccf"
},
"source_unchanged": true,
"results": [
{
"repository": "fidc-backtest-engine",
"exit_code": 0,
"elapsed_seconds": 54.005,
"passed": 901,
"failed": 0,
"ignored": 9,
"log_sha256": "ed7d00793dbbb037b9c806687841de00507ef8f59a174ab4c6ab38cbd419fe35"
},
{
"repository": "fidc-trading-platform",
"exit_code": 0,
"elapsed_seconds": 88.008,
"passed": 625,
"failed": 0,
"ignored": 63,
"log_sha256": "d4c6e129353c9cb738bb277c68bd390ac8b1e8df4db44340bd63e71fd60fa9da"
},
{
"repository": "fidc-backtest-service",
"exit_code": 0,
"elapsed_seconds": 110.01,
"passed": 594,
"failed": 0,
"ignored": 16,
"log_sha256": "57cd4b3f900f01f74864a1bb07a726b5d350b42384153f9094d9f316ddb7063c"
}
],
"available_bytes": 87221923840
}
@@ -0,0 +1,61 @@
{
"observed_date": "2026-09-14",
"status": "tested_not_deployed",
"source_snapshot_manifest_sha256": "8452dc9dedb6b3b3a48f37a572f4fe653728c856eda9aedd2644079448a55d65",
"immutable_source_file_count": 978,
"test_scope": "fidc-reinvestment-check-20260914-dBw0O5.scope",
"test_cpu_quota_percent": 400,
"test_memory_max_gib": 12,
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
"source_runtime_pid": 1700096,
"source_runtime_changed": false,
"runtime_services": [
{
"service": "omniquant-highmem177.service",
"pid": 987464,
"state": "active",
"version": "v2026.9.14.8"
},
{
"service": "fidc-backtest-service-highmem177.service",
"pid": 4108679,
"state": "active",
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
},
{
"service": "fidc-paper-trading-highmem177.service",
"pid": 3765829,
"state": "active",
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
},
{
"service": "fidc-live-trading-highmem177.service",
"pid": 3765783,
"state": "active",
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
},
{
"service": "fidc-strategy-runtime-highmem177.service",
"pid": 3765705,
"state": "active",
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
},
{
"service": "fidc-trading-control-highmem177.service",
"pid": 3765532,
"state": "active",
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
}
],
"engine_development_checkout": "984f9d308dbddec8134b8aed249f82b962839505",
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
"production_order_or_cancel_requests_by_this_verification": 0,
"production_task_configuration_writes_by_this_verification": 0,
"remaining": [
"Source freeze authorization and physical partition validation",
"Actual Source and Runner shadow joint acceptance",
"Unaccepted Arrow performance candidate gate",
"Successor data scope, target state, cleared-position references and cross-mode ETF matrix"
],
"source_runtime_tracked_clean": true
}
@@ -0,0 +1,588 @@
{
"schema": "fidc.selection-risk-plan-acceptance/v1",
"rows": [
{
"name": "control-1",
"receiptSha256": "f18b3b484d40e2a813bd795cb38e263ff43f65b17f31004786d3a23a6af5bcb6",
"wallSeconds": 30.986483575077727,
"engineSeconds": 8.79,
"dataSeconds": 8.445,
"validationSeconds": 12.244,
"resultSeconds": 1.292,
"maxRssKiB": 7090392,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "control-2",
"receiptSha256": "8e5f7f8fe77ba2a798056306277a4ae4f00b6aa98b8c277269aca8c235bbd0fb",
"wallSeconds": 13.274638780159876,
"engineSeconds": 6.739,
"dataSeconds": 5.19,
"validationSeconds": 0.209,
"resultSeconds": 1.003,
"maxRssKiB": 7092040,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "control-3",
"receiptSha256": "d106ddae57c64f931e196b80ffa517443e5c0f11eb9c2079f84d55b2d693fb13",
"wallSeconds": 13.043757867999375,
"engineSeconds": 6.732,
"dataSeconds": 5.159,
"validationSeconds": 0.005,
"resultSeconds": 1,
"maxRssKiB": 7089984,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "candidate-1",
"receiptSha256": "a76e11c115ad42389dfdf72ed674ad75af8ec3d4646feb57feee9e6a4418f20d",
"wallSeconds": 12.976857921108603,
"engineSeconds": 6.682,
"dataSeconds": 5.132,
"validationSeconds": 0.004,
"resultSeconds": 1.021,
"maxRssKiB": 7091752,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "candidate-2",
"receiptSha256": "38f61fd0d495daa5e29d6354679ce51e33473fb3ecbbb420c93d2fd41b74246f",
"wallSeconds": 12.927155625075102,
"engineSeconds": 6.64,
"dataSeconds": 5.128,
"validationSeconds": 0.005,
"resultSeconds": 1.01,
"maxRssKiB": 7092320,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "candidate-3",
"receiptSha256": "9b56896d6dc048c5dd3d56cbe863778122b5bdf42fc9769eaa41f2d1b339dcd4",
"wallSeconds": 12.926160736009479,
"engineSeconds": 6.664,
"dataSeconds": 5.113,
"validationSeconds": 0.006,
"resultSeconds": 1.006,
"maxRssKiB": 7091128,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "trend-40-control",
"receiptSha256": "305f0ea34b50355661ef9d3583467f7160cfbffd95f03b9e21a631bebc37af64",
"wallSeconds": 15.628366323187947,
"engineSeconds": 8.199,
"dataSeconds": 5.234,
"validationSeconds": 0.694,
"resultSeconds": 1.33,
"maxRssKiB": 7108340,
"fills": 29776,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 128192,
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
"sections": {
"accountEvents": {
"rowCount": 29968,
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
},
"fillEvents": {
"rowCount": 29776,
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
},
"holdingSnapshots": {
"rowCount": 37367,
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
},
"orderEvents": {
"rowCount": 29932,
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
},
"riskAudits": {
"rowCount": 124,
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
}
}
},
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
"verifiedFactBlocks": 293,
"sharedInputsUnchanged": true
},
{
"name": "trend-40-candidate",
"receiptSha256": "0174941bea20079730c019b3de4185cc439528160ab54cafc1be4e3f8a0a08fc",
"wallSeconds": 14.82603678200394,
"engineSeconds": 8.087,
"dataSeconds": 5.276,
"validationSeconds": 0.004,
"resultSeconds": 1.322,
"maxRssKiB": 7108656,
"fills": 29776,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 128192,
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
"sections": {
"accountEvents": {
"rowCount": 29968,
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
},
"fillEvents": {
"rowCount": 29776,
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
},
"holdingSnapshots": {
"rowCount": 37367,
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
},
"orderEvents": {
"rowCount": 29932,
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
},
"riskAudits": {
"rowCount": 124,
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
}
}
},
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
"verifiedFactBlocks": 293,
"sharedInputsUnchanged": true
},
{
"name": "pullback-40-control",
"receiptSha256": "0d39c6af608d3ec89fc44d0715dab41229511c68cf5ea4eb01f763c10bded8bf",
"wallSeconds": 13.775856785941869,
"engineSeconds": 7.374,
"dataSeconds": 4.893,
"validationSeconds": 0.005,
"resultSeconds": 1.358,
"maxRssKiB": 7119352,
"fills": 31862,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 135630,
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
"sections": {
"accountEvents": {
"rowCount": 32010,
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
},
"fillEvents": {
"rowCount": 31862,
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
},
"holdingSnapshots": {
"rowCount": 38679,
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
},
"orderEvents": {
"rowCount": 31966,
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
},
"riskAudits": {
"rowCount": 88,
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
}
}
},
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
"verifiedFactBlocks": 281,
"sharedInputsUnchanged": true
},
{
"name": "pullback-40-candidate",
"receiptSha256": "3f0ec7b8b6ad74fc7349586a8716d45b8075ebad03c77776ca78fab5188d49ee",
"wallSeconds": 13.927610703045502,
"engineSeconds": 7.239,
"dataSeconds": 5.137,
"validationSeconds": 0.003,
"resultSeconds": 1.368,
"maxRssKiB": 7119784,
"fills": 31862,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 135630,
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
"sections": {
"accountEvents": {
"rowCount": 32010,
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
},
"fillEvents": {
"rowCount": 31862,
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
},
"holdingSnapshots": {
"rowCount": 38679,
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
},
"orderEvents": {
"rowCount": 31966,
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
},
"riskAudits": {
"rowCount": 88,
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
}
}
},
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
"verifiedFactBlocks": 281,
"sharedInputsUnchanged": true
},
{
"name": "volume-momentum-80-control",
"receiptSha256": "98239365828453888930a1fceb2a7d9b5402b03cd32c9303a9fa1532af3644ed",
"wallSeconds": 18.176081838086247,
"engineSeconds": 11.154,
"dataSeconds": 4.585,
"validationSeconds": 0.004,
"resultSeconds": 2.268,
"maxRssKiB": 7158556,
"fills": 51300,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 234267,
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
"sections": {
"accountEvents": {
"rowCount": 51696,
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
},
"fillEvents": {
"rowCount": 51300,
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
},
"holdingSnapshots": {
"rowCount": 78078,
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
},
"orderEvents": {
"rowCount": 51783,
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
},
"riskAudits": {
"rowCount": 385,
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
}
}
},
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
"verifiedFactBlocks": 309,
"sharedInputsUnchanged": true
},
{
"name": "volume-momentum-80-candidate",
"receiptSha256": "1cbbccd9678bc8ea2754f2ffa678f3d43feb659a58f86393c4c54961daa5a8d0",
"wallSeconds": 18.627057212870568,
"engineSeconds": 11.013,
"dataSeconds": 5.2,
"validationSeconds": 0.005,
"resultSeconds": 2.267,
"maxRssKiB": 7152664,
"fills": 51300,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 234267,
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
"sections": {
"accountEvents": {
"rowCount": 51696,
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
},
"fillEvents": {
"rowCount": 51300,
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
},
"holdingSnapshots": {
"rowCount": 78078,
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
},
"orderEvents": {
"rowCount": 51783,
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
},
"riskAudits": {
"rowCount": 385,
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
}
}
},
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
"verifiedFactBlocks": 309,
"sharedInputsUnchanged": true
}
],
"sharedInputFiles": 9257,
"sharedInputBytes": 12596608049,
"sharedInputInventorySha256": "1a4818aaab906e77b750e28601d3d405ad9e14e0553f7937cc60b68be0c9b71d",
"verifiedFactBlocks": 3506,
"status": "candidate-not-deployed",
"sourceCommit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
"engineCommit": "d2aa16a2f0064297d0d8c931060646d66422e9d4",
"serviceCommit": "4e23c7558d8301ba697543c39d5604289bb82c53",
"controlRunnerSha256": "b90886b80634c7565ca215fbe1f9ed0cbb5a6bd967373a9b1f6753be5164737d",
"candidateRunnerSha256": "1bda2d3acc016ca5addbb12e33cfcc31a23ece562f1d7d1ff8a825fbc83873fb",
"candidateApiSha256": "30ac3b50996e1769c1d93bd5d302a23c4af7ebe773d3e8110ee278c44aeb9501",
"bounds": [
"All twelve are new runner processes and private result artifacts using the same verified shared input files.",
"Input hashing is outside the elapsed benchmark timer; no GDB samples are in these measurements.",
"The first control had 12.244s Source validation and a slower preparation phase. Its entire latency difference is not candidate speedup.",
"The full input set is identical across the twelve runs, not only a global cache hit counter.",
"No Source/trading service was changed and no paused research/signal task resumed.",
"The independently recorded intraday-clock counterexample remains open. These day-level replays do not close it."
]
}
@@ -0,0 +1,648 @@
{
"schema": "fidc.series-column-storage-acceptance/v1",
"rows": [
{
"name": "control-1",
"receiptSha256": "fa8f2265f1d3ed2cdeef02f62840fec00a266ace3cf9399cb81d33110f3b115c",
"wallSeconds": 23.878584733000025,
"engineSeconds": 6.612,
"dataSeconds": 5.213,
"datasetConstructSeconds": 1.901,
"loopSeconds": 1.734,
"validationSeconds": 10.871,
"resultSeconds": 1.007,
"maxRssKiB": 7137676,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "candidate-1",
"receiptSha256": "edd7e575013932ae58603e9fa810626573e1b0eaa00d01426d736cb4740aaf86",
"wallSeconds": 24.12557172914967,
"engineSeconds": 6.674,
"dataSeconds": 4.818,
"datasetConstructSeconds": 1.589,
"loopSeconds": 1.646,
"validationSeconds": 11.47,
"resultSeconds": 1.011,
"maxRssKiB": 6463660,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "rotation-control-2",
"receiptSha256": "6a5b37c2881c7a4177ce69cef2440c3dd7a0846c6ca9a4811c68f173b16f7183",
"wallSeconds": 18.18023332185112,
"engineSeconds": 9.509,
"dataSeconds": 7.272,
"datasetConstructSeconds": 2.824,
"loopSeconds": 2.822,
"validationSeconds": 0.005,
"resultSeconds": 1.206,
"maxRssKiB": 7138628,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "rotation-candidate-2",
"receiptSha256": "f50ca6af6496213d558af646862e5a86334077bb9080e4e7c6c9602a0cabd789",
"wallSeconds": 15.078223099000752,
"engineSeconds": 8.171,
"dataSeconds": 5.543,
"datasetConstructSeconds": 2.094,
"loopSeconds": 2.066,
"validationSeconds": 0.004,
"resultSeconds": 1.177,
"maxRssKiB": 6454624,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "candidate-3",
"receiptSha256": "c8bce9a8eeda769f00e72118c9ea955d323fdad902dc271590d6e377b5a99196",
"wallSeconds": 13.124768079956993,
"engineSeconds": 6.649,
"dataSeconds": 5.103,
"datasetConstructSeconds": 1.853,
"loopSeconds": 1.676,
"validationSeconds": 0.208,
"resultSeconds": 1.017,
"maxRssKiB": 6457728,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "control-3",
"receiptSha256": "1f999a89aadab1baf4a70cda96a314ab237302722cdce895bb5fe26dea4306ec",
"wallSeconds": 12.725315875839442,
"engineSeconds": 6.583,
"dataSeconds": 4.955,
"datasetConstructSeconds": 1.713,
"loopSeconds": 1.675,
"validationSeconds": 0.004,
"resultSeconds": 1.013,
"maxRssKiB": 7140772,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "trend-40-control",
"receiptSha256": "55b1976d15ba531477ff92f07f953a0a8a9b7cab96f01fb0ce5546f9aeda7406",
"wallSeconds": 14.779385674046353,
"engineSeconds": 7.867,
"dataSeconds": 5.418,
"datasetConstructSeconds": 1.921,
"loopSeconds": 1.952,
"validationSeconds": 0.005,
"resultSeconds": 1.35,
"maxRssKiB": 7157844,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 128192,
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
"sections": {
"accountEvents": {
"rowCount": 29968,
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
},
"fillEvents": {
"rowCount": 29776,
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
},
"holdingSnapshots": {
"rowCount": 37367,
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
},
"orderEvents": {
"rowCount": 29932,
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
},
"riskAudits": {
"rowCount": 124,
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
}
}
},
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
"verifiedFactBlocks": 293,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "trend-40-candidate",
"receiptSha256": "86a3f452168d059f034e91f7317890af49d9763b75745dd3d39a948b00c1d72d",
"wallSeconds": 15.011793529149145,
"engineSeconds": 8.011,
"dataSeconds": 5.168,
"datasetConstructSeconds": 1.851,
"loopSeconds": 1.739,
"validationSeconds": 0.376,
"resultSeconds": 1.315,
"maxRssKiB": 6470768,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 128192,
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
"sections": {
"accountEvents": {
"rowCount": 29968,
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
},
"fillEvents": {
"rowCount": 29776,
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
},
"holdingSnapshots": {
"rowCount": 37367,
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
},
"orderEvents": {
"rowCount": 29932,
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
},
"riskAudits": {
"rowCount": 124,
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
}
}
},
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
"verifiedFactBlocks": 293,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "pullback-40-control",
"receiptSha256": "f5e6c9cb90db0dc7095e388b4ee939d4c6609824f3c065e0a08144a80f3d9395",
"wallSeconds": 14.010676869889721,
"engineSeconds": 7.298,
"dataSeconds": 5.172,
"datasetConstructSeconds": 1.893,
"loopSeconds": 1.7,
"validationSeconds": 0.005,
"resultSeconds": 1.388,
"maxRssKiB": 7167408,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 135630,
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
"sections": {
"accountEvents": {
"rowCount": 32010,
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
},
"fillEvents": {
"rowCount": 31862,
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
},
"holdingSnapshots": {
"rowCount": 38679,
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
},
"orderEvents": {
"rowCount": 31966,
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
},
"riskAudits": {
"rowCount": 88,
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
}
}
},
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
"verifiedFactBlocks": 281,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "pullback-40-candidate",
"receiptSha256": "f8caf67a6010d882a064678cf5c57f48c53c85b7c82dde708c029ee52e9e56b2",
"wallSeconds": 13.87669027899392,
"engineSeconds": 7.219,
"dataSeconds": 5.117,
"datasetConstructSeconds": 1.837,
"loopSeconds": 1.691,
"validationSeconds": 0.005,
"resultSeconds": 1.362,
"maxRssKiB": 6478492,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 135630,
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
"sections": {
"accountEvents": {
"rowCount": 32010,
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
},
"fillEvents": {
"rowCount": 31862,
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
},
"holdingSnapshots": {
"rowCount": 38679,
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
},
"orderEvents": {
"rowCount": 31966,
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
},
"riskAudits": {
"rowCount": 88,
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
}
}
},
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
"verifiedFactBlocks": 281,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "volume-momentum-80-control",
"receiptSha256": "0f589d11fec4a37f80635446fa445b7c7a9ae58e202e5b0c2533aa257fd94c81",
"wallSeconds": 18.577259425073862,
"engineSeconds": 10.96,
"dataSeconds": 5.163,
"datasetConstructSeconds": 1.891,
"loopSeconds": 1.698,
"validationSeconds": 0.004,
"resultSeconds": 2.282,
"maxRssKiB": 7210220,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 234267,
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
"sections": {
"accountEvents": {
"rowCount": 51696,
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
},
"fillEvents": {
"rowCount": 51300,
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
},
"holdingSnapshots": {
"rowCount": 78078,
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
},
"orderEvents": {
"rowCount": 51783,
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
},
"riskAudits": {
"rowCount": 385,
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
}
}
},
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
"verifiedFactBlocks": 309,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "volume-momentum-80-candidate",
"receiptSha256": "5ae74a1b311479a39c9863c2fba487951631353f9ae2a523d919f2d0a8f592e7",
"wallSeconds": 18.476340716006234,
"engineSeconds": 10.989,
"dataSeconds": 5.051,
"datasetConstructSeconds": 1.844,
"loopSeconds": 1.645,
"validationSeconds": 0.005,
"resultSeconds": 2.284,
"maxRssKiB": 6527236,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 234267,
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
"sections": {
"accountEvents": {
"rowCount": 51696,
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
},
"fillEvents": {
"rowCount": 51300,
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
},
"holdingSnapshots": {
"rowCount": 78078,
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
},
"orderEvents": {
"rowCount": 51783,
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
},
"riskAudits": {
"rowCount": 385,
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
}
}
},
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
"verifiedFactBlocks": 309,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
}
],
"sharedInputFiles": 9257,
"sharedInputBytes": 12596608049,
"sharedInputInventorySha256": "1a4818aaab906e77b750e28601d3d405ad9e14e0553f7937cc60b68be0c9b71d",
"verifiedFactBlocks": 3506,
"status": "candidate-not-deployed",
"scope": "exact in-memory column reuse; separate DayOpen correctness fix included in both control and candidate",
"controlRunnerSha256": "8859459f54389f12af1ab7d4e36802c01aff63fb10fbb679243ccdd54d013e2d",
"candidateRunnerSha256": "40bcf65c1977dbd93ab8bc80e3ff04d0db5e27b61fce1afdce99cf1b5e58eb43",
"candidateApiSha256": "c54be3a8196c32051520c709f793bcb974d869467bb12700d846efaad8c2180e",
"engineCommit": "996b909608589fb1987f33c0cfb4c62099f69617",
"serviceCommit": "443ed421c2c9c854a01fab69ce58957690504570",
"boundaries": [
"The DayOpen prefix correction is present in both storage A/B binaries.",
"No file format, cache schema, input values or execution policy changed for the storage comparison.",
"Original shared inputs were hashed and remained unchanged; results were recalculated into private artifacts.",
"The last pair ran candidate before control. It did not establish a general latency improvement.",
"Source remains frozen and paused research/signal tasks were not resumed.",
"The independent same-day intraday clock counterexample remains unresolved."
]
}
@@ -0,0 +1,40 @@
{
"source": "/srv/fidc/programs/runtime-snapshots/order-recovery-20260915-VjcwWa",
"build": "/srv/fidc/canonical/build/order-recovery-20260915-VjcwWa",
"commits": {
"fidc-backtest-engine": "695fdee4b8ba4b456313f06415c128a828b43f56",
"fidc-backtest-service": "234d85bdb8ed12b552ba8e9eff47fddc5e683acc",
"fidc-trading-platform": "d7d492541e60eb16e58dbff74b664b8ac85f6ccf"
},
"source_unchanged": true,
"results": [
{
"repository": "fidc-backtest-engine",
"exit_code": 0,
"elapsed_seconds": 56.009,
"passed": 926,
"failed": 0,
"ignored": 9,
"log_sha256": "1e966ddca61fdf592a3333df6c459688988f248e2503d45550634cf1184a5921"
},
{
"repository": "fidc-trading-platform",
"exit_code": 0,
"elapsed_seconds": 114.012,
"passed": 625,
"failed": 0,
"ignored": 63,
"log_sha256": "571a20700bd185304e3e13dbe7cd2d2dcf38f8d7e9d177bddcbf01cee68c0f3b"
},
{
"repository": "fidc-backtest-service",
"exit_code": 0,
"elapsed_seconds": 146.014,
"passed": 594,
"failed": 0,
"ignored": 16,
"log_sha256": "342dad04bade99f034da22cf3dcaadb40854ece7e1b57cfbeeb6240fc1ee8fee"
}
],
"available_bytes": 73738203136
}
@@ -0,0 +1,75 @@
{
"date": "2026-09-15",
"baseline_engine": "4c6147e2ee9e76b5d3aaa8b9c3a70a67777b1f98",
"candidate_engine": "695fdee4b8ba4b456313f06415c128a828b43f56",
"reproduced": {
"accepted_simulator_orders_before_error": 2,
"accepted_simulator_orders_after_error_before_fix": 0,
"first_order_prior_filled_quantity": 100,
"error": "historical_slippage_calibration_missing"
},
"verified": {
"original_order_identity_and_cumulative_fills_retained": true,
"retry_after_data_repair_matches_clean_call": true,
"prior_success_not_rolled_back": true,
"etf_targets_and_progress_retained": true,
"quote_consumption_and_commission_restored": true,
"callback_unwind_restores_unpublished_state_and_context": true,
"untouched_lots_not_copied": true,
"position_order_and_flat_rows_preserved": true
},
"local_tests": {
"core": {
"passed": 926,
"ignored": 9
},
"trading": {
"passed": 625,
"ignored": 63
},
"runner": {
"passed": 463,
"ignored": 9
},
"api": {
"passed": 129,
"ignored": 7
}
},
"new_test_functions": 7,
"microprofile": {
"mode": "unoptimized isolated simulator only",
"securities": 30,
"initial_lots_per_security": 20,
"calls": 500,
"full_checkpoint_us": [
27608,
40229,
35652,
11085
],
"scoped_checkpoint_us": [
22132,
26813,
24778,
11539
],
"protected_order": [
false,
true,
true,
false
],
"successful_outcomes_identical": true,
"production_throughput_conclusion": false
},
"limits": [
"not real GT/QMT rollback",
"not automatic retry",
"failed caller must stop or retry its explicit request",
"external hook side effects, OOM and power failure not covered",
"Source runtime freeze unchanged"
],
"production_runtime_modified": false,
"production_order_or_cancel_requests": 0
}
@@ -0,0 +1,53 @@
{
"observed_at": "2026-09-14T17:38:22.023395+00:00",
"status": "tested_not_deployed",
"runtime_services": [
{
"service": "omniquant-highmem177.service",
"pid": 987464,
"state": "active"
},
{
"service": "fidc-backtest-service-highmem177.service",
"pid": 4108679,
"state": "active",
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
},
{
"service": "fidc-paper-trading-highmem177.service",
"pid": 3765829,
"state": "active",
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
},
{
"service": "fidc-live-trading-highmem177.service",
"pid": 3765783,
"state": "active",
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
},
{
"service": "fidc-strategy-runtime-highmem177.service",
"pid": 3765705,
"state": "active",
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
},
{
"service": "fidc-trading-control-highmem177.service",
"pid": 3765532,
"state": "active",
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
}
],
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
"source_runtime_tracked_clean": true,
"engine_development_checkout": "695fdee4b8ba4b456313f06415c128a828b43f56",
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
"immutable_source_file_count": 994,
"source_snapshot_manifest_sha256": "bcefde438955ff4eda51494e96913a23d5e628375d45f8f09a498020224a7bb6",
"test_scope": "fidc-order-recovery-check-20260915-VjcwWa.scope",
"test_cpu_quota_percent": 400,
"test_memory_max_gib": 12,
"source_runtime_modified": false,
"production_order_or_cancel_requests_by_this_verification": 0,
"production_task_configuration_writes_by_this_verification": 0
}
@@ -0,0 +1,40 @@
{
"source": "/srv/fidc/programs/runtime-snapshots/successor-20260914-Sx15GO",
"build": "/srv/fidc/canonical/build/successor-20260914-Sx15GO",
"commits": {
"fidc-backtest-engine": "59a0c95aaec9c14ddd7384bb451d6d90eb3abec7",
"fidc-backtest-service": "234d85bdb8ed12b552ba8e9eff47fddc5e683acc",
"fidc-trading-platform": "d7d492541e60eb16e58dbff74b664b8ac85f6ccf"
},
"source_unchanged": true,
"results": [
{
"repository": "fidc-backtest-engine",
"exit_code": 0,
"elapsed_seconds": 58.008,
"passed": 907,
"failed": 0,
"ignored": 9,
"log_sha256": "9fa0e1cfcc55722beaacf22e743f4a4e097df8c967894b13b4d7dfd7e18540bc"
},
{
"repository": "fidc-trading-platform",
"exit_code": 0,
"elapsed_seconds": 88.009,
"passed": 625,
"failed": 0,
"ignored": 63,
"log_sha256": "50c659d618fccf51ce33e15a879d086934efb4a537f82dafb5f4c133d46eecce"
},
{
"repository": "fidc-backtest-service",
"exit_code": 0,
"elapsed_seconds": 110.011,
"passed": 594,
"failed": 0,
"ignored": 16,
"log_sha256": "7a2676f29c4d5867b6df64779d356c33217004ed0ddad73af75255eb0143d57a"
}
],
"available_bytes": 83883585536
}
@@ -0,0 +1,46 @@
{
"observed_date": "2026-09-14",
"baseline_engine": "d4e7cdd5b53e97666afb40c512223def0401539c",
"candidate_engine": "59a0c95aaec9c14ddd7384bb451d6d90eb3abec7",
"scope": "isolated core regression; not Source or broker acceptance",
"reproduced": [
{
"test": "successor_without_frozen_instrument_metadata_is_not_an_implicit_new_security",
"before": "returned success and created 300 shares of an instrument absent from the frozen metadata",
"after": "successor_instrument_missing, original ledger and notes unchanged"
},
{
"test": "invalid_successor_terms_cannot_be_replaced_with_one_share_or_zero_cash",
"before": "missing ratio accepted as 1.0",
"after": "all 13 malformed term cases rejected without mutation"
},
{
"test": "a_late_corporate_batch_failure_keeps_prior_cash_positions_and_notes",
"before": "an overflowing cash leg failed after deleting the old position and retaining a successor plus a cash receivable",
"after": "whole batch rejected, including original target quantity and weight state, notes unchanged"
}
],
"local_tests": {
"core": {
"passed": 907,
"ignored": 9
},
"trading": {
"passed": 625,
"ignored": 63
},
"runner": {
"passed": 463,
"ignored": 9
},
"api": {
"passed": 129,
"ignored": 7
}
},
"completed_engine_replay_cases": 12,
"engine_replay_cases": "paper/gt/qmt observation origins x timely/late x buy/sell; each paired with complete/missing successor metadata",
"live_order_or_cancel_requests": 0,
"source_runtime_modified": false,
"ui_modified": false
}

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