Compare commits

...

18 Commits

Author SHA1 Message Date
boris 4edc70c4c6 fix(backtest): advance resting limit orders on subsequent quote events 2026-09-12 05:05:39 +08:00
boris 9804851133 fix(stock-pool): reconcile completed entry quantities before repricing 2026-09-12 04:29:40 +08:00
boris 6ffa0346aa feat(stock-pool): unify target execution, durable intent state and ETF rules 2026-09-12 03:55:00 +08:00
boris 29eafc79e2 fix(backtest): 停牌证券保留预算且不参与退出权重分配 2026-09-11 22:50:27 +08:00
boris b6859a3360 统一 FIDC FiPanda 品牌标识与资源 2026-09-11 20:17:51 +08:00
boris a00dea58b2 test: assert causal TWAP entitlement and unfilled remainder 2026-09-11 17:11:53 +08:00
boris 2445dc925a fix: size TWAP slices from frozen clocks instead of future quotes 2026-09-11 17:04:25 +08:00
boris 1e8d38f2ee Merge remote-tracking branch 'origin/main' 2026-09-11 16:14:55 +08:00
boris d2071e4311 docs: compare execution frameworks and specify remaining causal capacity work 2026-09-11 16:11:57 +08:00
boris 0fba16342f fix: share explicit execution price selection and reject stale-price fallback 2026-09-11 15:44:38 +08:00
boris bd389de57f test: exercise full odd-lot liquidation against the remaining quote quota 2026-09-11 15:28:19 +08:00
boris bfbf898027 test: qualify execution clock regression time types 2026-09-11 15:19:18 +08:00
boris 2b6d031a55 fix: separate as-of quote time from execution clock and retain volume consumption 2026-09-11 15:12:42 +08:00
boris 6160a74d2a 合并主分支最新因子元数据合同 2026-09-11 15:01:29 +08:00
boris 4acecda79d feat: introduce causal capacity primitives and exact participation quotas 2026-09-11 15:00:18 +08:00
boris d847cb5c28 修正回撤指标的初始净值基线并补充回归测试 2026-09-11 15:00:02 +08:00
boris fa0b316a8b refactor: separate expression metadata and tests from numerical identity 2026-09-11 13:26:39 +08:00
boris 21786187c9 feat: publish typed native indicator parameter domains 2026-09-11 12:52:28 +08:00
40 changed files with 8322 additions and 400 deletions
Generated
+314 -8
View File
@@ -2,6 +2,17 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -25,6 +36,12 @@ dependencies = [
"libc",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -49,6 +66,18 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bitvec"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -58,12 +87,58 @@ dependencies = [
"generic-array",
]
[[package]]
name = "borsh"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
dependencies = [
"once_cell",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "bytes"
version = "1.12.1"
@@ -100,7 +175,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -239,11 +314,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
name = "fidc-core"
version = "0.1.0"
dependencies = [
"ahash",
"ahash 0.8.12",
"chrono",
"indexmap",
"rayon",
"rhai",
"rust_decimal",
"serde",
"serde_json",
"sha2",
@@ -275,6 +351,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futures-channel"
version = "0.3.34"
@@ -353,10 +435,19 @@ dependencies = [
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -596,7 +687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
@@ -729,6 +820,24 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -738,6 +847,26 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "quinn"
version = "0.11.11"
@@ -767,7 +896,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
@@ -815,6 +944,23 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.2"
@@ -823,7 +969,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
@@ -838,7 +1003,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -861,6 +1026,15 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.24"
@@ -905,7 +1079,7 @@ version = "1.23.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4e35aaaa439a5bda2f8d15251bc375e4edfac75f9865734644782c9701b5709"
dependencies = [
"ahash",
"ahash 0.8.12",
"bitflags",
"instant",
"no-std-compat",
@@ -942,6 +1116,51 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "rust_decimal"
version = "1.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.8",
"rkyv",
"serde",
"serde_json",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -995,6 +1214,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "serde"
version = "1.0.228"
@@ -1067,6 +1292,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "slab"
version = "0.4.12"
@@ -1124,6 +1355,17 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.117"
@@ -1179,6 +1421,12 @@ name = "ta-lib-dispatch"
version = "0.1.2"
source = "git+https://github.com/TA-Lib/ta-lib.git?rev=dd5a90259a3f9e04e2da9f38bf0719a841b40108#dd5a90259a3f9e04e2da9f38bf0719a841b40108"
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "thin-vec"
version = "0.2.16"
@@ -1263,6 +1511,36 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0db3bae107c9522f86d361697dee1d7386a2ddcf659d5aea5159819a21a3c4a7"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -1369,6 +1647,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "version_check"
version = "0.9.5"
@@ -1624,6 +1912,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
@@ -1636,6 +1933,15 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "yoke"
version = "0.8.3"
+1
View File
@@ -12,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"
}
]
}
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
authors.workspace = true
[dependencies]
rust_decimal.workspace = true
ahash.workspace = true
chrono.workspace = true
indexmap.workspace = true
+219 -138
View File
@@ -7,6 +7,8 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::cost::CostModel;
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
use crate::engine::BacktestError;
use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeObservation, VolumeObservationKind};
use crate::execution_schedule::TwapSchedule;
use crate::events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind,
@@ -21,6 +23,9 @@ use crate::strategy::{
TargetPortfolioOrderPricing,
};
#[path="broker_stock_pool.rs"]
mod stock_pool;
#[derive(Debug, Default)]
pub struct BrokerExecutionReport {
pub order_events: Vec<OrderEvent>,
@@ -255,6 +260,21 @@ pub enum MatchingType {
Twap,
}
pub(crate) fn intraday_reference_price(
quote: &IntradayExecutionQuote,
matching_type: MatchingType,
side: OrderSide,
) -> Option<f64> {
let price = match (matching_type, side) {
(MatchingType::MinuteBestOwn, OrderSide::Buy)
| (MatchingType::MinuteBestCounterparty, OrderSide::Sell) => quote.bid1,
(MatchingType::MinuteBestOwn, OrderSide::Sell)
| (MatchingType::MinuteBestCounterparty, OrderSide::Buy) => quote.ask1,
_ => quote.last_price,
};
(price.is_finite() && price > 0.0).then_some(price)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EquityExecutionPhase {
ContinuousAuction,
@@ -401,6 +421,7 @@ pub struct BrokerSimulator<C, R> {
execution_price_field: PriceField,
slippage_model: SlippageModel,
volume_percent: f64,
volume_rate: Result<ParticipationRate, CapacityError>,
volume_limit: bool,
inactive_limit: bool,
liquidity_limit: bool,
@@ -436,6 +457,7 @@ impl<C, R> BrokerSimulator<C, R> {
execution_price_field: PriceField::Open,
slippage_model: SlippageModel::None,
volume_percent: 0.25,
volume_rate: ParticipationRate::new(0.25),
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
@@ -475,6 +497,7 @@ impl<C, R> BrokerSimulator<C, R> {
execution_price_field,
slippage_model: SlippageModel::None,
volume_percent: 0.25,
volume_rate: ParticipationRate::new(0.25),
volume_limit: true,
inactive_limit: true,
liquidity_limit: true,
@@ -547,6 +570,7 @@ impl<C, R> BrokerSimulator<C, R> {
pub fn with_risk_config(mut self, config: FidcRiskControlConfig) -> Self {
self.volume_limit = config.trading_constraints.volume_limit_enabled;
self.volume_percent = config.trading_constraints.volume_percent;
self.volume_rate = ParticipationRate::new(self.volume_percent);
self.liquidity_limit = config.trading_constraints.liquidity_limit_enabled;
self.risk_config = config;
self
@@ -558,6 +582,7 @@ impl<C, R> BrokerSimulator<C, R> {
pub fn with_volume_percent(mut self, volume_percent: f64) -> Self {
self.volume_percent = volume_percent;
self.volume_rate = ParticipationRate::new(volume_percent);
self
}
@@ -1347,58 +1372,7 @@ where
if self.is_post_close_fixed_price(snapshot.date) {
return (snapshot.close.is_finite() && snapshot.close > 0.0).then_some(snapshot.close);
}
let raw_price = match matching_type {
MatchingType::MinuteBestOwn => match side {
OrderSide::Buy => {
if quote.bid1.is_finite() && quote.bid1 > 0.0 {
Some(quote.bid1)
} else {
quote
.last_price
.is_finite()
.then_some(quote.last_price)
.filter(|price| *price > 0.0)
}
}
OrderSide::Sell => {
if quote.ask1.is_finite() && quote.ask1 > 0.0 {
Some(quote.ask1)
} else {
quote
.last_price
.is_finite()
.then_some(quote.last_price)
.filter(|price| *price > 0.0)
}
}
},
MatchingType::MinuteBestCounterparty => match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
},
MatchingType::CurrentBarClose
| MatchingType::MinuteLast
| MatchingType::Vwap
| MatchingType::Twap => {
if quote.last_price.is_finite() && quote.last_price > 0.0 {
Some(quote.last_price)
} else {
match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
}
}
}
_ => match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
},
}?;
if raw_price.is_finite() && raw_price > 0.0 {
Some(raw_price)
} else {
None
}
intraday_reference_price(quote,matching_type,side)
}
fn quote_mark_price(&self, quote: &IntradayExecutionQuote, fallback: f64) -> f64 {
@@ -1482,6 +1456,9 @@ where
data: &DataSet,
decision: &StrategyDecision,
) -> Result<BrokerExecutionReport, BacktestError> {
if self.volume_limit {
self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?;
}
let mut session = std::mem::take(&mut *self.execution_session.borrow_mut());
session.activate(date);
let result = self.execute_with_daily_session(date, portfolio, data, decision, &mut session);
@@ -1783,6 +1760,9 @@ where
return result;
}
match intent {
OrderIntent::StockPool { contract } => self.process_stock_pool_contract(
date,portfolio,data,contract,intraday_turnover,execution_cursors,global_execution_cursor,commission_state,report,
),
OrderIntent::WithTimeInForce { .. } => unreachable!("wrapper handled above"),
OrderIntent::Shares {
symbol,
@@ -2233,13 +2213,9 @@ where
};
let latest = quotes
.iter()
.filter(|quote| {
quote.timestamp <= cursor
&& self
.select_quote_reference_price(snapshot, quote, side, matching_type)
.is_some()
})
.filter(|quote| quote.timestamp <= cursor)
.max_by_key(|quote| quote.timestamp)?;
self.select_quote_reference_price(snapshot,latest,side,matching_type)?;
if require_executable_liquidity
&& !self.quote_has_executable_liquidity(latest, side, matching_type)
{
@@ -3557,6 +3533,7 @@ where
date,
sell_execution_price,
current_qty.saturating_sub(provisional_target_qty),
data.instruments().get(&symbol),
);
}
constraints.push(TargetConstraint {
@@ -3612,6 +3589,7 @@ where
date,
constraint.buy_execution_price,
target_qty - constraint.current_qty,
data.instruments().get(&constraint.symbol),
);
}
if target_qty > 0 {
@@ -4195,14 +4173,14 @@ where
u32::MAX
}
fn estimated_sell_net_cash(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 {
fn estimated_sell_net_cash(&self, date: NaiveDate, price: f64, quantity: u32, instrument: Option<&Instrument>) -> f64 {
if quantity == 0 {
return 0.0;
}
let gross = Self::fixed_gross_amount(price, quantity);
let cost = self
.cost_model
.calculate(date, OrderSide::Sell, gross.to_f64());
.calculate_for_instrument(date, OrderSide::Sell, gross.to_f64(), instrument);
gross
.checked_sub(cost.fixed_total())
.expect("fixed-point sell proceeds underflow")
@@ -4312,14 +4290,14 @@ where
}
}
fn estimated_buy_cash_out(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 {
fn estimated_buy_cash_out(&self, date: NaiveDate, price: f64, quantity: u32, instrument: Option<&Instrument>) -> f64 {
if quantity == 0 {
return 0.0;
}
let gross = Self::fixed_gross_amount(price, quantity);
let cost = self
.cost_model
.calculate(date, OrderSide::Buy, gross.to_f64());
.calculate_for_instrument(date, OrderSide::Buy, gross.to_f64(), instrument);
gross
.checked_add(cost.fixed_total())
.expect("fixed-point buy cash overflow")
@@ -4358,7 +4336,7 @@ where
let minimum_execution_price =
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(minimum_buy_quantity))?;
Ok(Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity),
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity, data.instruments().get(symbol)),
portfolio.cash(),
))
}
@@ -4784,7 +4762,7 @@ where
let execution_price =
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(fillable_qty))?;
if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price)
self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price, data.instruments().get(symbol))
{
partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason));
(0, Vec::new(), None, Vec::new())
@@ -4805,6 +4783,7 @@ where
OrderSide::Sell,
execution_price,
limit_price,
data.instruments().get(symbol),
) {
Ok(execution_price) => (
fillable_qty,
@@ -4930,12 +4909,13 @@ where
let leg_cash_before = portfolio.cash();
let gross_money = Self::fixed_gross_amount(leg.price, leg.quantity);
let gross_amount = gross_money.to_f64();
let cost = self.cost_model.calculate_with_order_state(
let cost = self.cost_model.calculate_with_order_state_for_instrument(
date,
OrderSide::Sell,
gross_amount,
Some(order_id),
commission_state,
data.instruments().get(symbol),
);
let net_cash = gross_money
.checked_sub(cost.fixed_total())
@@ -5633,6 +5613,7 @@ where
price,
minimum_order_quantity,
order_step_size,
data.instruments().get(symbol),
);
let requested_qty = self.maybe_expand_periodic_value_buy_quantity(
date,
@@ -5749,6 +5730,7 @@ where
price,
minimum_order_quantity,
order_step_size,
data.instruments().get(symbol),
);
let requested_qty = self.maybe_expand_periodic_value_buy_quantity(
date,
@@ -5917,6 +5899,7 @@ where
price,
minimum_order_quantity,
order_step_size,
data.instruments().get(symbol),
);
let requested_qty = self.maybe_expand_periodic_value_buy_quantity(
date,
@@ -6512,7 +6495,7 @@ where
let execution_price =
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(constrained_qty))?;
if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price)
self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price, data.instruments().get(symbol))
{
partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason));
(0, Vec::new(), None, Vec::new())
@@ -6533,6 +6516,7 @@ where
OrderSide::Buy,
execution_price,
limit_price,
data.instruments().get(symbol),
) {
Err(reason) => {
partial_fill_reason =
@@ -6548,6 +6532,7 @@ where
constrained_qty,
self.minimum_order_quantity(data, symbol),
self.order_step_size(data, symbol),
data.instruments().get(symbol),
);
let mut blocked_by_final_price = false;
if filled_qty > 0 {
@@ -6562,6 +6547,7 @@ where
OrderSide::Buy,
execution_price,
limit_price,
data.instruments().get(symbol),
) {
Ok(price) => execution_price = price,
Err(reason) => {
@@ -6711,12 +6697,13 @@ where
let leg_cash_before = portfolio.cash();
let gross_money = Self::fixed_gross_amount(leg.price, leg.quantity);
let gross_amount = gross_money.to_f64();
let cost = self.cost_model.calculate_with_order_state(
let cost = self.cost_model.calculate_with_order_state_for_instrument(
date,
OrderSide::Buy,
gross_amount,
Some(order_id),
commission_state,
data.instruments().get(symbol),
);
let cash_out = gross_money
.checked_add(cost.fixed_total())
@@ -7113,6 +7100,7 @@ where
price: f64,
minimum_order_quantity: u32,
order_step_size: u32,
instrument: Option<&Instrument>,
) -> u32 {
if !value_budget.is_finite() || value_budget <= 0.0 || !price.is_finite() || price <= 0.0 {
return 0;
@@ -7123,7 +7111,7 @@ where
self.round_buy_quantity(raw_quantity, minimum_order_quantity, order_step_size);
while quantity >= minimum {
if Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, price, quantity),
self.estimated_buy_cash_out(date, price, quantity, instrument),
value_budget,
) {
return quantity;
@@ -7152,6 +7140,7 @@ where
fallback_price,
minimum_order_quantity,
order_step_size,
data.instruments().get(symbol),
);
for _ in 0..8 {
let execution_price = snapshot
@@ -7167,6 +7156,7 @@ where
execution_price,
minimum_order_quantity,
order_step_size,
data.instruments().get(symbol),
);
if resolved == quantity {
return Ok(quantity);
@@ -7182,7 +7172,7 @@ where
.filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price);
if Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, execution_price, quantity),
self.estimated_buy_cash_out(date, execution_price, quantity, data.instruments().get(symbol)),
value_budget,
) {
return Ok(quantity);
@@ -7216,6 +7206,7 @@ where
requested_qty: u32,
minimum_order_quantity: u32,
order_step_size: u32,
instrument: Option<&Instrument>,
) -> u32 {
let mut quantity =
self.round_buy_quantity(requested_qty, minimum_order_quantity, order_step_size);
@@ -7229,7 +7220,7 @@ where
);
continue;
}
let cost = self.cost_model.calculate(date, OrderSide::Buy, gross);
let cost = self.cost_model.calculate_for_instrument(date, OrderSide::Buy, gross, instrument);
let cash_out = FixedMoney::checked_sum_f64([gross, cost.total()])
.expect("buy cash must be finite fixed-point money")
.to_f64();
@@ -7311,37 +7302,16 @@ where
return Ok(max_fill);
}
if self.liquidity_limit && uses_intraday_quantity && !self.is_open_auction_matching() {
let top_level_liquidity = match side {
OrderSide::Buy => snapshot.liquidity_for_buy(),
OrderSide::Sell => snapshot.liquidity_for_sell(),
}
.min(u32::MAX as u64) as u32;
if top_level_liquidity == 0 {
return Err("no quote liquidity".to_string());
}
let top_level_limit = if side == OrderSide::Sell && allow_odd_lot_sell {
top_level_liquidity
} else {
self.round_buy_quantity(
top_level_liquidity,
minimum_order_quantity,
order_step_size,
)
};
max_fill = max_fill.min(top_level_limit);
}
if self.volume_limit {
let raw_limit = ((available_market_volume as f64) * self.volume_percent).floor() as i64
- consumed_turnover as i64;
if raw_limit <= 0 {
let raw_limit = self.volume_rate.map_err(|error| error.to_string())?
.remaining(available_market_volume, u64::from(consumed_turnover), requested_qty);
if raw_limit == 0 {
return Err(volume_limit_reason.to_string());
}
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
raw_limit as u32
raw_limit
} else {
self.round_buy_quantity(raw_limit as u32, minimum_order_quantity, order_step_size)
self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size)
};
if volume_limited == 0 {
return Err(volume_limit_reason.to_string());
@@ -7377,6 +7347,7 @@ where
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
execution_price: f64,
instrument: Option<&Instrument>,
) -> Option<&'static str> {
if !execution_price.is_finite() || execution_price <= 0.0 {
return Some("invalid execution price");
@@ -7384,6 +7355,7 @@ where
match side {
OrderSide::Buy
if self.risk_config.static_rules.reject_one_yuan_buy
&& !instrument.is_some_and(Instrument::is_exchange_traded_fund)
&& execution_price <= 1.0 =>
{
Some("one_yuan")
@@ -7421,9 +7393,10 @@ where
side: OrderSide,
execution_price: f64,
limit_price: Option<f64>,
instrument: Option<&Instrument>,
) -> Result<f64, &'static str> {
let adjusted = self.execution_price_with_limit_slippage(execution_price, limit_price);
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, adjusted) {
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, adjusted, instrument) {
Err(reason)
} else {
Ok(adjusted)
@@ -7511,6 +7484,7 @@ where
limit_price,
execution_ledger,
calibration.as_ref(),
data.instruments().get(symbol),
)? {
return Ok(Some(fill));
}
@@ -7606,6 +7580,7 @@ where
limit_price,
&IntradayExecutionLedger::default(),
None,
None,
)
.expect("test quote selection without historical calibration")
}
@@ -7630,6 +7605,7 @@ where
limit_price: Option<f64>,
execution_ledger: &IntradayExecutionLedger,
calibration: Option<&HistoricalSlippageCalibration>,
instrument: Option<&Instrument>,
) -> Result<Option<ExecutionFill>, BacktestError> {
if requested_qty == 0 {
return Ok(None);
@@ -7637,6 +7613,9 @@ where
let quote_quantity_limited =
self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor);
let twap_schedule = (matching_type == MatchingType::Twap)
.then(|| TwapSchedule::new(start_cursor, end_cursor, requested_qty))
.transpose()?;
let lot = round_lot.max(1);
let exact_time_order_quote = matching_type != MatchingType::MinuteLast
&& start_cursor.is_some()
@@ -7686,18 +7665,20 @@ where
let mut pending_volume_consumption = BTreeMap::<NaiveDateTime, u32>::new();
let mut liquidity_consumption = Vec::new();
for (quote_index, quote) in eligible_quotes.iter().enumerate() {
// Approximate platform-native market-order fills with the evolving L1 book after
// the decision time instead of trade VWAP. This keeps quantities/prices
// closer to the observed 10:18 execution logs.
for quote in &eligible_quotes {
let execution_at = if use_decision_time_quote {
start_cursor.expect("as-of orders have an execution clock").max(quote.timestamp)
} else {
quote.timestamp
};
let Some(raw_quote_price) =
self.select_quote_reference_price(snapshot, quote, side, matching_type)
else {
continue;
};
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price) {
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price, instrument) {
execution_block_reason.get_or_insert(reason);
execution_block_timestamp = Some(quote.timestamp);
execution_block_timestamp = Some(execution_at);
continue;
}
let mark_price = self.quote_mark_price(quote, raw_quote_price);
@@ -7743,12 +7724,6 @@ where
remaining_qty
};
if self.volume_limit {
let raw_limit = ((quote.volume_delta as f64) * self.volume_percent).floor() as u32;
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
raw_limit
} else {
self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size)
};
let consumed = execution_ledger
.volume_consumed(symbol, quote.timestamp)
.saturating_add(
@@ -7757,17 +7732,31 @@ where
.copied()
.unwrap_or(0),
);
available_qty = available_qty.min(volume_limited.saturating_sub(consumed));
let observation = VolumeObservation {
kind: VolumeObservationKind::TradeIncrement,
start: quote.timestamp,
end: quote.timestamp,
available_at: quote.timestamp,
shares: quote.volume_delta,
};
let raw_limit = observation.remaining(
execution_at,
self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?,
u64::from(consumed), remaining_qty,
).map_err(|error| BacktestError::Execution(error.to_string()))?;
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
raw_limit
} else {
self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size)
};
available_qty = available_qty.min(volume_limited);
}
if available_qty == 0 {
continue;
}
let mut take_qty = if matching_type == MatchingType::Twap {
let remaining_quotes = (eligible_quotes.len() - quote_index) as u32;
let scheduled_qty =
((remaining_qty as f64) / remaining_quotes.max(1) as f64).ceil() as u32;
remaining_qty.min(available_qty).min(scheduled_qty.max(1))
let mut take_qty = if let Some(schedule) = &twap_schedule {
remaining_qty.min(available_qty).min(schedule.due_quantity(execution_at, filled_qty))
} else {
remaining_qty.min(available_qty)
};
@@ -7781,10 +7770,10 @@ where
let mut quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price, instrument)
{
execution_block_reason.get_or_insert(reason);
execution_block_timestamp = Some(quote.timestamp);
execution_block_timestamp = Some(execution_at);
continue;
}
saw_non_blocked_execution_price = true;
@@ -7809,10 +7798,10 @@ where
quote_price =
self.execution_price_with_limit_slippage(quote_price, limit_price);
if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, side, quote_price)
self.execution_limit_rejection_reason(snapshot, side, quote_price, instrument)
{
execution_block_reason.get_or_insert(reason);
execution_block_timestamp = Some(quote.timestamp);
execution_block_timestamp = Some(execution_at);
take_qty = 0;
break;
}
@@ -7830,7 +7819,7 @@ where
}
let candidate_cost = self
.cost_model
.calculate(snapshot.date, OrderSide::Buy, candidate_gross)
.calculate_for_instrument(snapshot.date, OrderSide::Buy, candidate_gross, instrument)
.total();
let candidate_cash =
FixedMoney::checked_sum_f64([candidate_gross, candidate_cost])
@@ -7854,24 +7843,24 @@ where
quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price);
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price, instrument)
{
execution_block_reason.get_or_insert(reason);
execution_block_timestamp = Some(quote.timestamp);
execution_block_timestamp = Some(execution_at);
continue;
}
gross_amount += quote_price * take_qty as f64;
mark_amount += mark_price * take_qty as f64;
filled_qty += take_qty;
first_timestamp.get_or_insert(quote.timestamp);
last_timestamp = Some(quote.timestamp);
first_timestamp.get_or_insert(execution_at);
last_timestamp = Some(execution_at);
legs.push(ExecutionLeg {
price: quote_price,
mark_price,
quantity: take_qty,
execution_start_timestamp: Some(quote.timestamp),
execution_timestamp: Some(quote.timestamp),
execution_start_timestamp: Some(execution_at),
execution_timestamp: Some(execution_at),
});
if consume_depth {
let state = depth_state
@@ -7972,7 +7961,7 @@ where
quote.volume_delta > 0 && quote.bid1_volume == 0 && quote.ask1_volume == 0
}
fn matching_type_uses_intraday_quotes(&self) -> bool {
pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool {
matches!(
self.matching_type,
MatchingType::MinuteLast
@@ -8095,7 +8084,7 @@ mod tests {
use super::{
BrokerExecutionReport, BrokerSimulator, EquityExecutionPhase, IntradayExecutionLedger,
MatchingType, OpenOrder, RebalanceCashMode, SlippageModel,
ExecutionFill, MatchingType, OpenOrder, RebalanceCashMode, SlippageModel,
};
use crate::cost::ChinaAShareCostModel;
use crate::data::{
@@ -8793,19 +8782,19 @@ mod tests {
).unwrap();
assert_eq!(blocked.quantity, 0);
assert_eq!(blocked.unfilled_reason, Some("one_yuan"));
assert_eq!(slipped.execution_price_with_limit_slippage_or_rejection(&snapshot, OrderSide::Buy, 1.0, None), Err("one_yuan"));
assert_eq!(slipped.execution_price_with_limit_slippage_or_rejection(&snapshot, OrderSide::Buy, 1.0, None, None), Err("one_yuan"));
let limit_broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_slippage_model(SlippageModel::LimitPrice);
assert_eq!(limit_broker.execution_price_with_limit_slippage_or_rejection(
&snapshot, OrderSide::Buy, 1.2, Some(0.9)), Err("one_yuan"));
&snapshot, OrderSide::Buy, 1.2, Some(0.9), None), Err("one_yuan"));
let mut risk = FidcRiskControlConfig::default();
risk.static_rules.reject_one_yuan_buy = false;
let allowed = limit_broker.with_risk_config(risk);
assert_eq!(allowed.execution_price_with_limit_slippage_or_rejection(
&snapshot, OrderSide::Buy, 1.2, Some(0.9)), Ok(0.9));
assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Buy, f64::NAN), Some("invalid execution price"));
assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Sell, 0.9), None);
&snapshot, OrderSide::Buy, 1.2, Some(0.9), None), Ok(0.9));
assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Buy, f64::NAN, None), Some("invalid execution price"));
assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Sell, 0.9, None), None);
}
#[test]
@@ -10036,9 +10025,9 @@ mod tests {
let allocated_amount = 50_000.0;
assert_eq!(quantity, 4_900);
assert!(broker.estimated_buy_cash_out(date, execution_price, quantity) <= allocated_amount);
assert!(broker.estimated_buy_cash_out(date, execution_price, quantity, None) <= allocated_amount);
assert!(
broker.estimated_buy_cash_out(date, execution_price, quantity + 100) > allocated_amount
broker.estimated_buy_cash_out(date, execution_price, quantity + 100, None) > allocated_amount
);
}
@@ -11582,14 +11571,69 @@ mod tests {
assert_eq!(fill.quantity, 200);
assert_eq!(fill.legs.len(), 1);
assert_eq!(fill.legs[0].price, 10.8);
assert_eq!(fill.legs[0].execution_timestamp, Some(quote_timestamp));
assert!(fill.legs[0].execution_timestamp.unwrap() <= decision_time);
assert!(quote_timestamp < decision_time);
assert_eq!(fill.legs[0].execution_timestamp, Some(decision_time));
assert_eq!(
fill.next_cursor,
quote_timestamp + chrono::Duration::seconds(1)
decision_time + chrono::Duration::seconds(1)
);
}
#[test]
fn explicit_quote_price_models_do_not_replace_missing_prices_or_reuse_older_ones() {
let mut quote = limit_test_quote(10.2,10.1,10.3);
assert_eq!(super::intraday_reference_price(&quote,MatchingType::CurrentBarClose,OrderSide::Buy),Some(10.2));
assert_eq!(super::intraday_reference_price(&quote,MatchingType::MinuteBestOwn,OrderSide::Buy),Some(10.1));
assert_eq!(super::intraday_reference_price(&quote,MatchingType::MinuteBestCounterparty,OrderSide::Buy),Some(10.3));
let old = quote.clone();
quote.timestamp += chrono::Duration::seconds(1);
quote.ask1 = 0.0;
assert!(super::intraday_reference_price(&quote,MatchingType::MinuteBestCounterparty,OrderSide::Buy).is_none());
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(),ChinaEquityRuleHooks);
let snapshot = limit_test_snapshot();
let at = quote.timestamp;
assert!(broker.latest_known_quote_at_or_before(&[old,quote.clone()],Some(at),&snapshot,OrderSide::Buy,MatchingType::MinuteBestCounterparty,false).is_none());
quote.last_price = f64::NAN;
assert!(super::intraday_reference_price(&quote,MatchingType::MinuteLast,OrderSide::Sell).is_none());
}
#[test]
fn later_execution_clocks_do_not_replenish_the_same_observed_volume() {
let date = chrono::NaiveDate::from_ymd_opt(2025,1,2).unwrap();
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last,
).with_volume_limit(true).with_volume_percent(0.25).with_liquidity_limit(false);
let snapshot = limit_test_snapshot();
let mut quote = limit_test_quote(10.8,10.79,10.81);
quote.timestamp = date.and_hms_opt(9,32,58).unwrap();
quote.volume_delta = 1000;
let quotes = [quote];
let mut ledger = super::IntradayExecutionLedger::default();
let clock = date.and_hms_opt(9,33,0).unwrap();
let first = broker.select_execution_fill_with_ledger(
&snapshot.symbol,&snapshot,&quotes,OrderSide::Buy,MatchingType::MinuteLast,
Some(clock),Some(clock),200,100,100,100,false,None,None,None,&ledger,None,None,
).unwrap().unwrap();
assert_eq!(first.quantity,200);
assert_eq!(first.legs[0].execution_timestamp,Some(clock));
assert_eq!(first.liquidity_consumption[0].timestamp,quotes[0].timestamp);
ledger.apply_liquidity_consumption(&first.liquidity_consumption);
let later = clock + chrono::Duration::seconds(1);
let second = broker.select_execution_fill_with_ledger(
&snapshot.symbol,&snapshot,&quotes,OrderSide::Sell,MatchingType::MinuteLast,
Some(later),Some(later),50,100,100,100,true,None,None,None,&ledger,None,None,
).unwrap().unwrap();
assert_eq!(second.quantity,50);
assert_eq!(second.legs[0].execution_timestamp,Some(later));
ledger.apply_liquidity_consumption(&second.liquidity_consumption);
assert_eq!(ledger.volume_consumed(&snapshot.symbol,quotes[0].timestamp),250);
let third = broker.select_execution_fill_with_ledger(
&snapshot.symbol,&snapshot,&quotes,OrderSide::Buy,MatchingType::MinuteLast,
Some(later),Some(later),100,100,100,100,false,None,None,None,&ledger,None,None,
).unwrap();
assert!(third.is_none());
}
#[test]
fn value_buy_process_uses_latest_quote_before_decision_time() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
@@ -11794,6 +11838,43 @@ mod tests {
assert!((fill.price - 7.15428).abs() < 1e-6);
}
#[test]
fn twap_earlier_fills_do_not_depend_on_later_quote_liquidity_or_count() {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(true).with_volume_percent(0.25).with_liquidity_limit(false);
let snapshot = limit_test_snapshot();
let start = snapshot.date.and_hms_opt(10, 0, 0).unwrap();
let end = snapshot.date.and_hms_opt(10, 10, 0).unwrap();
let quote_at = |minute| {
let mut quote = limit_test_quote(10.0, 9.99, 10.01);
quote.timestamp = snapshot.date.and_hms_opt(10, minute, 0).unwrap();
quote.volume_delta = 10_000;
quote.ask1_volume = 0;
quote.bid1_volume = 0;
quote
};
let original = vec![quote_at(2), quote_at(5), quote_at(10)];
let mut changed = original.clone();
changed[2].volume_delta = 0;
let read = |quotes: &[IntradayExecutionQuote]| broker.select_execution_fill(
&snapshot, quotes, OrderSide::Buy, MatchingType::Twap, Some(start), Some(end),
1_000, 100, 100, 100, false, None, None, None,
).unwrap();
let original_fill = read(&original);
let changed_fill = read(&changed);
let fewer_fill = read(&original[..2]);
let prefix = |fill: &ExecutionFill| fill.legs.iter()
.filter(|leg| leg.execution_timestamp.unwrap() < end)
.map(|leg| (leg.execution_timestamp, leg.quantity, leg.price.to_bits()))
.collect::<Vec<_>>();
assert_eq!(prefix(&original_fill), prefix(&changed_fill));
assert_eq!(prefix(&original_fill), prefix(&fewer_fill));
assert_eq!(prefix(&original_fill).iter().map(|row| row.1).collect::<Vec<_>>(), vec![200, 300]);
assert_eq!(original_fill.quantity, 1_000);
assert_eq!(changed_fill.quantity, 500);
assert_eq!(fewer_fill.quantity, 500);
}
#[test]
fn instantaneous_twap_without_limits_does_not_cap_quote_quantity() {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
+480
View File
@@ -0,0 +1,480 @@
//! Executes one frozen pool intent against real broker-simulator state.
use super::*;
use crate::holding_policy::HoldingLifecycleEvidence;
use crate::stock_pool_execution as pool;
use rust_decimal::{Decimal, prelude::ToPrimitive};
fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
if !value.is_finite() {
return Err(BacktestError::Execution(format!(
"stock_pool_nonfinite_{label}"
)));
}
value
.to_string()
.parse()
.map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}")))
}
fn pool_positions(
portfolio: &PortfolioState,
date: NaiveDate,
) -> Result<Vec<pool::Position>, BacktestError> {
portfolio
.positions()
.values()
.filter(|p| p.quantity > 0)
.map(|p| {
Ok(pool::Position {
symbol: p.symbol.clone(),
quantity: Decimal::from(p.quantity),
closable_quantity: Decimal::from(p.sellable_qty(date)),
average_cost: decimal(p.average_cost, "position_cost")?,
})
})
.collect()
}
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
fn pool_quote_inputs(
&self,
date: NaiveDate,
data: &DataSet,
symbols: &BTreeSet<String>,
execution_clock: Option<NaiveDateTime>,
) -> Result<Vec<pool::MarketSnapshot>, BacktestError> {
symbols
.iter()
.map(|symbol| {
let snapshot = data.market(date, symbol).ok_or_else(|| {
BacktestError::Execution(format!(
"stock_pool_execution_snapshot_missing:{symbol}:{date}"
))
})?;
let instrument = data.instruments().get(symbol).ok_or_else(|| {
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
})?;
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if self
.matching_type_uses_intraday_quotes()
{
let time = self
.runtime_intraday_start_time
.get()
.or(self.intraday_execution_start_time)
.ok_or_else(|| {
BacktestError::Execution(
"stock_pool_intraday_execution_clock_required".into(),
)
})?;
let clock = execution_clock
.unwrap_or(date.and_time(time))
.max(date.and_time(time));
let quote = data
.execution_quotes_on(date, symbol)
.iter()
.rev()
.find(|quote| quote.timestamp <= clock)
.ok_or_else(|| {
BacktestError::Execution(format!(
"stock_pool_execution_quote_missing:{symbol}:{clock}"
))
})?;
if !quote.last_price.is_finite() || quote.last_price <= 0.0 {
return Err(BacktestError::Execution(format!(
"stock_pool_execution_quote_invalid:{symbol}:{clock}"
)));
}
let raw_buy = self
.select_quote_reference_price(
snapshot,
quote,
OrderSide::Buy,
self.matching_type,
)
.ok_or_else(|| {
BacktestError::Execution(format!(
"stock_pool_buy_reference_missing:{symbol}:{clock}"
))
})?;
let raw_sell = self
.select_quote_reference_price(
snapshot,
quote,
OrderSide::Sell,
self.matching_type,
)
.ok_or_else(|| {
BacktestError::Execution(format!(
"stock_pool_sell_reference_missing:{symbol}:{clock}"
))
})?;
let calibration = self.slippage_calibration(data, snapshot)?;
let buy = self.quote_execution_price(
snapshot,
OrderSide::Buy,
raw_buy,
None,
calibration.as_ref(),
)?;
let sell = self.quote_execution_price(
snapshot,
OrderSide::Sell,
raw_sell,
None,
calibration.as_ref(),
)?;
(
quote.last_price,
snapshot.prev_close,
Some(quote.volume_delta as f64),
Some(quote.amount_delta),
Some(quote.bid1),
Some(quote.ask1),
buy,
sell,
)
} else {
let price = snapshot.price(self.effective_execution_price_field(date));
if !price.is_finite() || price <= 0.0 {
return Err(BacktestError::Execution(format!(
"stock_pool_execution_price_missing:{symbol}:{date}"
)));
}
// A daily open does not reveal the session's volume/turnover.
let completed = self.effective_execution_price_field(date) == PriceField::Close;
(
price,
snapshot.prev_close,
completed.then_some(snapshot.volume as f64),
None,
Some(price),
Some(price),
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?,
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, None)?,
)
};
Ok(pool::MarketSnapshot {
symbol: symbol.clone(),
last_price: decimal(price, "price")?,
prev_close: Some(decimal(prev, "prev_close")?),
volume: volume.map(|v| decimal(v, "volume")).transpose()?,
turnover: amount.map(|v| decimal(v, "amount")).transpose()?,
bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?,
ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?,
is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")),
instrument_rules: Some(pool::StockPoolInstrumentRules {
price_tick: decimal(snapshot.price_tick, "price_tick")?,
quantity_step: instrument.order_step_size().into(),
minimum_buy_quantity: instrument.minimum_order_quantity().into(),
}),
buy_sizing_price: Some(decimal(buy_price, "buy_price")?),
sell_sizing_price: Some(decimal(sell_price, "sell_price")?),
})
})
.collect()
}
pub(super) fn process_stock_pool_contract(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
contract: &pool::FrozenStockPoolIntent,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut IntradayExecutionLedger,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
if contract.signal_date > date
|| contract.frozen_equity < Decimal::ZERO
|| contract.generation.is_empty()
|| contract.pool_id.trim().is_empty()
{
return Err(BacktestError::Execution(
"stock_pool_frozen_intent_invalid".into(),
));
}
if self.matching_type == MatchingType::NextBarOpen && contract.signal_date >= date {
return Err(BacktestError::Execution(
"stock_pool_next_open_requires_prior_signal".into(),
));
}
let mut selection = contract.selection.clone();
let mut members = contract.members.clone();
for symbol in &contract.selection.requested_symbols {
let instrument = data.instruments().get(symbol).ok_or_else(|| {
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
})?;
if portfolio.position(symbol).is_none()
&& let Some(reason) = instrument.dated_market_absence_reason(date)
{
selection.requested_symbols.retain(|v| v != symbol);
selection.normal_trading_symbols.retain(|v| v != symbol);
selection.risk_eligible_symbols.retain(|v| v != symbol);
selection.final_symbols.retain(|v| v != symbol);
members.retain(|v| &v.symbol != symbol);
report.diagnostics.push(format!(
"stock_pool_market_absence symbol={symbol} date={date} reason={reason}"
));
}
}
let mut scope = selection
.requested_symbols
.iter()
.cloned()
.collect::<BTreeSet<_>>();
scope.extend(portfolio.positions().keys().cloned());
let official_dates = data.calendar().iter().collect::<Vec<_>>();
let initial_positions = pool_positions(portfolio, date)?;
let state = portfolio
.stock_pool_execution_state(&contract.pool_id)
.observe(
contract.signal_date,
date,
&official_dates,
&members,
&initial_positions,
)
.map_err(BacktestError::Execution)?;
portfolio
.set_stock_pool_execution_state(&contract.pool_id, state)
.map_err(BacktestError::Execution)?;
if self.has_open_orders() {
report
.diagnostics
.push("stock_pool_waiting_for_active_orders no_new_intent=true".into());
return Ok(());
}
let mut constraints = contract.constraints.clone();
constraints.execution_date = Some(date);
constraints.frozen_positions.clear();
let mut quote_scope = scope.clone();
for symbol in &scope {
let paused = data.market(date, symbol).is_some_and(|row| row.paused)
|| data
.candidate(date, symbol)
.is_some_and(|row| row.is_paused);
if !paused {
continue;
}
quote_scope.remove(symbol);
if let Some(position) = portfolio
.position(symbol)
.filter(|position| position.quantity > 0)
{
constraints.frozen_positions.insert(
symbol.clone(),
pool::FrozenStockPoolPosition {
trade_date: date,
reason: "paused".into(),
valuation_price: decimal(position.last_price, "paused_holding_valuation")?,
},
);
} else {
selection
.normal_trading_symbols
.retain(|item| item != symbol);
selection
.risk_eligible_symbols
.retain(|item| item != symbol);
selection.final_symbols.retain(|item| item != symbol);
selection
.exclusion_reasons
.entry(symbol.clone())
.or_default()
.push("paused".into());
}
}
let before_positions = portfolio
.positions()
.keys()
.cloned()
.collect::<BTreeSet<_>>();
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
let quotes =
self.pool_quote_inputs(date, data, &quote_scope, *global_execution_cursor)?;
let positions = pool_positions(portfolio, date)?;
let execution_state = portfolio
.stock_pool_execution_state(&contract.pool_id)
.observe(
contract.signal_date,
date,
&official_dates,
&members,
&positions,
)
.map_err(BacktestError::Execution)?;
constraints.pending_entry_symbols = execution_state.pending_symbols();
constraints.prior_target_weights = execution_state.last_target_weights.clone();
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
let account = pool::AccountSnapshot {
total_equity: contract.frozen_equity,
cash: decimal(portfolio.cash(), "cash")?,
frozen_cash: Decimal::ZERO,
};
constraints.automatic_permissions.clear();
if contract.rule.automatic_trade_protection.enabled() {
for symbol in &scope {
let position = portfolio.position(symbol).filter(|p| p.quantity > 0);
let sold = self
.same_day_sold_symbols
.borrow()
.iter()
.rev()
.find(|(day, symbols)| **day <= date && symbols.contains(symbol))
.map(|(day, _)| *day);
let evidence = HoldingLifecycleEvidence {
has_position: position.is_some(),
opened_date: position.and_then(|p| p.opened_date()),
last_buy_date: position.and_then(|p| p.last_buy_date()),
last_sell_date: sold,
};
let permission = contract
.rule
.automatic_trade_protection
.evaluate(symbol, date, &evidence, data.calendar())
.map_err(BacktestError::Execution)?;
constraints
.automatic_permissions
.insert(symbol.clone(), permission);
}
}
if self
.risk_config
.static_rules
.forbid_same_day_rebuy_after_sell
{
constraints.same_day_sold_symbols.extend(
self.same_day_sold_symbols
.borrow()
.get(&date)
.into_iter()
.flatten()
.cloned(),
);
}
constraints.same_day_sold_symbols.extend(
before_positions
.iter()
.filter(|symbol| portfolio.position(symbol).is_none_or(|p| p.quantity == 0))
.cloned(),
);
let fee =
|symbol: &str, side: pool::OrderSide, gross: Decimal| -> Result<Decimal, String> {
let amount = gross
.to_f64()
.ok_or("stock_pool_cost_amount_out_of_range")?;
decimal(
self.cost_model
.calculate_for_instrument(
date,
if side == pool::OrderSide::Buy {
OrderSide::Buy
} else {
OrderSide::Sell
},
amount,
data.instruments().get(symbol),
)
.total(),
"fee",
)
.map_err(|e| e.to_string())
};
let plan = pool::build_stock_pool_target_plan_with_fee_model(
&selection,
&members,
&contract.rule,
&account,
&positions,
&quotes,
contract.invest_ratio_bps,
contract.reserve_cash,
&contract.out_of_pool_policy,
"full_rebalance",
&constraints,
&contract.generation,
Decimal::ZERO,
Decimal::ZERO,
Decimal::ZERO,
Some(&fee),
)
.map_err(BacktestError::Execution)?;
let updated = execution_state
.record_plan(contract.signal_date, &contract.generation, &plan)
.map_err(BacktestError::Execution)?;
portfolio
.set_stock_pool_execution_state(&contract.pool_id, updated)
.map_err(BacktestError::Execution)?;
report.diagnostics.push(format!("stock_pool_plan phase={side:?} generation={} requested_bps={} effective_bps={} budget={}",contract.generation,plan.requested_invest_ratio_bps,plan.effective_invest_ratio_bps,plan.budget));
let max_positions = constraints
.target_holding_count
.unwrap_or(selection.final_symbols.len());
for row in plan.rows {
if side == pool::OrderSide::Buy && row.side.is_none() {
report.diagnostics.push(format!(
"stock_pool_decision symbol={} status={} current={} target={} reason={}",
row.symbol,
row.status,
row.current_quantity,
row.target_quantity,
row.reason
));
}
if row.side != Some(side) {
continue;
}
if side == pool::OrderSide::Buy
&& portfolio
.position(&row.symbol)
.is_none_or(|p| p.quantity == 0)
&& Self::positive_position_count(portfolio) >= max_positions
{
report.diagnostics.push(format!(
"stock_pool_buy_deferred symbol={} reason=occupied_position_slots",
row.symbol
));
continue;
}
let target = row.target_quantity.to_i32().ok_or_else(|| {
BacktestError::Execution("stock_pool_target_quantity_out_of_range".into())
})?;
let reason = row.source_intent.as_deref().unwrap_or("stock_pool_target");
if let Some(price) = row.limit_price {
self.process_limit_target_shares(
date,
portfolio,
data,
&row.symbol,
target,
price.to_f64().ok_or_else(|| {
BacktestError::Execution("stock_pool_limit_price_out_of_range".into())
})?,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)?;
} else {
self.process_target_shares(
date,
portfolio,
data,
&row.symbol,
target,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)?;
}
}
}
Ok(())
}
}
+52
View File
@@ -5,6 +5,7 @@ use chrono::NaiveDate;
use crate::events::OrderSide;
use crate::fixed_point::{FixedChinaAShareCostModel, FixedMoney, FixedTradingCost};
use crate::risk_control::TradingConstraintConfig;
use crate::Instrument;
#[derive(Debug, Clone, Copy)]
pub struct TradingCost {
@@ -35,6 +36,17 @@ impl TradingCost {
pub trait CostModel {
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost;
fn calculate_for_instrument(&self, date: NaiveDate, side: OrderSide, gross_amount: f64, _instrument: Option<&Instrument>) -> TradingCost {
self.calculate(date, side, gross_amount)
}
fn calculate_with_order_state_for_instrument(
&self, date: NaiveDate, side: OrderSide, gross_amount: f64,
order_id: Option<u64>, commission_state: &mut BTreeMap<u64,f64>, _instrument: Option<&Instrument>,
) -> TradingCost {
self.calculate_with_order_state(date, side, gross_amount, order_id, commission_state)
}
fn calculate_with_order_state(
&self,
date: NaiveDate,
@@ -215,6 +227,27 @@ impl ChinaAShareCostModel {
}
impl CostModel for ChinaAShareCostModel {
fn calculate_for_instrument(&self, date: NaiveDate, side: OrderSide, gross_amount: f64, instrument: Option<&Instrument>) -> TradingCost {
let mut cost = self.calculate(date, side, gross_amount);
if instrument.is_some_and(Instrument::is_exchange_traded_fund) {
cost.stamp_tax = 0.0;
cost.transfer_fee = 0.0;
}
cost
}
fn calculate_with_order_state_for_instrument(
&self, date: NaiveDate, side: OrderSide, gross_amount: f64,
order_id: Option<u64>, commission_state: &mut BTreeMap<u64,f64>, instrument: Option<&Instrument>,
) -> TradingCost {
let mut cost = self.calculate_with_order_state(date, side, gross_amount, order_id, commission_state);
if instrument.is_some_and(Instrument::is_exchange_traded_fund) {
cost.stamp_tax = 0.0;
cost.transfer_fee = 0.0;
}
cost
}
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost {
if gross_amount <= 0.0 {
return TradingCost {
@@ -273,6 +306,25 @@ impl CostModel for ChinaAShareCostModel {
mod tests {
use super::*;
#[test]
fn fund_fees_use_admitted_instrument_type_and_share_the_order_commission_budget() {
let day=NaiveDate::from_ymd_opt(2026,9,11).unwrap();
let model=ChinaAShareCostModel::from_trading_constraints(TradingConstraintConfig{commission_rate:0.0003,minimum_commission:5.,transfer_fee_rate:0.00001,..Default::default()});
let mut instrument=Instrument{symbol:"510300.SH".into(),name:"fixture".into(),board:"ETF".into(),round_lot:100,listed_at:Some(day),delisted_at:None,status:"active".into()};
for side in [OrderSide::Buy,OrderSide::Sell] {
let cost=model.calculate_for_instrument(day,side,10_000.,Some(&instrument));
assert_eq!(cost.commission,5.);assert_eq!(cost.stamp_tax,0.);assert_eq!(cost.transfer_fee,0.);
let mut state=BTreeMap::new();
let one=model.calculate_with_order_state_for_instrument(day,side,1_000.,Some(1),&mut state,Some(&instrument));
let two=model.calculate_with_order_state_for_instrument(day,side,9_000.,Some(1),&mut state,Some(&instrument));
assert_eq!(one.total()+two.total(),cost.total());
}
instrument.board="SH".into();
let stock=model.calculate_for_instrument(day,OrderSide::Sell,10_000.,Some(&instrument));
assert_eq!(stock.stamp_tax,5.);assert_eq!(stock.transfer_fee,0.1);
assert_eq!(stock.total(),model.calculate(day,OrderSide::Sell,10_000.).total());
}
#[test]
fn default_quantizes_fees_to_micro_yuan() {
let model = ChinaAShareCostModel::default();
+98 -7
View File
@@ -2849,9 +2849,16 @@ where
"bar:post",
)?;
if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions) {
if self.execution_quote_loader.is_some() && !self.subscriptions.is_empty() {
let mut minute_symbols = self.subscriptions.clone();
if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions)
|| (self.broker.has_open_orders() && self.broker.matching_type_uses_intraday_quotes())
{
let unfiltered_minute_stream = self.subscriptions.is_empty();
let mut full_minute_symbols = self.subscriptions.clone();
if self.broker.matching_type_uses_intraday_quotes() {
full_minute_symbols.extend(self.broker.open_order_views().into_iter().map(|order| order.symbol));
}
if self.execution_quote_loader.is_some() && !full_minute_symbols.is_empty() {
let mut minute_symbols = full_minute_symbols.clone();
self.load_missing_execution_quotes(
execution_date,
None,
@@ -2862,11 +2869,11 @@ where
// Keep the iterator attached to an O(1) DataSet clone. This
// preserves the immutable quote snapshot for the day while
// allowing lazy quote loads and broker state updates on self.
let quote_data = self.data.clone();
let mut quote_data = self.data.clone();
let mut minute_quotes = quote_data
.execution_quotes_iter_on_date_for_symbols(
execution_date,
(!self.subscriptions.is_empty()).then_some(&self.subscriptions),
(!unfiltered_minute_stream).then_some(&full_minute_symbols),
)
.peekable();
let requires_minute_callbacks = self.strategy.requires_minute_callbacks();
@@ -2913,7 +2920,8 @@ where
minute_group.push(
minute_quotes
.next()
.expect("peeked minute quote must be available"),
.expect("peeked minute quote must be available")
.clone(),
);
}
let has_specific_schedule = next_schedule_timestamp == Some(minute_timestamp);
@@ -2985,7 +2993,10 @@ where
crate::strategy::StrategyDecision::default()
};
if requires_minute_callbacks {
for &quote in &minute_group {
for quote in &minute_group {
if !self.subscriptions.is_empty() && !self.subscriptions.contains(&quote.symbol) {
continue;
}
minute_decision.merge_from(self.strategy.on_minute(
&StrategyContext {
execution_date,
@@ -3098,6 +3109,28 @@ where
ProcessEventKind::PostMinute,
format!("minute:{minute_timestamp}:post"),
)?;
// A scheduled strategy need not subscribe to every
// minute to keep a DAY/GTC limit order alive. Fetch the
// resting symbols once, then resume the actual quote
// clock strictly after the event already processed.
let mut newly_pending = self.broker.open_order_views().into_iter()
.map(|order| order.symbol)
.filter(|symbol| !full_minute_symbols.contains(symbol))
.collect::<BTreeSet<_>>();
if !newly_pending.is_empty() && self.broker.matching_type_uses_intraday_quotes() {
full_minute_symbols.extend(newly_pending.iter().cloned());
if self.execution_quote_loader.is_some() {
self.load_missing_execution_quotes(execution_date, None, None, &mut newly_pending)?;
}
drop(minute_quotes);
quote_data = self.data.clone();
minute_quotes = quote_data.execution_quotes_iter_on_date_for_symbols(
execution_date, (!unfiltered_minute_stream).then_some(&full_minute_symbols),
).peekable();
while minute_quotes.peek().is_some_and(|quote| quote.timestamp <= minute_timestamp) {
minute_quotes.next();
}
}
}
drop(minute_group);
drop(minute_quotes);
@@ -4097,6 +4130,10 @@ fn execution_quote_symbols_for_decision(
for intent in &decision.order_intents {
match intent.unwrapped() {
OrderIntent::StockPool { contract } => {
symbols.extend(contract.selection.requested_symbols.iter().cloned());
symbols.extend(portfolio.positions().keys().cloned());
}
OrderIntent::Shares { symbol, .. }
| OrderIntent::LimitShares { symbol, .. }
| OrderIntent::Lots { symbol, .. }
@@ -5843,6 +5880,60 @@ mod tests {
);
}
#[test]
fn scheduled_day_limit_order_loads_later_quotes_without_strategy_minute_subscription() {
struct RestingLimit { quantity: i32 }
impl Strategy for RestingLimit {
fn name(&self) -> &str { "resting-limit" }
fn requires_minute_callbacks(&self) -> bool { false }
fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![ScheduleRule::daily("open", ScheduleStage::OnDay)
.with_time_rule(ScheduleTimeRule::physical_time(9, 30))]
}
fn on_scheduled(&mut self, _: &StrategyContext<'_>, _: &ScheduleRule) -> Result<StrategyDecision, crate::BacktestError> {
Ok(StrategyDecision { order_intents: vec![OrderIntent::LimitTargetShares {
symbol: SYMBOL.into(), target_quantity: self.quantity, limit_price: 10.0, reason: "resting-entry".into(),
}], ..StrategyDecision::default() })
}
}
for partial in [false, true] {
let date = d(2026, 6, 1);
let quote = |hour, minute, price| IntradayExecutionQuote {
date, symbol: SYMBOL.into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
last_price: price, bid1: price, ask1: price, bid1_volume: 10_000, ask1_volume: 10_000,
volume_delta: 10_000, amount_delta: price * 10_000.0, trading_phase: None,
};
let first = quote(9, 30, if partial { 9.8 } else { 10.2 });
let earlier = quote(9, 29, 9.0);
let later = quote(10, 0, 9.8);
let last = quote(10, 1, 9.8);
let mut data = dataset_from_market_and_candidates(vec![market(date, 10.2, 9.8)], vec![candidate(date)]);
data.add_execution_quotes(vec![first.clone()]);
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose)
.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(partial).with_volume_percent(0.01).with_liquidity_limit(false).with_inactive_limit(false);
let requests = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&requests);
let mut engine = BacktestEngine::new(data, RestingLimit { quantity: if partial { 300 } else { 100 } }, broker, BacktestConfig {
initial_cash: 100_000.0, benchmark_code: "000852.SH".into(), start_date: Some(date), end_date: Some(date),
decision_lag_trading_days: 0, execution_price_field: PriceField::Close,
}).with_execution_quote_loader(move |request| {
captured.lock().unwrap().push((request.start_time, request.end_time));
Ok(vec![earlier.clone(), first.clone(), later.clone(), last.clone()])
});
let result = engine.run().unwrap();
assert_eq!(result.fills.len(), if partial { 3 } else { 1 }, "resting DAY order must match later actual quotes: {:?}", result.order_events);
assert_eq!(result.fills[0].execution_timestamp, if partial { date.and_hms_opt(9, 30, 0) } else { date.and_hms_opt(10, 0, 0) });
assert_eq!(result.fills[0].price, 9.8);
assert_eq!(result.fills[0].quantity, 100);
assert_eq!(result.fills.iter().map(|fill| fill.quantity).sum::<u32>(), if partial { 300 } else { 100 });
assert!(result.fills.iter().all(|fill| fill.execution_timestamp >= date.and_hms_opt(9, 30, 0)));
assert_eq!(requests.lock().unwrap().as_slice(), &[(None, None)]);
assert!(!result.order_events.iter().any(|order| order.status == crate::OrderStatus::Expired));
}
}
#[test]
fn scheduled_event_detail_records_actual_time_only_for_timed_rules() {
let timed = ScheduleRule::daily("timed", ScheduleStage::OnDay)
+170
View File
@@ -0,0 +1,170 @@
//! Causal volume budgets. Session totals may audit fills, never size earlier orders.
use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VolumeCapacityMode {
#[default]
ExecutionObservation,
CompletedBar,
SessionCapacityAudit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum CapacityError {
#[error("execution capacity ratio must be finite and in (0, 1]")]
InvalidRatio,
#[error("execution capacity decimal cannot be represented exactly")]
InvalidDecimal,
#[error("execution capacity observation has invalid time bounds")]
InvalidWindow,
#[error("execution capacity is not visible: available={available_at}, execution={execution_at}")]
NotVisible { available_at: NaiveDateTime, execution_at: NaiveDateTime },
#[error("execution capacity observation belongs to another session")]
WrongSession,
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
MissingObservation,
}
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
#[derive(Debug, Clone, Copy)]
pub struct ParticipationRate {
numerator: u128,
denominator: u128,
}
impl ParticipationRate {
pub fn new(rate: f64) -> Result<Self, CapacityError> {
if !rate.is_finite() || rate <= 0.0 || rate > 1.0 {
return Err(CapacityError::InvalidRatio);
}
if rate < 1e-20 {
// Even u64::MAX shares at this rate cannot admit a single share.
return Ok(Self { numerator: 0, denominator: 1 });
}
if rate == 1.0 {
return Ok(Self { numerator: 1, denominator: 1 });
}
let text = rate.to_string();
let digits = text.strip_prefix("0.").ok_or(CapacityError::InvalidDecimal)?;
let digits = digits.trim_end_matches('0');
let numerator = digits.parse::<u128>().map_err(|_| CapacityError::InvalidDecimal)?;
let denominator = 10_u128.checked_pow(digits.len() as u32).ok_or(CapacityError::InvalidDecimal)?;
if numerator > u128::MAX / u128::from(u64::MAX) {
return Err(CapacityError::InvalidDecimal);
}
Ok(Self { numerator, denominator })
}
pub fn total_shares(self, market_shares: u64) -> u64 {
let total = u128::from(market_shares) * self.numerator / self.denominator;
u64::try_from(total).expect("participation rate cannot exceed the market shares")
}
pub fn remaining(self, market_shares: u64, consumed_shares: u64, requested: u32) -> u32 {
self.total_shares(market_shares).saturating_sub(consumed_shares).min(u64::from(requested)) as u32
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VolumeObservationKind {
TradeIncrement,
CompletedBar,
CumulativeSession,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct VolumeObservation {
pub kind: VolumeObservationKind,
pub start: NaiveDateTime,
pub end: NaiveDateTime,
pub available_at: NaiveDateTime,
pub shares: u64,
}
impl VolumeObservation {
pub fn visible_shares(self, execution_at: NaiveDateTime) -> Result<u64, CapacityError> {
if self.start > self.end || self.available_at < self.end {
return Err(CapacityError::InvalidWindow);
}
if self.available_at > execution_at {
return Err(CapacityError::NotVisible { available_at: self.available_at, execution_at });
}
if self.start.date() != self.end.date() || self.end.date() != execution_at.date() {
return Err(CapacityError::WrongSession);
}
Ok(self.shares)
}
pub fn remaining(self, execution_at: NaiveDateTime, rate: ParticipationRate, consumed: u64, requested: u32) -> Result<u32, CapacityError> {
Ok(rate.remaining(self.visible_shares(execution_at)?, consumed, requested))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionCapacityAudit {
pub date: NaiveDate,
pub symbol: String,
pub filled_shares: u64,
pub session_shares: u64,
pub allowed_shares: u64,
pub passed: bool,
}
impl SessionCapacityAudit {
pub fn new(date: NaiveDate, symbol: String, filled_shares: u64, session_shares: u64, rate: ParticipationRate) -> Self {
let allowed_shares = rate.total_shares(session_shares);
Self { date, symbol, filled_shares, session_shares, allowed_shares, passed: filled_shares <= allowed_shares }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decimal_participation_never_rounds_a_fractional_share_up_or_overflows() {
assert_eq!(ParticipationRate::new(0.58).unwrap().total_shares(50), 29);
assert_eq!(ParticipationRate::new(0.25).unwrap().total_shares(3), 0);
assert_eq!(ParticipationRate::new(0.5).unwrap().total_shares(3), 1);
assert_eq!(ParticipationRate::new(1.).unwrap().total_shares(u64::MAX), u64::MAX);
assert_eq!(ParticipationRate::new(0.25).unwrap().remaining(u64::MAX, 0, u32::MAX), u32::MAX);
assert_eq!(ParticipationRate::new(f64::MIN_POSITIVE).unwrap().total_shares(u64::MAX), 0);
for rate in [0., -1., f64::NAN, f64::INFINITY, 1.001] {
assert!(ParticipationRate::new(rate).is_err());
}
}
#[test]
fn completed_volume_cannot_be_used_for_an_earlier_open() {
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
let opening = day.and_hms_opt(9,30,0).unwrap();
let closing = day.and_hms_opt(15,0,0).unwrap();
let observation = VolumeObservation { kind:VolumeObservationKind::CompletedBar, start:opening, end:closing, available_at:closing, shares:10000 };
assert!(matches!(observation.visible_shares(opening), Err(CapacityError::NotVisible { .. })));
assert_eq!(observation.remaining(closing, ParticipationRate::new(0.25).unwrap(), 1000, 5000).unwrap(), 1500);
assert!(matches!(observation.visible_shares(closing+chrono::Duration::days(1)), Err(CapacityError::WrongSession)));
}
#[test]
fn delayed_publication_and_invalid_bounds_are_not_treated_as_zero_volume() {
let at = NaiveDate::from_ymd_opt(2025,1,2).unwrap().and_hms_opt(10,18,0).unwrap();
let observation = VolumeObservation { kind:VolumeObservationKind::TradeIncrement, start:at, end:at, available_at:at+chrono::Duration::seconds(1), shares:0 };
assert!(matches!(observation.visible_shares(at), Err(CapacityError::NotVisible { .. })));
assert_eq!(VolumeObservation { available_at:at-chrono::Duration::seconds(1), ..observation }.visible_shares(at), Err(CapacityError::InvalidWindow));
assert_eq!(VolumeObservation { available_at:at, ..observation }.visible_shares(at).unwrap(), 0);
}
#[test]
fn session_audit_changes_verdict_not_executed_quantity() {
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
let rate = ParticipationRate::new(0.25).unwrap();
let a = SessionCapacityAudit::new(day,"TEST".into(),1000,3000,rate);
let b = SessionCapacityAudit::new(day,"TEST".into(),1000,5000,rate);
assert!(!a.passed); assert!(b.passed);
assert_eq!(a.filled_shares,b.filled_shares);
}
}
@@ -0,0 +1,61 @@
use chrono::NaiveDateTime;
use crate::engine::BacktestError;
/// Cumulative TWAP entitlement depends on the frozen clock, not future quotes.
pub(crate) struct TwapSchedule {
start: NaiveDateTime,
end: NaiveDateTime,
quantity: u32,
}
impl TwapSchedule {
pub(crate) fn new(
start: Option<NaiveDateTime>,
end: Option<NaiveDateTime>,
quantity: u32,
) -> Result<Self, BacktestError> {
let (Some(start), Some(end)) = (start, end) else {
return Err(BacktestError::Execution("TWAP requires an explicit start and end clock".into()));
};
if end < start || start.date() != end.date() {
return Err(BacktestError::Execution("TWAP requires an ordered same-session clock window".into()));
}
Ok(Self { start, end, quantity })
}
pub(crate) fn due_quantity(&self, at: NaiveDateTime, filled: u32) -> u32 {
if at < self.start {
return 0;
}
let entitlement = if at >= self.end {
self.quantity
} else {
let elapsed = (at - self.start).num_microseconds().expect("same-day interval") as u128;
let duration = (self.end - self.start).num_microseconds().expect("same-day interval") as u128;
(u128::from(self.quantity) * elapsed / duration) as u32
};
entitlement.saturating_sub(filled)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, NaiveDate};
#[test]
fn clock_entitlements_are_exact_and_allow_backlog_without_future_quote_counts() {
let start = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(10, 0, 0).unwrap();
let end = start + Duration::minutes(10);
let schedule = TwapSchedule::new(Some(start), Some(end), 1_000).unwrap();
assert_eq!(schedule.due_quantity(start, 0), 0);
assert_eq!(schedule.due_quantity(start + Duration::minutes(2), 0), 200);
assert_eq!(schedule.due_quantity(start + Duration::minutes(5), 100), 400);
assert_eq!(schedule.due_quantity(end, 100), 900);
assert_eq!(schedule.due_quantity(end, 1_000), 0);
assert!(TwapSchedule::new(Some(start), None, 1_000).is_err());
assert!(TwapSchedule::new(Some(end), Some(start), 1_000).is_err());
assert_eq!(TwapSchedule::new(Some(start), Some(start), 1_000).unwrap().due_quantity(start, 0), 1_000);
}
}
@@ -0,0 +1,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);
}
+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)
}
+8
View File
@@ -6,6 +6,9 @@ pub mod daily_patterns;
pub mod pattern_context;
pub mod session_events;
pub mod factor_events;
pub mod execution_capacity;
mod execution_schedule;
mod factor_event_catalog;
pub mod factor_cross_section;
pub mod market_event_context;
pub mod engine;
@@ -26,6 +29,11 @@ pub mod rules;
pub mod scheduler;
pub mod strategy;
pub mod holding_policy;
pub mod stock_pool_candidates;
pub mod stock_pool_indicators;
pub mod stock_pool_execution;
pub mod stock_pool_index_policy;
pub mod stock_pool_state;
pub mod signal_contract;
pub mod strategy_ai;
pub mod universe;
+25 -1
View File
@@ -558,7 +558,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 +769,28 @@ fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
mod tests {
use super::*;
#[test]
fn drawdown_includes_initial_nav_without_adding_a_trading_day() {
let (drawdown, duration) = drawdown_stats(&[0.9, 0.99]);
assert!((drawdown + 0.1).abs() < 1e-12);
assert_eq!(duration, 2);
assert_eq!(drawdown_stats(&[1.0, 1.1, 1.1]), (0.0, 0));
assert_eq!(drawdown_stats(&[0.0]), (-1.0, 1));
assert_eq!(drawdown_stats(&[]), (0.0, 0));
}
#[test]
fn first_day_loss_is_preserved_in_shared_backtest_metrics() {
let curve = vec![
equity_point("2025-01-03", 99.16648349337, 98.81608059815, 100.0),
equity_point("2025-01-06", 99.68551588547, 98.65392198168, 98.81608059815),
];
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
assert!((metrics.max_drawdown + 0.0083351650663).abs() < 1e-12);
assert_eq!(metrics.total_trade_days, 2);
assert_eq!(metrics.max_drawdown_duration_days, 2);
}
fn equity_point(
date: &str,
total_equity: f64,
+174 -92
View File
@@ -7,7 +7,7 @@ use chrono::{Datelike, Duration, FixedOffset, NaiveDate, NaiveDateTime, NaiveTim
use sha2::{Digest, Sha256};
use rhai::{AST, Dynamic, Engine, ImmutableString, Map, Scope};
use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel};
use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel, intraday_reference_price};
use crate::cost::ChinaAShareCostModel;
#[cfg(test)]
use crate::data::EligibleUniverseSnapshot;
@@ -17,6 +17,7 @@ use crate::data::{
decision_market_cap_bn,
};
use crate::engine::BacktestError;
use crate::execution_capacity::{CapacityError, ParticipationRate};
use crate::events::{OrderSide, ProcessEvent, ProcessEventKind};
use crate::fixed_point::FixedMoney;
use crate::futures::{
@@ -28,6 +29,9 @@ use crate::numeric_expr_vm::{
};
use crate::portfolio::PortfolioState;
use crate::holding_policy::{AutomaticTradeProtection, AutomaticTradePermission, HoldingLifecycleEvidence};
#[path="platform_stock_pool.rs"]
mod stock_pool;
use crate::portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossState};
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler};
@@ -247,12 +251,16 @@ fn ordered_weight_bps_from_scales(
Ok(weights)
}
fn replenish_target_weight_bps(
pub(crate) fn replenish_target_weight_bps(
original_weights: &[(String, u32)],
candidate_symbols: &[String],
excluded_symbols: &BTreeSet<String>,
target_count: usize,
) -> Vec<(String, u32)> {
if target_count == 0 || original_weights.is_empty() {
return Vec::new();
}
let total_weight = original_weights.iter().map(|(_, weight)| *weight).sum::<u32>();
let original_by_symbol = original_weights.iter().cloned().collect::<BTreeMap<_, _>>();
let mut active = original_weights
.iter()
@@ -285,7 +293,7 @@ fn replenish_target_weight_bps(
})
.collect::<Vec<_>>();
let assigned = result.iter().map(|(_, weight)| *weight).sum::<u32>();
let missing = 10_000_u32.saturating_sub(assigned);
let missing = total_weight.saturating_sub(assigned);
if missing == 0 {
return result;
}
@@ -363,6 +371,7 @@ fn resolved_target_scales(
excluded_symbols: &BTreeSet<String>,
target_count: usize,
redistribute_after_exit: bool,
unavailable_symbols: &BTreeSet<String>,
) -> Result<Vec<(String, f64)>, BacktestError> {
if !redistribute_after_exit {
return Ok(replenish_target_scales(
@@ -373,15 +382,25 @@ fn resolved_target_scales(
));
}
let original_weights = ordered_weight_bps_from_scales(original_scales)?;
// 不可报价证券的原始预算及席位保留,不能将其预算重新归一到其他股票。
let reserved_slots = original_weights
.iter()
.filter(|(symbol, _)| unavailable_symbols.contains(symbol))
.count();
let available_weights = original_weights
.into_iter()
.filter(|(symbol, _)| !unavailable_symbols.contains(symbol))
.collect::<Vec<_>>();
let candidate_symbols = candidate_scales
.iter()
.filter(|(symbol, _)| !unavailable_symbols.contains(symbol))
.map(|(symbol, _)| symbol.clone())
.collect::<Vec<_>>();
Ok(replenish_target_weight_bps(
&original_weights,
&available_weights,
&candidate_symbols,
excluded_symbols,
target_count,
target_count.saturating_sub(reserved_slots),
)
.into_iter()
.map(|(symbol, weight_bps)| {
@@ -609,6 +628,7 @@ pub struct PlatformPositionTargetRule {
#[derive(Debug, Clone)]
pub struct PlatformExprStrategyConfig {
pub stock_pool:Option<crate::stock_pool_execution::StockPoolProgram>,
pub signal_book: Option<Arc<crate::signal_contract::ValidatedSignalBook>>,
pub strategy_name: String,
pub market: String,
@@ -697,6 +717,7 @@ impl PlatformExprStrategyConfig {
pub fn generic() -> Self {
Self {
signal_book: None,
stock_pool:None,
strategy_name: "platform-expression".to_string(),
market: "CN_A".to_string(),
benchmark_symbol: String::new(),
@@ -1369,6 +1390,7 @@ pub struct PlatformExprStrategy {
pattern_specs: RefCell<BTreeMap<String,String>>,
pattern_frame_at:RefCell<Option<NaiveDateTime>>,
config: PlatformExprStrategyConfig,
volume_rate: Result<ParticipationRate, CapacityError>,
engine: Engine,
rebalance_day_counter: usize,
last_rebalance_date: Option<NaiveDate>,
@@ -1776,6 +1798,7 @@ impl PlatformExprStrategy {
.clone()
.map(PlatformPortfolioDrawdownController::new);
Self {
volume_rate: ParticipationRate::new(config.risk_config.trading_constraints.volume_percent),
config,
engine,
protection_fill_count: 0,
@@ -2228,6 +2251,7 @@ impl PlatformExprStrategy {
| "benchmark_open"
| "has_dynamic_universe"
| "dynamic_universe_count"
| "pool_candidate_count"
| "has_subscriptions"
| "subscription_count"
| "subscription_guard_required"
@@ -3087,32 +3111,7 @@ impl PlatformExprStrategy {
quote: &crate::data::IntradayExecutionQuote,
side: OrderSide,
) -> Option<f64> {
let last =
|| (quote.last_price.is_finite() && quote.last_price > 0.0).then_some(quote.last_price);
match self.config.matching_type {
MatchingType::MinuteBestOwn => match side {
OrderSide::Buy => (quote.bid1.is_finite() && quote.bid1 > 0.0)
.then_some(quote.bid1)
.or_else(last),
OrderSide::Sell => (quote.ask1.is_finite() && quote.ask1 > 0.0)
.then_some(quote.ask1)
.or_else(last),
},
MatchingType::MinuteBestCounterparty => match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
},
MatchingType::MinuteLast | MatchingType::Vwap | MatchingType::Twap => {
last().or_else(|| match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
})
}
_ => match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
},
}
intraday_reference_price(quote,self.config.matching_type,side)
}
fn projected_execution_limit_rejection_reason(
@@ -3156,9 +3155,9 @@ impl PlatformExprStrategy {
allow_odd_lot_sell: bool,
current_fill_quantity: u32,
execution_state: &ProjectedExecutionState,
) -> Option<u32> {
) -> Result<Option<u32>, BacktestError> {
if requested_qty == 0 {
return Some(0);
return Ok(Some(0));
}
let constraints = self.config.risk_config.trading_constraints;
@@ -3183,7 +3182,7 @@ impl PlatformExprStrategy {
};
if top_level_liquidity == 0 {
if quote.is_some() {
return None;
return Ok(None);
}
} else {
let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
@@ -3196,7 +3195,7 @@ impl PlatformExprStrategy {
)
};
if liquidity_limited == 0 {
return None;
return Ok(None);
}
max_fill = max_fill.min(liquidity_limited);
}
@@ -3209,7 +3208,7 @@ impl PlatformExprStrategy {
None => market.volume,
};
if volume_basis == 0 {
return None;
return Ok(None);
}
let consumed_turnover = execution_state
.intraday_turnover
@@ -3217,23 +3216,23 @@ impl PlatformExprStrategy {
.copied()
.unwrap_or(0)
.saturating_add(current_fill_quantity);
let raw_limit = ((volume_basis as f64) * constraints.volume_percent).floor() as i64
- consumed_turnover as i64;
if raw_limit <= 0 {
return None;
let raw_limit = self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?
.remaining(volume_basis, u64::from(consumed_turnover), requested_qty);
if raw_limit == 0 {
return Ok(None);
}
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
raw_limit as u32
raw_limit
} else {
self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size)
self.round_lot_quantity(raw_limit, minimum_order_quantity, order_step_size)
};
if volume_limited == 0 {
return None;
return Ok(None);
}
max_fill = max_fill.min(volume_limited);
}
Some(max_fill)
Ok(Some(max_fill))
}
fn quote_lacks_level1_depth(quote: &crate::data::IntradayExecutionQuote) -> bool {
@@ -3333,7 +3332,7 @@ impl PlatformExprStrategy {
allow_odd_lot_sell,
filled_qty,
execution_state,
)
)?
.unwrap_or(0);
if available_qty == 0 {
break;
@@ -3407,7 +3406,7 @@ impl PlatformExprStrategy {
}
gross_amount += quote_price * take_qty as f64;
filled_qty += take_qty;
last_timestamp = Some(quote.timestamp);
last_timestamp = Some(start_cursor.max(quote.timestamp));
if filled_qty >= requested_qty {
break;
}
@@ -3483,7 +3482,7 @@ impl PlatformExprStrategy {
let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol);
let Some(fill) = self
let mut fill = self
.projected_select_execution_fill_at_time(
ctx,
date,
@@ -3498,22 +3497,18 @@ impl PlatformExprStrategy {
None,
execution_state,
execution_time,
)?
.or_else(|| {
if self.uses_intraday_execution_quotes()
&& !Self::defer_projection_execution_risk(ctx, date)
{
return None;
}
if !self.has_execution_quote_at_or_before_at_time(
)?;
if fill.is_none()
&& (!self.uses_intraday_execution_quotes() || Self::defer_projection_execution_risk(ctx, date))
&& !self.has_execution_quote_at_or_before_at_time(
ctx,
date,
symbol,
execution_state,
execution_time,
) && ctx.data.execution_quotes_on(date, symbol).is_empty()
{
let fallback_quantity = self.projected_market_fillable_quantity(
{
if let Some(fallback_quantity) = self.projected_market_fillable_quantity(
market,
None,
symbol,
@@ -3525,21 +3520,18 @@ impl PlatformExprStrategy {
sellable_qty >= current_qty,
0,
execution_state,
)?;
if fallback_quantity == 0 {
return None;
}
Some(ProjectedExecutionFill {
)?.filter(|quantity| *quantity > 0)
{
fill = Some(ProjectedExecutionFill {
price: self.projected_execution_price(market, OrderSide::Sell),
quantity: fallback_quantity,
next_cursor: date.and_time(
execution_time.unwrap_or_else(|| self.intraday_execution_start_time()),
) + Duration::seconds(1),
})
} else {
None
}
}) else { return Ok(None); };
});
}
}
let Some(fill) = fill else { return Ok(None); };
let gross_amount = fill.price * fill.quantity as f64;
let net_cash = self.sell_net_cash(date, gross_amount);
projected
@@ -4120,7 +4112,7 @@ impl PlatformExprStrategy {
}
let submitted_quantity = quantity;
let defer_projection_execution_risk = Self::defer_projection_execution_risk(ctx, date);
let fill = self
let mut fill = self
.projected_select_execution_fill(
ctx,
date,
@@ -4134,23 +4126,22 @@ impl PlatformExprStrategy {
Some(cash_limit),
gross_limit,
execution_state,
)?
.or_else(|| {
if !defer_projection_execution_risk
)?;
if fill.is_none()
&& !(!defer_projection_execution_risk
&& ctx.data.has_execution_quotes_on_date(date)
&& ctx.data.execution_quotes_on(date, symbol).is_empty()
{
None
} else if !self.has_execution_quote_at_or_before_at_time(
&& ctx.data.execution_quotes_on(date, symbol).is_empty())
&& !self.has_execution_quote_at_or_before_at_time(
ctx,
date,
symbol,
execution_state,
None,
) && ctx.data.execution_quotes_on(date, symbol).is_empty()
{
let fallback_quantity = self.projected_market_fillable_quantity(
ctx.data.market(date, symbol)?,
&& let Some(market) = ctx.data.market(date, symbol)
{
if let Some(fallback_quantity) = self.projected_market_fillable_quantity(
market,
None,
symbol,
OrderSide::Buy,
@@ -4161,20 +4152,16 @@ impl PlatformExprStrategy {
false,
0,
execution_state,
)?;
if fallback_quantity == 0 {
return None;
}
Some(ProjectedExecutionFill {
)?.filter(|quantity| *quantity > 0)
{
fill = Some(ProjectedExecutionFill {
price: sizing_price,
quantity: fallback_quantity,
next_cursor: date.and_time(self.intraday_execution_start_time())
+ Duration::seconds(1),
})
} else {
None
}
});
});
}
}
let Some(fill) = fill else {
return Ok(ProjectedOrderValueResult::submitted_without_fill(submitted_quantity));
};
@@ -5111,6 +5098,7 @@ impl PlatformExprStrategy {
ctx.dynamic_universe_count() as i64,
);
scope.push("has_subscriptions", ctx.has_subscriptions());
scope.push("pool_candidate_count", self.frozen_candidate_count(ctx) as i64);
scope.push("subscription_count", ctx.subscription_count() as i64);
scope.push(
"subscription_guard_required",
@@ -5283,6 +5271,7 @@ impl PlatformExprStrategy {
"has_dynamic_universe".into(),
Dynamic::from(ctx.has_dynamic_universe()),
);
day_factors.insert("pool_candidate_count".into(), Dynamic::from(self.frozen_candidate_count(ctx) as i64));
day_factors.insert(
"dynamic_universe_count".into(),
Dynamic::from(ctx.dynamic_universe_count() as i64),
@@ -6041,6 +6030,7 @@ impl PlatformExprStrategy {
}
"has_dynamic_universe" => boolean(ctx.has_dynamic_universe()),
"dynamic_universe_count" => integer(ctx.dynamic_universe_count() as i64),
"pool_candidate_count" => integer(self.frozen_candidate_count(ctx) as i64),
"has_subscriptions" => boolean(ctx.has_subscriptions()),
"subscription_count" => integer(ctx.subscription_count() as i64),
"subscription_guard_required" => boolean(self.config.subscription_guard_required),
@@ -8643,12 +8633,27 @@ impl PlatformExprStrategy {
Ok((low.min(high), low.max(high)))
}
fn frozen_candidate_count(&self, ctx: &StrategyContext<'_>) -> usize {
if !self.config.candidate_symbols_by_date.is_empty() {
self.config.candidate_symbols_by_date.get(&ctx.decision_date).map_or(0, BTreeSet::len)
} else if ctx.has_dynamic_universe() {
ctx.dynamic_universe_count()
} else if let Some(symbols) = &self.config.universe_include {
symbols.len()
} else {
ctx.data.daily_snapshot_view(ctx.decision_date).factor_symbol_ids().len()
}
}
fn selection_limit(
&self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState,
) -> Result<usize, BacktestError> {
let value = self.eval_float(ctx, &self.config.selection_limit_expr, day, None, None)?;
if !value.is_finite() || value < 0.0 {
return Err(BacktestError::Execution("selection limit must be finite and non-negative".into()));
}
Ok(value.round().max(1.0) as usize)
}
@@ -12697,6 +12702,27 @@ impl PlatformExprStrategy {
for value in contexts.values() {let record=value.to_string();if !decision.diagnostics.contains(&record){decision.diagnostics.push(record);}}
}
fn target_quote_unavailable_symbols(
&self,
ctx: &StrategyContext<'_>,
original_scales: &[(String, f64)],
candidates: &[(String, f64)],
) -> Result<BTreeSet<String>, BacktestError> {
if ctx.is_lagged_execution() {
return Ok(BTreeSet::new());
}
let symbols = original_scales.iter().chain(candidates).map(|(symbol, _)| symbol)
.collect::<BTreeSet<_>>();
let mut unavailable = BTreeSet::new();
for symbol in symbols {
// 只用实际执行时点已知的停牌事实;未知行情仍由原有严格读取返回错误。
if self.stock_state(ctx, ctx.execution_date, symbol)?.paused {
unavailable.insert(symbol.clone());
}
}
Ok(unavailable)
}
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
if self.config.signal_book.is_some() && self.config.explicit_action_schedule.is_some() {
return Ok(StrategyDecision::default());
@@ -12712,6 +12738,7 @@ impl PlatformExprStrategy {
{
return Ok(StrategyDecision::default());
}
if self.config.stock_pool.is_some(){return self.stock_pool_decision(ctx)}
let execution_date = ctx.execution_date;
let decision_date = ctx.decision_date;
let defer_execution_risk = ctx.is_lagged_execution();
@@ -13421,12 +13448,16 @@ impl PlatformExprStrategy {
} else {
(Vec::new(), Vec::new())
};
let quote_unavailable_symbols = self.target_quote_unavailable_symbols(
ctx, &original_target_scales, &candidate_target_scales,
)?;
let target_portfolio_scales = resolved_target_scales(
&original_target_scales,
&candidate_target_scales,
&exit_symbols,
selection_limit,
self.config.redistribute_target_weights_after_exit,
&quote_unavailable_symbols,
)?;
let target_portfolio_scale_by_symbol = target_portfolio_scales
.iter()
@@ -14018,6 +14049,7 @@ impl PlatformExprStrategy {
&exit_symbols,
daily_target_count,
self.config.redistribute_target_weights_after_exit,
&quote_unavailable_symbols,
)?;
if daily_top_up_active && self.config.target_portfolio_daily_enabled {
for (symbol, target_scale) in &daily_target_portfolio_scales {
@@ -14770,14 +14802,14 @@ mod tests {
];
let excluded = BTreeSet::from(["B".to_string()]);
let preserved = resolved_target_scales(&original, &original, &excluded, 3, false)
let preserved = resolved_target_scales(&original, &original, &excluded, 3, false, &BTreeSet::new())
.expect("preserved target scales");
assert_eq!(
preserved,
vec![("A".to_string(), 1.0008), ("C".to_string(), 0.9984)]
);
let redistributed = resolved_target_scales(&original, &original, &excluded, 3, true)
let redistributed = resolved_target_scales(&original, &original, &excluded, 3, true, &BTreeSet::new())
.expect("redistributed target scales");
assert_eq!(
redistributed,
@@ -14786,6 +14818,56 @@ mod tests {
assert!((redistributed.iter().map(|(_, scale)| *scale).sum::<f64>() - 3.0).abs() < 1e-12);
}
#[test]
fn stock_pool_suspension_keeps_budget_and_exit_redistributes_only_available_weights() {
let original = (0..24).map(|i| (format!("S{i:02}"), if i < 16 { 1.0008 } else { 0.9984 })).collect::<Vec<_>>();
let excluded = BTreeSet::from(["S02".to_string()]);
let paused = BTreeSet::from(["S15".to_string()]);
let resolved = resolved_target_scales(&original, &original, &excluded, 24, true, &paused).unwrap();
let weights = resolved.iter().map(|(s, scale)| (s.clone(), (scale / 24.0 * 10000.0).round() as u32)).collect::<BTreeMap<_, _>>();
assert_eq!(weights.len(), 22);
assert_eq!(weights.values().sum::<u32>(), 9583);
assert!(!weights.contains_key("S15"));
assert!(!weights.contains_key("S02"));
assert_eq!(weights["S11"], 436);
assert_eq!(weights["S14"], 436);
assert_eq!(weights["S22"], 435);
assert_eq!(weights["S23"], 434);
let candidates = [("A".to_string(), 1.2), ("PAUSED".to_string(), 0.9),
("EXIT".to_string(), 0.9), ("NEW".to_string(), 0.9)];
let replacement = resolved_target_scales(&candidates[..3], &candidates,
&BTreeSet::from(["EXIT".to_string()]), 3, true,
&BTreeSet::from(["PAUSED".to_string()])).unwrap();
assert_eq!(replacement, vec![("A".to_string(), 1.2), ("NEW".to_string(), 0.9)]);
let all_paused = original.iter().map(|(s, _)| s.clone()).collect::<BTreeSet<_>>();
assert!(resolved_target_scales(&original, &original, &BTreeSet::new(), 24, true, &all_paused).unwrap().is_empty());
}
#[test]
fn stock_pool_suspension_resolution_never_reads_the_future_execution_day() {
let date = d(2025, 9, 29);
let symbol = "000001.SZ";
let mut parts = single_symbol_platform_data(&[date], symbol).snapshot_components();
parts.market[0].paused = true;
let data = DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
parts.factors, parts.candidates, parts.benchmarks, Vec::new(), Vec::new()).unwrap();
let portfolio = PortfolioState::new(100_000.0);
let subscriptions = BTreeSet::new();
let mut ctx = StrategyContext {
execution_date: date, decision_date: date, decision_index: 0, data: &data,
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
active_datetime: None, order_events: &[], fills: &[],
};
let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::generic());
let targets = [(symbol.to_string(), 1.0)];
assert_eq!(strategy.target_quote_unavailable_symbols(&ctx, &targets, &targets).unwrap(),
BTreeSet::from([symbol.to_string()]));
ctx.execution_date = d(2025, 9, 30);
assert!(strategy.target_quote_unavailable_symbols(&ctx, &targets, &targets).unwrap().is_empty());
}
#[test]
fn model_target_lifecycle_removes_exits_and_appends_replacements() {
let date = d(2025, 1, 3);
@@ -17958,7 +18040,7 @@ mod tests {
false,
0,
&execution_state,
),
).expect("valid volume capacity"),
Some(2_500)
);
@@ -17978,7 +18060,7 @@ mod tests {
false,
0,
&execution_state,
),
).expect("valid remaining volume capacity"),
Some(100)
);
}
+236
View File
@@ -0,0 +1,236 @@
//! Stock pools emit one frozen framework intent, not a lossy code-strategy translation.
use super::*;
use crate::stock_pool_execution as pool;
use rust_decimal::Decimal;
impl PlatformExprStrategy {
pub(super) fn stock_pool_decision(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, BacktestError> {
let program = self
.config
.stock_pool
.as_ref()
.ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))?
.clone();
let mut constraints = pool::stock_pool_constraints_from_configuration(
&program.allocation_policy,
&program.stop_take_policy,
)
.map_err(BacktestError::Execution)?;
if let Some(policy) = constraints
.market_timing_policy
.as_ref()
.filter(|policy| policy.enabled)
{
let before_close = !ctx.is_lagged_execution()
&& ctx
.active_datetime
.is_some_and(|at| at.time() < NaiveTime::from_hms_opt(15, 0, 0).unwrap());
let as_of = if before_close {
ctx.data
.previous_trading_date(ctx.decision_date, 1)
.ok_or_else(|| {
BacktestError::Execution(
"market_timing_previous_completed_session_missing".into(),
)
})?
} else {
ctx.decision_date
};
let required = policy
.required_history()
.map_err(BacktestError::Execution)?;
let mut dates = ctx
.data
.calendar()
.iter()
.filter(|date| *date <= as_of)
.collect::<Vec<_>>();
if dates.len() < required {
return Err(BacktestError::Execution(format!(
"market_timing_official_calendar_incomplete:required={required}:available={}",
dates.len()
)));
}
dates = dates.split_off(dates.len() - required);
let index = policy.index_code.as_ref().expect("validated index policy");
let closes = dates
.iter()
.map(|date| {
let row = ctx.data.market(*date, index).ok_or_else(|| {
BacktestError::Execution(format!(
"market_timing_completed_index_row_missing:{index}:{date}"
))
})?;
Ok(crate::stock_pool_index_policy::IndexClose {
date: *date,
close: row.close,
})
})
.collect::<Result<Vec<_>, BacktestError>>()?;
constraints.market_timing_input =
Some(crate::stock_pool_index_policy::MarketTimingInput {
index_code: index.clone(),
as_of_date: as_of,
official_dates: dates,
closes,
});
}
let rule = pool::normalize_stock_pool_execution_rule(
Some(&program.timing_policy),
!self.config.buy_filter_expr.trim().is_empty(),
!self.config.stop_loss_expr.trim().is_empty()
|| !self.config.take_profit_expr.trim().is_empty()
|| !self.config.position_target_rules.is_empty(),
)
.map_err(BacktestError::Execution)?;
if self.config.in_skip_window(ctx.decision_date) {
return Ok(StrategyDecision::default());
}
let day = self.day_state(ctx, ctx.decision_date)?;
let (market_date, universe_date, factor_date) = self.selection_dates(ctx);
let (low, high) = self.market_cap_band(ctx, &day)?;
let (ranked, mut diagnostics, risk_decisions) = self.select_symbols(
ctx,
market_date,
universe_date,
factor_date,
&day,
low,
high,
usize::MAX,
)?;
let held = ctx
.portfolio
.positions()
.values()
.filter(|p| p.quantity > 0)
.map(|p| p.symbol.clone())
.collect::<BTreeSet<_>>();
if !self.config.buy_filter_expr.trim().is_empty() {
for symbol in &ranked {
let stock =
self.stock_state_with_factor_date(ctx, market_date, factor_date, symbol)?;
if !self.eval_bool(ctx, &self.config.buy_filter_expr, &day, Some(&stock), None)? {
constraints
.buy_denials
.insert(symbol.clone(), vec!["frozen_buy_condition_not_met".into()]);
}
}
}
let native_exits = self.current_stop_take_exit_symbols(ctx, ctx.decision_date, &day)?;
for symbol in native_exits {
constraints.position_target_bps.insert(symbol, 0);
}
for (symbol, (bps, _)) in
self.current_position_target_rules(ctx, ctx.decision_date, factor_date, &day)?
{
constraints
.position_target_bps
.entry(symbol)
.and_modify(|old| *old = (*old).min(bps))
.or_insert(bps);
}
let limit = constraints.target_holding_count.unwrap_or(ranked.len());
let final_symbols = ranked
.iter()
.filter(|symbol| !constraints.position_target_bps.contains_key(*symbol))
.take(limit)
.cloned()
.collect();
let generation = format!(
"stock-pool:{}:{}:{}",
program.pool_id,
program.version_id,
ctx.active_datetime
.map(|date| date.to_string())
.unwrap_or_else(|| ctx.decision_date.to_string())
);
let selection = pool::StockPoolSelection {
trade_date: ctx.decision_date,
requested_symbols: ranked.clone(),
normal_trading_symbols: ranked.clone(),
risk_eligible_symbols: ranked.clone(),
final_symbols,
exclusion_reasons: BTreeMap::new(),
inherited_from_generation: None,
explicit_empty: program.members.is_empty()
&& self.config.candidate_symbols_by_date.is_empty(),
generation: Some(generation.clone()),
};
let by_symbol = program
.members
.iter()
.map(|member| (member.symbol.as_str(), member))
.collect::<BTreeMap<_, _>>();
let mut scope = ranked.clone();
// Kept rules are execution metadata, not additional selection candidates.
for symbol in held {
if by_symbol.contains_key(symbol.as_str()) && !scope.contains(&symbol) {
scope.push(symbol)
}
}
let members = scope
.iter()
.enumerate()
.map(|(index, symbol)| {
let mut member = by_symbol
.get(symbol.as_str())
.map(|member| (*member).clone())
.unwrap_or_else(|| pool::StockPoolMemberSpec {
symbol: symbol.clone(),
requested_order: index as i32,
recommendation_reason: String::new(),
target_weight_bps: None,
stop_loss: constraints.default_stop_loss,
take_profit: constraints.default_take_profit,
});
member.requested_order = index as i32;
member
})
.collect();
let (base_ratio, reserve_cash) =
pool::stock_pool_funding_from_configuration(&program.allocation_policy)
.map_err(BacktestError::Execution)?;
let ratio = self
.config
.position_exposure_schedule
.range(..=ctx.decision_date)
.next_back()
.map(|(_, value)| (*value * 10000.).round() as i64)
.unwrap_or(i64::from(base_ratio));
let invest_ratio_bps = i32::try_from(ratio)
.ok()
.filter(|ratio| (0..=10000).contains(ratio))
.ok_or_else(|| BacktestError::Execution("stock_pool_invest_ratio_invalid".into()))?;
let signal_equity =
self.signal_visible_total_value(ctx, ctx.decision_date, ctx.is_lagged_execution());
let frozen_equity = signal_equity
.to_string()
.parse::<Decimal>()
.map_err(|_| BacktestError::Execution("stock_pool_signal_equity_invalid".into()))?;
diagnostics.push(format!("stock_pool_signal_frozen generation={generation} candidate_count={} frozen_equity={frozen_equity}",ranked.len()));
Ok(StrategyDecision {
order_intents: vec![OrderIntent::StockPool {
contract: Box::new(pool::FrozenStockPoolIntent {
pool_id:program.pool_id.clone(),
signal_date: ctx.decision_date,
frozen_equity,
selection,
members,
rule,
constraints,
invest_ratio_bps,
reserve_cash,
out_of_pool_policy: program.out_of_pool_policy,
generation,
}),
}],
diagnostics,
risk_decisions,
..Default::default()
})
}
}
@@ -17,6 +17,8 @@ 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)]
@@ -914,6 +916,8 @@ pub struct StrategyExpressionSelectionConfig {
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)]
@@ -1652,6 +1656,7 @@ fn apply_execution_behavior_overrides(
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(
@@ -2138,6 +2143,19 @@ pub fn platform_expr_config_from_spec(
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}")
@@ -2636,6 +2654,12 @@ 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()?)),
@@ -2670,6 +2694,17 @@ pub fn platform_expr_config_from_spec(
}
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 secondary_buy=!cfg.buy_filter_expr.trim().is_empty();
let secondary_sell=spec.runtime_expressions.as_ref().and_then(|runtime|runtime.risk.as_ref()).is_some_and(|risk|risk.stop_loss_expr.is_some()||risk.take_profit_expr.is_some()) || !cfg.position_target_rules.is_empty();
pool.validate(secondary_buy,secondary_sell)?;
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)
}
+11
View File
@@ -676,6 +676,7 @@ pub struct PortfolioState {
cash_receivables: Vec<CashReceivable>,
pending_cash_flows: Vec<PendingCashFlow>,
day_sold_symbols: BTreeSet<String>,
stock_pool_states: std::collections::BTreeMap<String,crate::stock_pool_state::StockPoolExecutionState>,
}
#[derive(Debug, Clone)]
@@ -712,6 +713,7 @@ impl PortfolioState {
cash_receivables: Vec::new(),
pending_cash_flows: Vec::new(),
day_sold_symbols: BTreeSet::new(),
stock_pool_states: std::collections::BTreeMap::new(),
}
}
@@ -721,6 +723,15 @@ impl PortfolioState {
self.initial_cash.to_f64()
}
pub(crate) fn stock_pool_execution_state(&self,pool_id:&str)->crate::stock_pool_state::StockPoolExecutionState{
self.stock_pool_states.get(pool_id).cloned().unwrap_or_default()
}
pub(crate) fn set_stock_pool_execution_state(&mut self,pool_id:&str,state:crate::stock_pool_state::StockPoolExecutionState)->Result<(),String>{
if pool_id.trim().is_empty(){return Err("stock_pool_state_identity_missing".into())}
state.validate()?;self.stock_pool_states.insert(pool_id.into(),state);Ok(())
}
pub fn initial_cash(&self) -> f64 {
self.initial_cash.to_f64()
}
+17 -1
View File
@@ -418,6 +418,7 @@ impl ChinaAShareRiskControl {
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))
{
@@ -492,7 +493,8 @@ impl ChinaAShareRiskControl {
}
// 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 {
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 {
@@ -936,6 +938,20 @@ mod tests {
day, &candidate, &snapshot, None, 0.9, &relaxed), None);
}
#[test]
fn fund_identity_excludes_stock_one_yuan_rule_but_not_actual_price_and_pause_checks() {
let day=d(2025,2,6);
let mut candidate=candidate(day);
let mut snapshot=market(day,1.2,0.5);
snapshot.lower_limit=0.01;snapshot.upper_limit=10.;
let instrument=Instrument{symbol:candidate.symbol.clone(),name:"fixture fund".into(),board:"ETF".into(),round_lot:100,listed_at:Some(d(2024,1,2)),delisted_at:None,status:"active".into()};
let config=FidcRiskControlConfig::default();
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),None);
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.,&config),Some("invalid execution price"));
candidate.is_paused=true;snapshot.paused=true;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),Some("paused"));
}
#[test]
fn execution_quote_covers_missing_one_yuan_flag_but_not_other_risk_facts() {
let day = d(2025, 2, 6);
@@ -0,0 +1,229 @@
//! Candidate provenance and ordering; contains no market-data or broker I/O.
use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
pub const CANDIDATE_SOURCES_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CandidateSourceMode {
Manual,
FilteredManual,
Automatic,
Mixed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CandidateSourcePriority {
#[default]
ManualFirst,
AutomaticFirst,
ListOrder,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CandidateSourcePolicy {
pub schema_version: u32,
pub mode: CandidateSourceMode,
#[serde(default)]
pub priority: CandidateSourcePriority,
#[serde(default)]
pub merged_order: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CandidateMember {
pub symbol: String,
pub manual: bool,
pub automatic: bool,
}
fn symbols(values: &[String], label: &str) -> Result<Vec<String>, String> {
let mut seen = BTreeSet::new();
values.iter().map(|value| {
let symbol = value.trim().to_ascii_uppercase();
if !symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
code.len() == 6 && code.bytes().all(|byte| byte.is_ascii_digit())
&& matches!(exchange, "SH" | "SZ" | "BJ")
}) {
return Err(format!("{label}: invalid qualified security code {value}"));
}
if !seen.insert(symbol.clone()) {
return Err(format!("{label}: duplicate security {symbol}"));
}
Ok(symbol)
}).collect()
}
impl CandidateSourcePolicy {
pub fn validate(&self) -> Result<(), String> {
if self.schema_version != CANDIDATE_SOURCES_SCHEMA {
return Err("candidate_sources schema_version must be 1".into());
}
symbols(&self.merged_order, "candidate_sources.merged_order")?;
if self.mode != CandidateSourceMode::Mixed && self.priority != CandidateSourcePriority::ManualFirst {
return Err("candidate source priority only applies to mixed sources".into());
}
if self.priority != CandidateSourcePriority::ListOrder && !self.merged_order.is_empty() {
return Err("merged_order requires list_order priority".into());
}
Ok(())
}
pub fn uses_screen(&self) -> bool {
self.mode != CandidateSourceMode::Manual
}
pub fn validate_screen_binding(&self, manual: &[String], has_screen: bool) -> Result<(), String> {
self.validate()?;
symbols(manual, "manual candidates")?;
if self.uses_screen() != has_screen {
return Err("candidate source mode and screen contract must agree".into());
}
if self.mode == CandidateSourceMode::FilteredManual && manual.is_empty() {
return Err("filtered_manual requires manual members; an empty scope must not become all-market".into());
}
Ok(())
}
}
/// Overlap between two valid sources denotes one member with both provenance
/// flags. Duplicates *within* a source are invalid evidence, not fixed by dedup.
pub fn resolve_candidates(
policy: &CandidateSourcePolicy,
manual: &[String],
automatic: Option<&[String]>,
) -> Result<Vec<CandidateMember>, String> {
policy.validate_screen_binding(manual, automatic.is_some())?;
let manual = symbols(manual, "manual candidates")?;
let automatic = automatic.map(|values| symbols(values, "automatic candidates")).transpose()?.unwrap_or_default();
let manual_set = manual.iter().cloned().collect::<BTreeSet<_>>();
let auto_set = automatic.iter().cloned().collect::<BTreeSet<_>>();
if policy.mode == CandidateSourceMode::FilteredManual && !auto_set.is_subset(&manual_set) {
return Err("filtered_manual snapshot contains a security outside the manual scope".into());
}
let mut ordered = match policy.mode {
CandidateSourceMode::Manual => manual.clone(),
CandidateSourceMode::FilteredManual | CandidateSourceMode::Automatic => automatic.clone(),
CandidateSourceMode::Mixed => {
let (first, second) = if policy.priority == CandidateSourcePriority::AutomaticFirst {
(&automatic, &manual)
} else { (&manual, &automatic) };
let mut union = first.clone();
let mut seen = first.iter().cloned().collect::<BTreeSet<_>>();
union.extend(second.iter().filter(|symbol| seen.insert((*symbol).clone())).cloned());
union
}
};
if policy.priority == CandidateSourcePriority::ListOrder {
let present = ordered.iter().cloned().collect::<BTreeSet<_>>();
let prefix = symbols(&policy.merged_order, "candidate_sources.merged_order")?
.into_iter().filter(|symbol| present.contains(symbol)).collect::<Vec<_>>();
let selected = prefix.iter().cloned().collect::<BTreeSet<_>>();
let tail = ordered.into_iter().filter(|symbol| !selected.contains(symbol));
ordered = prefix.into_iter().chain(tail).collect();
}
Ok(ordered.into_iter().map(|symbol| CandidateMember {
manual: manual_set.contains(&symbol), automatic: auto_set.contains(&symbol), symbol,
}).collect())
}
/// Raw daily automatic candidates remain unchanged. Every resolved list is
/// derived by the shared kernel; absent dates never inherit yesterday's list.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CandidateSourceBook {
pub schema_version: u32,
pub policy: CandidateSourcePolicy,
pub manual_symbols: Vec<String>,
pub automatic_symbols_by_date: BTreeMap<NaiveDate, Vec<String>>,
pub source_snapshot_sha256: String,
pub source_coverage_sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_symbols: Option<Vec<String>>,
}
impl CandidateSourceBook {
pub fn resolve(&self) -> Result<BTreeMap<NaiveDate, Vec<CandidateMember>>, String> {
if self.schema_version != CANDIDATE_SOURCES_SCHEMA || !self.policy.uses_screen() {
return Err("candidate source book requires schema 1 and a screened source".into());
}
for value in [&self.source_snapshot_sha256, &self.source_coverage_sha256] {
if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("candidate source book requires snapshot and coverage SHA256".into());
}
}
if self.automatic_symbols_by_date.is_empty() {
return Err("candidate source book requires explicit covered trading dates".into());
}
let scope = self.execution_symbols.as_ref().map(|values| symbols(values, "candidate execution scope")
.map(|values| values.into_iter().collect::<BTreeSet<_>>())).transpose()?;
self.automatic_symbols_by_date.iter().map(|(day, values)| {
resolve_candidates(&self.policy, &self.manual_symbols, Some(values))
.map(|members| (*day, members.into_iter().filter(|member| scope.as_ref().is_none_or(|scope| scope.contains(&member.symbol))).collect()))
}).collect()
}
pub fn resolved_symbols(&self) -> Result<BTreeMap<NaiveDate, Vec<String>>, String> {
Ok(self.resolve()?.into_iter().map(|(date, values)|
(date, values.into_iter().map(|member| member.symbol).collect())).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn list(values: &[&str]) -> Vec<String> { values.iter().map(|value| value.to_string()).collect() }
fn policy(mode: CandidateSourceMode, priority: CandidateSourcePriority) -> CandidateSourcePolicy {
CandidateSourcePolicy { schema_version: 1, mode, priority, merged_order: vec![] }
}
#[test]
fn mixed_sources_preserve_priority_and_both_provenances() {
let manual = list(&["600000.SH", "000001.SZ"]);
let automatic = list(&["000002.SZ", "000001.SZ"]);
for (priority, expected) in [
(CandidateSourcePriority::ManualFirst, list(&["600000.SH", "000001.SZ", "000002.SZ"])),
(CandidateSourcePriority::AutomaticFirst, list(&["000002.SZ", "000001.SZ", "600000.SH"])),
] {
let result = resolve_candidates(&policy(CandidateSourceMode::Mixed, priority), &manual, Some(&automatic)).unwrap();
assert_eq!(result.iter().map(|value| value.symbol.clone()).collect::<Vec<_>>(), expected);
let overlap = result.iter().find(|value| value.symbol == "000001.SZ").unwrap();
assert!(overlap.manual && overlap.automatic);
}
}
#[test]
fn list_order_reuses_explicit_prefix_and_appends_new_candidates() {
let mut p = policy(CandidateSourceMode::Mixed, CandidateSourcePriority::ListOrder);
p.merged_order = list(&["000002.SZ", "600036.SH", "600000.SH"]);
let result = resolve_candidates(&p, &list(&["600000.SH", "000001.SZ"]), Some(&list(&["000002.SZ", "000003.SZ"]))).unwrap();
assert_eq!(result.into_iter().map(|row| row.symbol).collect::<Vec<_>>(), list(&["000002.SZ", "600000.SH", "000001.SZ", "000003.SZ"]));
}
#[test]
fn missing_snapshot_duplicate_input_and_empty_filtered_scope_fail() {
let p = policy(CandidateSourceMode::Mixed, CandidateSourcePriority::ManualFirst);
assert!(resolve_candidates(&p, &[], None).is_err());
assert!(resolve_candidates(&p, &[], Some(&list(&["000001.SZ", "000001.sz"]))).is_err());
let p = policy(CandidateSourceMode::FilteredManual, CandidateSourcePriority::ManualFirst);
assert!(resolve_candidates(&p, &[], Some(&[])).unwrap_err().contains("all-market"));
assert!(resolve_candidates(&p, &list(&["000001.SZ"]), Some(&list(&["600000.SH"]))).is_err());
}
#[test]
fn zero_automatic_day_keeps_manual_members_without_inheriting_old_auto_targets() {
let day1 = NaiveDate::from_ymd_opt(2026, 9, 9).unwrap();
let day2 = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
let book = CandidateSourceBook { schema_version: 1,
policy: policy(CandidateSourceMode::Mixed, CandidateSourcePriority::AutomaticFirst),
manual_symbols: list(&["510300.SH"]),
automatic_symbols_by_date: BTreeMap::from([(day1, list(&["000001.SZ"])), (day2, vec![])]),
source_snapshot_sha256: "a".repeat(64), source_coverage_sha256: "b".repeat(64), execution_symbols:None };
let result = book.resolved_symbols().unwrap();
assert_eq!(result[&day1], list(&["000001.SZ", "510300.SH"]));
assert_eq!(result[&day2], list(&["510300.SH"]));
let mut auto = book; auto.policy = policy(CandidateSourceMode::Automatic, CandidateSourcePriority::ManualFirst);
assert!(auto.resolved_symbols().unwrap()[&day2].is_empty());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
//! Dated non-tradability and valuation are separate from execution quotations.
use super::*;
pub(super) fn validate(
signal_date: NaiveDate,
constraints: &StockPoolDecisionConstraints,
current: &BTreeMap<String, (Decimal, Decimal, Decimal)>,
) -> Result<(), String> {
for (symbol, fact) in &constraints.frozen_positions {
if constraints.execution_date != Some(fact.trade_date)
|| fact.trade_date < signal_date
|| fact.reason != "paused"
|| fact.valuation_price <= Decimal::ZERO
|| current.get(symbol).is_none_or(|row| row.0 <= Decimal::ZERO)
{
return Err(format!("stock_pool_frozen_position_invalid:{symbol}"));
}
}
if constraints
.prior_target_weights
.iter()
.any(|(symbol, weight)| {
normalize_stock_symbol(symbol).as_ref() != Some(symbol)
|| !(0..=10_000).contains(weight)
})
{
return Err("stock_pool_prior_target_weights_invalid".into());
}
Ok(())
}
pub(super) fn valuation(
symbol: &str,
quotes: &HashMap<String, &MarketSnapshot>,
frozen: &BTreeMap<String, FrozenStockPoolPosition>,
) -> Result<Decimal, String> {
frozen
.get(symbol)
.map(|fact| fact.valuation_price)
.or_else(|| quotes.get(symbol).map(|quote| quote.last_price))
.filter(|price| *price > Decimal::ZERO)
.ok_or_else(|| format!("{symbol} confirmed holding valuation missing"))
}
pub(super) fn weights(
original: &[String],
active: &[String],
members: &[StockPoolMemberSpec],
explicit: &BTreeMap<String, i32>,
constraints: &StockPoolDecisionConstraints,
reserved_slots: usize,
target_count: usize,
) -> Result<BTreeMap<String, i32>, String> {
let count = original.len() + reserved_slots;
let order = members
.iter()
.map(|member| (&member.symbol, member.requested_order))
.collect::<BTreeMap<_, _>>();
let mut original_budget_symbols = original.to_vec();
for symbol in constraints.frozen_positions.keys() {
if order.contains_key(symbol) && !original_budget_symbols.contains(symbol) {
original_budget_symbols.push(symbol.clone());
}
}
if original_budget_symbols.len() != original.len() {
original_budget_symbols
.sort_by_key(|symbol| order.get(symbol).copied().unwrap_or(i32::MAX));
}
let initial = original_budget_symbols
.iter()
.enumerate()
.map(|(index, symbol)| {
let weight = if explicit.is_empty() {
if count == 0 {
0
} else {
10_000 / count as i32 + i32::from(index < 10_000 % count)
}
} else {
*explicit.get(symbol).unwrap_or(&0)
};
(symbol.clone(), weight)
})
.collect::<Vec<_>>();
let mut frozen = BTreeMap::new();
for symbol in constraints.frozen_positions.keys() {
let weight = explicit
.get(symbol)
.copied()
.or_else(|| constraints.prior_target_weights.get(symbol).copied())
.or_else(|| {
initial
.iter()
.find(|(key, _)| key == symbol)
.map(|(_, weight)| *weight)
})
.ok_or_else(|| format!("stock_pool_frozen_position_target_weight_missing:{symbol}"))?;
frozen.insert(symbol.clone(), weight);
}
let frozen_total = frozen.values().copied().sum::<i32>();
if frozen_total > 10_000 {
return Err("stock_pool_frozen_position_weights_exceed_budget".into());
}
let mut free = initial
.into_iter()
.filter(|(symbol, _)| !frozen.contains_key(symbol))
.map(|(symbol, weight)| (symbol, weight as u32))
.collect::<Vec<_>>();
let total = free.iter().map(|(_, weight)| *weight).sum::<u32>();
let available = (10_000 - frozen_total) as u32;
// A paused holding removed from today's candidates still owns its prior
// budget. Scale only the new tradable allocation, never the frozen leg.
if total > available {
let mut remainder = available;
for (_, weight) in &mut free {
*weight = (u64::from(*weight) * u64::from(available) / u64::from(total)) as u32;
remainder -= *weight;
}
for (_, weight) in free.iter_mut().take(remainder as usize) {
*weight += 1;
}
}
let excluded = free
.iter()
.filter(|(symbol, _)| !active.contains(symbol))
.map(|(symbol, _)| symbol.clone())
.collect();
let candidates = active
.iter()
.filter(|symbol| !frozen.contains_key(*symbol))
.cloned()
.collect::<Vec<_>>();
let allocated = crate::platform_expr_strategy::replenish_target_weight_bps(
&free,
&candidates,
&excluded,
target_count.saturating_sub(
frozen
.keys()
.filter(|symbol| original.contains(symbol))
.count(),
),
);
frozen.extend(
allocated
.into_iter()
.map(|(symbol, weight)| (symbol, weight as i32)),
);
Ok(frozen)
}
@@ -0,0 +1,93 @@
//! Index exposure is independent of relative-weight maintenance. Reduce the
//! existing proportions only when the portfolio exceeds its index budget.
use super::*;
pub(super) struct IndexCapTarget {
pub quantity: Decimal,
pub blocked_by_t1: bool,
}
pub(super) fn remaining_index_targets(
current: &BTreeMap<String, (Decimal, Decimal, Decimal)>,
members: &HashMap<String, &StockPoolMemberSpec>,
automatic: &BTreeMap<String, crate::holding_policy::AutomaticTradePermission>,
already_planned: &[StockPoolPlanRow],
quotes: &HashMap<String, &MarketSnapshot>,
frozen: &BTreeMap<String, FrozenStockPoolPosition>,
budget: Decimal,
) -> Result<BTreeMap<String, IndexCapTarget>, String> {
struct Entry {
symbol: String,
quantity: Decimal,
minimum: Decimal,
price: Decimal,
}
let other = already_planned
.iter()
.map(|row| (&row.symbol, row))
.collect::<BTreeMap<_, _>>();
let mut fixed = Decimal::ZERO;
let mut entries = Vec::new();
for (symbol, (quantity, closable, _)) in current.iter().filter(|(_, row)| row.0 > Decimal::ZERO)
{
let price = super::frozen::valuation(symbol, quotes, frozen)?;
let minimum = (*quantity - *closable).max(Decimal::ZERO);
let remaining = other
.get(symbol)
.map(|row| row.target_quantity.min(*quantity))
.unwrap_or(*quantity)
.max(minimum);
if frozen.contains_key(symbol)
|| automatic
.get(symbol)
.is_some_and(|permission| permission.sell_denial.is_some())
{
fixed += *quantity * price;
} else if members.contains_key(symbol) && remaining > Decimal::ZERO {
entries.push(Entry {
symbol: symbol.clone(),
quantity: remaining,
minimum,
price,
});
} else {
fixed += remaining * price;
}
}
let mut remaining = entries
.iter()
.map(|row| row.quantity * row.price)
.sum::<Decimal>();
let mut available = (budget - fixed).max(Decimal::ZERO);
if remaining <= available {
return Ok(BTreeMap::new());
}
// The highest non-sellable proportions are fixed first; the remainder
// keeps its existing relative weights. No planned sale funds a new buy.
entries.sort_by(|left, right| {
(right.minimum / right.quantity)
.cmp(&(left.minimum / left.quantity))
.then_with(|| left.symbol.cmp(&right.symbol))
});
let mut result = BTreeMap::new();
for row in entries {
let scale = if remaining > Decimal::ZERO {
(available / remaining).min(Decimal::ONE)
} else {
Decimal::ZERO
};
let desired = (row.quantity * scale).floor();
let blocked = desired < row.minimum;
let target = desired.max(row.minimum).min(row.quantity);
remaining -= row.quantity * row.price;
available = (available - target * row.price).max(Decimal::ZERO);
result.insert(
row.symbol,
IndexCapTarget {
quantity: target,
blocked_by_t1: blocked,
},
);
}
Ok(result)
}
@@ -0,0 +1,344 @@
//! Explicit index timing, shared by historical and online stock-pool planners.
//! Inputs are completed official sessions, not a shortened available-row window.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MarketTimingPolicy {
pub enabled: bool,
pub index_code: Option<String>,
pub fast_window: Option<usize>,
pub slow_window: Option<usize>,
pub volatility_window: Option<usize>,
pub drawdown_window: Option<usize>,
pub bull_exposure: Option<f64>,
pub bear_exposure: Option<f64>,
pub volatility_threshold: Option<f64>,
pub volatility_cap: Option<f64>,
pub drawdown_threshold: Option<f64>,
pub drawdown_cap: Option<f64>,
}
impl MarketTimingPolicy {
pub fn from_allocation(value: &serde_json::Value) -> Result<Self, String> {
let raw = value.get("market_timing").filter(|value| !value.is_null());
let policy = raw
.map(|raw| serde_json::from_value::<Self>(raw.clone()))
.transpose()
.map_err(|error| format!("market_timing_invalid:{error}"))?
.unwrap_or_default();
policy.validate()?;
Ok(policy)
}
pub fn validate(&self) -> Result<(), String> {
if let Some(index) = &self.index_code {
let valid = index.split_once('.').is_some_and(|(code, exchange)| {
(6..=12).contains(&code.len())
&& code
.bytes()
.all(|v| v.is_ascii_uppercase() || v.is_ascii_digit())
&& matches!(exchange, "SH" | "SZ" | "CSI" | "CNI")
});
if !valid {
return Err("market_timing_index_code_invalid".into());
}
} else if self.enabled {
return Err("market_timing_index_code_required".into());
}
for (key, value) in [
("fast_window", self.fast_window),
("slow_window", self.slow_window),
("volatility_window", self.volatility_window),
("drawdown_window", self.drawdown_window),
] {
match value {
Some(value) if !(2..=250).contains(&value) => {
return Err(format!("market_timing_{key}_must_be_2_to_250"));
}
None if self.enabled => return Err(format!("market_timing_{key}_required")),
_ => {}
}
}
if let (Some(fast), Some(slow)) = (self.fast_window, self.slow_window) {
if fast >= slow {
return Err("market_timing_fast_window_must_be_less_than_slow_window".into());
}
}
for (key, value) in [
("bull_exposure", self.bull_exposure),
("bear_exposure", self.bear_exposure),
("volatility_threshold", self.volatility_threshold),
("volatility_cap", self.volatility_cap),
("drawdown_threshold", self.drawdown_threshold),
("drawdown_cap", self.drawdown_cap),
] {
match value {
Some(value) if !value.is_finite() || !(0.0..=1.0).contains(&value) => {
return Err(format!("market_timing_{key}_must_be_in_0_to_1"));
}
None if self.enabled => return Err(format!("market_timing_{key}_required")),
_ => {}
}
}
Ok(())
}
pub fn required_history(&self) -> Result<usize, String> {
self.validate()?;
if !self.enabled {
return Ok(0);
}
Ok(self
.slow_window
.unwrap()
.max(self.volatility_window.unwrap() + 1)
.max(self.drawdown_window.unwrap()))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IndexClose {
pub date: NaiveDate,
pub close: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MarketTimingInput {
pub index_code: String,
pub as_of_date: NaiveDate,
pub official_dates: Vec<NaiveDate>,
pub closes: Vec<IndexClose>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MarketTimingEvaluation {
pub index_code: String,
pub as_of_date: NaiveDate,
pub window_start: NaiveDate,
pub session_count: usize,
pub fast_ma: f64,
pub slow_ma: f64,
pub volatility: f64,
pub drawdown: f64,
pub exposure: f64,
pub reason_codes: Vec<String>,
pub policy_sha256: String,
pub input_sha256: String,
pub kernel_sha256: String,
}
pub fn implementation_sha256() -> String {
format!(
"{:x}",
Sha256::digest(include_bytes!("stock_pool_index_policy.rs"))
)
}
pub fn evaluate(
policy: &MarketTimingPolicy,
input: &MarketTimingInput,
decision_date: NaiveDate,
) -> Result<MarketTimingEvaluation, String> {
let needed = policy.required_history()?;
if needed == 0 {
return Err("market_timing_disabled_has_no_evaluation".into());
}
if policy.index_code.as_deref() != Some(input.index_code.as_str()) {
return Err("market_timing_input_index_mismatch".into());
}
if input.as_of_date > decision_date {
return Err("market_timing_future_input".into());
}
if input.official_dates.len() != needed
|| input.closes.len() != needed
|| input.official_dates.last() != Some(&input.as_of_date)
|| input
.official_dates
.windows(2)
.any(|pair| pair[0] >= pair[1])
{
return Err("market_timing_official_calendar_incomplete".into());
}
if input
.closes
.iter()
.zip(&input.official_dates)
.any(|(row, date)| row.date != *date || !row.close.is_finite() || row.close <= 0.)
{
return Err("market_timing_completed_index_rows_incomplete".into());
}
let closes = input.closes.iter().map(|row| row.close).collect::<Vec<_>>();
let mean = |window: usize| closes[needed - window..].iter().sum::<f64>() / window as f64;
let fast_ma = mean(policy.fast_window.unwrap());
let slow_ma = mean(policy.slow_window.unwrap());
let returns = closes[needed - policy.volatility_window.unwrap() - 1..]
.windows(2)
.map(|pair| pair[1] / pair[0] - 1.)
.collect::<Vec<_>>();
let average = returns.iter().sum::<f64>() / returns.len() as f64;
let volatility = (returns
.iter()
.map(|value| (value - average).powi(2))
.sum::<f64>()
/ (returns.len() - 1) as f64)
.sqrt();
let peak = closes[needed - policy.drawdown_window.unwrap()..]
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let drawdown = 1. - closes[needed - 1] / peak;
let bullish = fast_ma > slow_ma;
let mut exposure = if bullish {
policy.bull_exposure.unwrap()
} else {
policy.bear_exposure.unwrap()
};
let mut reason_codes = vec![
if bullish {
"fast_above_slow"
} else {
"fast_not_above_slow"
}
.into(),
];
if volatility >= policy.volatility_threshold.unwrap() {
exposure = exposure.min(policy.volatility_cap.unwrap());
reason_codes.push("volatility_cap".into());
}
if drawdown >= policy.drawdown_threshold.unwrap() {
exposure = exposure.min(policy.drawdown_cap.unwrap());
reason_codes.push("drawdown_cap".into());
}
if [fast_ma, slow_ma, volatility, drawdown, exposure]
.iter()
.any(|value| !value.is_finite())
{
return Err("market_timing_nonfinite_result".into());
}
let hash = |value: &serde_json::Value| -> Result<String, String> {
Ok(format!(
"{:x}",
Sha256::digest(serde_json::to_vec(value).map_err(|error| error.to_string())?)
))
};
Ok(MarketTimingEvaluation {
index_code: input.index_code.clone(),
as_of_date: input.as_of_date,
window_start: input.official_dates[0],
session_count: needed,
fast_ma,
slow_ma,
volatility,
drawdown,
exposure,
reason_codes,
policy_sha256: hash(&serde_json::to_value(policy).map_err(|error| error.to_string())?)?,
input_sha256: hash(&serde_json::to_value(input).map_err(|error| error.to_string())?)?,
kernel_sha256: implementation_sha256(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn policy() -> MarketTimingPolicy {
serde_json::from_value(serde_json::json!({"enabled":true,"index_code":"000300.SH","fast_window":10,"slow_window":30,
"volatility_window":20,"drawdown_window":60,"bull_exposure":1.0,"bear_exposure":0.3,"volatility_threshold":0.025,
"volatility_cap":0.3,"drawdown_threshold":0.08,"drawdown_cap":0.2})).unwrap()
}
fn input(values: Vec<f64>) -> MarketTimingInput {
let dates = (0..values.len())
.map(|i| {
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Duration::days(i as i64)
})
.collect::<Vec<_>>();
MarketTimingInput {
index_code: "000300.SH".into(),
as_of_date: *dates.last().unwrap(),
closes: dates
.iter()
.zip(values)
.map(|(date, close)| IndexClose { date: *date, close })
.collect(),
official_dates: dates,
}
}
#[test]
fn trend_and_caps_match_the_declared_math_and_keep_zero_meaningful() {
for (prices, expected) in [
((0..60).map(|i| 100. + i as f64).collect(), 1.),
((0..60).map(|i| 200. - i as f64).collect(), 0.2),
(
(0..59)
.map(|i| 100. + i as f64 * 0.1)
.chain([150.])
.collect(),
0.3,
),
] {
let input = input(prices);
let result = evaluate(&policy(), &input, input.as_of_date).unwrap();
assert_eq!(result.exposure, expected);
assert_eq!(result.session_count, 60);
assert_eq!(result.input_sha256.len(), 64);
}
let input = input(vec![100.; 60]);
let mut zero = policy();
zero.bear_exposure = Some(0.);
assert_eq!(
evaluate(&zero, &input, input.as_of_date).unwrap().exposure,
0.
);
}
#[test]
fn missing_or_duplicate_or_future_rows_never_shorten_the_window() {
let base = input(vec![100.; 60]);
let mut invalid = base.clone();
invalid.closes.remove(5);
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
let mut invalid = base.clone();
invalid.closes[5].date = invalid.closes[4].date;
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
let mut invalid = base.clone();
invalid.closes[5].close = f64::NAN;
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
assert!(
evaluate(
&policy(),
&base,
base.as_of_date - chrono::Duration::days(1)
)
.is_err()
);
let mut invalid = base.clone();
invalid.index_code = "000852.SH".into();
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
}
#[test]
fn disabled_is_optional_but_enabled_parameters_are_not_invented() {
assert_eq!(
MarketTimingPolicy::from_allocation(&serde_json::Value::Null)
.unwrap()
.required_history()
.unwrap(),
0
);
assert!(
MarketTimingPolicy::from_allocation(
&serde_json::json!({"market_timing":{"enabled":true}})
)
.is_err()
);
let mut invalid = policy();
invalid.fast_window = invalid.slow_window;
assert!(invalid.validate().is_err());
let mut invalid = policy();
invalid.volatility_window = Some(250);
assert_eq!(invalid.required_history().unwrap(), 251);
}
}
@@ -0,0 +1,179 @@
//! Shared causal indicator calculation for stock-pool screening.
use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::factor_events::{self, Expr, Frame};
pub fn implementation_sha256() -> String {
use sha2::{Digest,Sha256};
let mut identity=Sha256::new();
identity.update(include_bytes!("stock_pool_indicators.rs"));
identity.update(factor_events::catalog()["expression_kernel_sha256"].as_str().expect("native kernel identity"));
format!("{:x}",identity.finalize())
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct IndicatorSpec {
pub indicator: String,
pub field: String,
pub window: usize,
#[serde(default)]
pub output: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InputSeries {
pub frame: Frame,
/// Source-admitted historical suspension/lifecycle gaps, not guessed from
/// missing prices. The pure endpoint never certifies those source facts.
#[serde(default)]
pub admitted_missing_dates: BTreeSet<NaiveDate>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub specs: BTreeMap<String, IndicatorSpec>,
pub series: Vec<InputSeries>,
pub output_start_date: NaiveDate,
pub output_end_date: NaiveDate,
}
#[derive(Debug, Serialize)]
pub struct OutputSeries {
pub symbol: String,
pub indices: Vec<usize>,
pub values: BTreeMap<String, Vec<Option<f64>>>,
}
pub fn evaluate(request: Request) -> Result<Vec<OutputSeries>, String> {
let row_count: usize = request.series.iter().map(|series| series.frame.timestamps.len()).sum();
if request.specs.is_empty() || request.specs.len() > 64 || row_count > 60_000
|| row_count.saturating_mul(request.specs.len()) > 1_000_000
|| request.output_start_date > request.output_end_date {
return Err("stock_pool_indicator_request_budget_or_range_invalid".into());
}
for spec in request.specs.values() {
if !matches!(spec.indicator.as_str(), "ma" | "ema" | "kdj") || !(2..=10_000).contains(&spec.window)
|| !matches!(spec.field.as_str(), "close" | "volume" | "high" | "low")
|| (spec.indicator == "kdj" && !matches!(spec.output.as_deref(), Some("k" | "d" | "j"))) {
return Err("stock_pool_indicator_spec_invalid".into());
}
}
let mut seen = BTreeSet::new();
let mut result = Vec::new();
for series in request.series {
let frame = series.frame;
frame.validate()?;
if frame.frequency != "1d" { return Err("stock_pool_indicators_require_daily_source".into()); }
if series.admitted_missing_dates.iter().any(|day| !frame.timestamps.iter().any(|stamp| stamp.date_naive() == *day)) {
return Err("admitted_missing_date_is_outside_the_input_frame".into());
}
if !seen.insert(frame.symbol.clone()) { return Err("stock_pool_indicator_duplicate_symbol".into()); }
let indices = frame.timestamps.iter().enumerate().filter(|(_, stamp)| {
let day = stamp.date_naive(); request.output_start_date <= day && day <= request.output_end_date
}).map(|(index, _)| index).collect::<Vec<_>>();
let mut values = BTreeMap::new();
for (key, spec) in &request.specs {
let fields: Vec<&str> = if spec.indicator == "kdj" { vec!["high", "low", "close"] } else { vec![&spec.field] };
for field in &fields {
let raw = frame.fields.get(*field).ok_or_else(|| format!("indicator_field_missing:{field}"))?;
if raw.len() != frame.timestamps.len() { return Err("indicator_field_length_mismatch".into()); }
}
for index in 0..frame.timestamps.len() {
let valid = fields.iter().all(|field| frame.fields[*field][index].is_some_and(f64::is_finite));
if valid {
if fields.iter().any(|field| frame.fields[*field][index].is_some_and(|value| if *field == "volume" { value < 0.0 } else { value <= 0.0 })) {
return Err(format!("indicator_input_value_invalid:{}:{}", frame.symbol, frame.timestamps[index]));
}
if spec.indicator == "kdj" {
let (hi, lo, close) = (frame.fields["high"][index].unwrap(), frame.fields["low"][index].unwrap(), frame.fields["close"][index].unwrap());
if hi < lo || close < lo || close > hi { return Err(format!("indicator_ohlc_invalid:{}:{}", frame.symbol, frame.timestamps[index])); }
}
}
else if !series.admitted_missing_dates.contains(&frame.timestamps[index].date_naive()) {
return Err(format!("unclassified_indicator_input_gap:{}:{}", frame.symbol, frame.timestamps[index]));
}
}
let native_values = {
// The native KDJ has its own initialisation and lookback. Do
// not recreate it as STOCH or an independently seeded loop.
// Preserve admitted NULL rows too: the shared kernel defines
// gap/warmup semantics; compressing the calendar changes them.
let expression: Expr = serde_json::from_value(if spec.indicator == "kdj" {
json!({"kind":"indicator","name":"KDJ","inputs":[],
"parameters":{"optInFastK_Period":spec.window,"optInSlowK_Period":3,
"optInSlowK_MAType":13,"optInSlowD_Period":3,"optInSlowD_MAType":13},
"output":match spec.output.as_deref() { Some("k") => 0, Some("d") => 1, _ => 2 }})
} else {
json!({"kind":"indicator","name":if spec.indicator == "ma" {"SMA"} else {"EMA"},
"inputs":[{"kind":"field","name":spec.field}],"parameters":{"optInTimePeriod":spec.window}})
}).map_err(|error| error.to_string())?;
factor_events::evaluate(&expression, &frame)?.values
};
values.insert(key.clone(), indices.iter().map(|index| native_values[*index]).collect());
}
result.push(OutputSeries { symbol: frame.symbol, indices, values });
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn request() -> Request {
serde_json::from_value(json!({"specs":{"ma":{"indicator":"ma","field":"close","window":3},"ema":{"indicator":"ema","field":"close","window":3},"j":{"indicator":"kdj","field":"close","window":3,"output":"j"}},
"series":[{"frame":{"symbol":"000001.SZ","frequency":"1d","decision_at":"2026-09-10T17:00:00+08:00",
"timestamps":["2026-09-07T15:00:00+08:00","2026-09-08T15:00:00+08:00","2026-09-09T15:00:00+08:00","2026-09-10T15:00:00+08:00"],
"available_at":["2026-09-07T16:00:00+08:00","2026-09-08T16:00:00+08:00","2026-09-09T16:00:00+08:00","2026-09-10T16:00:00+08:00"],
"fields":{"close":[1.,2.,3.,4.],"high":[2.,3.,4.,5.],"low":[0.5,1.,2.,3.]}}}],
"output_start_date":"2026-09-09","output_end_date":"2026-09-10"})).unwrap()
}
#[test]
fn uses_shared_ma_ema_and_preserves_recursive_prefix_before_output_window() {
let rows = evaluate(request()).unwrap();
assert_eq!(rows[0].indices,vec![2,3]);
assert_eq!(rows[0].values["ma"],vec![Some(2.),Some(3.)]);
assert_eq!(rows[0].values["ema"],vec![Some(2.),Some(3.)]);
assert_eq!(rows[0].values["j"], vec![None, None]); // KDJ has a longer native lookback.
let mut later = request(); later.output_start_date = NaiveDate::from_ymd_opt(2026,9,10).unwrap();
assert_eq!(evaluate(later).unwrap()[0].values["ema"],vec![rows[0].values["ema"][1]]);
}
#[test]
fn kdj_uses_the_same_frozen_native_kernel_as_event_conditions() {
let mut data = request();
let frame = &mut data.series[0].frame;
for i in 4..16 {
let day = NaiveDate::from_ymd_opt(2026,9,7).unwrap() + chrono::Duration::days(i);
frame.timestamps.push(chrono::DateTime::parse_from_rfc3339(&format!("{day}T15:00:00+08:00")).unwrap());
frame.available_at.push(chrono::DateTime::parse_from_rfc3339(&format!("{day}T16:00:00+08:00")).unwrap());
frame.fields.get_mut("close").unwrap().push(Some(i as f64 + 1.));
frame.fields.get_mut("high").unwrap().push(Some(i as f64 + 2.));
frame.fields.get_mut("low").unwrap().push(Some(i as f64));
}
frame.decision_at = chrono::DateTime::parse_from_rfc3339("2026-09-23T17:00:00+08:00").unwrap();
data.output_start_date=NaiveDate::from_ymd_opt(2026,9,7).unwrap();
data.output_end_date=NaiveDate::from_ymd_opt(2026,9,22).unwrap();
let native:Expr=serde_json::from_value(json!({"kind":"indicator","name":"KDJ","inputs":[],"parameters":{"optInFastK_Period":3},"output":2})).unwrap();
let expected=factor_events::evaluate(&native,frame).unwrap().values;
assert!(expected.iter().any(Option::is_some));
assert_eq!(evaluate(data).unwrap()[0].values["j"],expected);
}
#[test]
fn missing_inputs_require_source_admission_and_future_inputs_fail() {
let mut data = request(); data.series[0].frame.fields.get_mut("close").unwrap()[1] = None;
assert!(evaluate(data).unwrap_err().contains("unclassified_indicator_input_gap"));
let mut data = request(); data.series[0].frame.available_at[3] = chrono::DateTime::parse_from_rfc3339("2026-09-11T16:00:00+08:00").unwrap();
assert!(evaluate(data).is_err());
let mut data=request();data.series[0].frame.fields.get_mut("close").unwrap()[1]=None;
data.series[0].admitted_missing_dates.insert(NaiveDate::from_ymd_opt(2026,9,8).unwrap());
let native:Expr=serde_json::from_value(json!({"kind":"indicator","name":"EMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}})).unwrap();
let expected=factor_events::evaluate(&native,&data.series[0].frame).unwrap().values;
assert_eq!(expected[2..], [None,None]);
assert_eq!(evaluate(data).unwrap()[0].values["ema"],expected[2..]);
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Durable intent progress, deliberately separate from actual-fill holding
//! protection. A published target starts no holding/protection timer.
use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::stock_pool_execution::{
Position, StockPoolMemberSpec, StockPoolPlan, normalize_stock_symbol,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolEntryProgress {
pub pending: bool,
pub observed_holding: bool,
pub first_decision_date: NaiveDate,
pub latest_generation: String,
pub latest_target_value: Decimal,
/// Fully funded entry goal, fixed at the last plan. Reconcile against
/// actual holdings before repricing, never against today's market value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_quantity: Option<Decimal>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolExecutionState {
pub schema_version: u32,
pub last_execution_date: Option<NaiveDate>,
pub entries: BTreeMap<String, StockPoolEntryProgress>,
#[serde(default)]
pub last_target_weights: BTreeMap<String, i32>,
/// First signal excluding an actually held member; not an acquisition date.
pub removed_since: BTreeMap<String, NaiveDate>,
}
pub struct StockPoolGoalObservation<'a> {
pub symbol: &'a str,
pub target_weight_bps: i32,
pub target_value: Decimal,
pub current_quantity: Decimal,
pub target_quantity: Decimal,
pub status: &'a str,
}
impl Default for StockPoolExecutionState {
fn default() -> Self {
Self {
schema_version: 1,
last_execution_date: None,
entries: BTreeMap::new(),
last_target_weights: BTreeMap::new(),
removed_since: BTreeMap::new(),
}
}
}
impl StockPoolExecutionState {
pub fn validate(&self) -> Result<(), String> {
if self.schema_version != 1
|| self.entries.len() > 10000
|| self.removed_since.len() > 10000
{
return Err("stock_pool_execution_state_invalid_schema_or_size".into());
}
for symbol in self
.entries
.keys()
.chain(self.removed_since.keys())
.chain(self.last_target_weights.keys())
{
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
return Err("stock_pool_execution_state_invalid_symbol".into());
}
}
if self.last_target_weights.len() > 10000
|| self
.last_target_weights
.values()
.any(|value| !(0..=10000).contains(value))
{
return Err("stock_pool_execution_state_invalid_weights".into());
}
if self.entries.values().any(|entry| {
entry.latest_target_value < Decimal::ZERO
|| entry.completion_quantity.is_some_and(|quantity| quantity <= Decimal::ZERO)
|| entry.latest_generation.is_empty()
|| self
.last_execution_date
.is_none_or(|last| entry.first_decision_date > last)
}) || self
.removed_since
.values()
.any(|day| self.last_execution_date.is_none_or(|last| *day > last))
{
return Err("stock_pool_execution_state_invalid_goal_or_clock".into());
}
Ok(())
}
pub fn observe(
&self,
decision_date: NaiveDate,
execution_date: NaiveDate,
official_dates: &[NaiveDate],
members: &[StockPoolMemberSpec],
positions: &[Position],
) -> Result<Self, String> {
self.validate()?;
if decision_date > execution_date
|| !official_dates.contains(&execution_date)
|| !official_dates.contains(&decision_date)
|| official_dates.windows(2).any(|pair| pair[0] >= pair[1])
|| self
.last_execution_date
.is_some_and(|last| last > execution_date)
{
return Err("stock_pool_execution_state_requires_monotone_official_clock".into());
}
let mut next = self.clone();
next.last_execution_date = Some(execution_date);
let members = members
.iter()
.map(|member| member.symbol.clone())
.collect::<BTreeSet<_>>();
let held = positions
.iter()
.filter(|position| position.quantity > Decimal::ZERO)
.map(|position| position.symbol.clone())
.collect::<BTreeSet<_>>();
next.entries.retain(|symbol, entry| {
// Confirmed flat starts a new cycle. A still-unfilled fresh target
// may remain pending while the latest pool still requests it.
!(entry.observed_holding && !held.contains(symbol))
&& (members.contains(symbol) || held.contains(symbol))
});
next.last_target_weights
.retain(|symbol, _| members.contains(symbol) || held.contains(symbol));
for (symbol, entry) in &mut next.entries {
entry.observed_holding |= held.contains(symbol);
if entry.pending
&& entry.completion_quantity.is_some_and(|goal| {
positions.iter().any(|position| {
&position.symbol == symbol && position.quantity >= goal
})
})
{
entry.pending = false;
}
}
next.removed_since
.retain(|symbol, _| held.contains(symbol) && !members.contains(symbol));
for symbol in held.difference(&members) {
next.removed_since
.entry(symbol.clone())
.or_insert(decision_date);
}
next.validate()?;
Ok(next)
}
pub fn pending_symbols(&self) -> BTreeSet<String> {
self.entries
.iter()
.filter(|(_, entry)| entry.pending)
.map(|(symbol, _)| symbol.clone())
.collect()
}
pub fn next_day_exit_symbols(&self, execution_date: NaiveDate) -> BTreeSet<String> {
self.removed_since
.iter()
.filter(|(_, removed)| **removed < execution_date)
.map(|(symbol, _)| symbol.clone())
.collect()
}
pub fn record_plan(
&self,
decision_date: NaiveDate,
generation: &str,
plan: &StockPoolPlan,
) -> Result<Self, String> {
self.record_targets(
decision_date,
generation,
plan.rows.iter().map(|row| StockPoolGoalObservation {
symbol: &row.symbol,
target_weight_bps: row.target_weight_bps,
target_value: row.target_value,
current_quantity: row.current_quantity,
target_quantity: row.target_quantity,
status: &row.status,
}),
)
}
pub fn record_targets<'a>(
&self,
decision_date: NaiveDate,
generation: &str,
rows: impl IntoIterator<Item = StockPoolGoalObservation<'a>>,
) -> Result<Self, String> {
self.validate()?;
if generation.is_empty()
|| self
.last_execution_date
.is_none_or(|date| decision_date > date)
{
return Err("stock_pool_execution_state_plan_clock_invalid".into());
}
let mut next = self.clone();
for row in rows {
if row.target_weight_bps > 0 {
next.last_target_weights
.insert(row.symbol.into(), row.target_weight_bps);
}
let eligible = row.target_weight_bps > 0 && row.target_value > Decimal::ZERO;
let completion_quantity = (row.status == "READY"
&& row.target_quantity > row.current_quantity)
.then_some(row.target_quantity);
let satisfied = matches!(
row.status,
"ALREADY_SATISFIED"
| "ENTRY_TARGET_ALREADY_SATISFIED"
| "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED"
);
if row.current_quantity == Decimal::ZERO && eligible && satisfied {
next.entries.remove(row.symbol);
continue;
}
if let Some(entry) = next.entries.get_mut(row.symbol) {
entry.latest_generation = generation.into();
entry.latest_target_value = row.target_value;
if entry.pending && completion_quantity.is_some() {
entry.completion_quantity = completion_quantity;
}
entry.observed_holding |= row.current_quantity > Decimal::ZERO;
if entry.pending && eligible && satisfied {
entry.pending = false;
}
} else if eligible && row.current_quantity == Decimal::ZERO && !satisfied {
next.entries.insert(
row.symbol.into(),
StockPoolEntryProgress {
pending: true,
observed_holding: false,
first_decision_date: decision_date,
latest_generation: generation.into(),
latest_target_value: row.target_value,
completion_quantity,
},
);
}
}
next.validate()?;
Ok(next)
}
}
+5
View File
@@ -1070,6 +1070,9 @@ pub enum TargetPortfolioOrderPricing {
#[derive(Debug, Clone)]
pub enum OrderIntent {
StockPool {
contract: Box<crate::stock_pool_execution::FrozenStockPoolIntent>,
},
WithTimeInForce {
intent: Box<OrderIntent>,
time_in_force: OrderTimeInForce,
@@ -1231,6 +1234,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 +1315,7 @@ impl OrderIntent {
pub fn supports_time_in_force(&self, time_in_force: OrderTimeInForce) -> bool {
let intent = self.unwrapped();
if matches!(intent, Self::StockPool { .. }) { return time_in_force == OrderTimeInForce::Day; }
if matches!(
intent,
Self::CancelOrder { .. }
@@ -3080,23 +3080,24 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
)
.expect("broker execution");
assert_eq!(report.fill_events.len(), 3);
assert_eq!(report.fill_events.len(), 2);
assert_eq!(
report
.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>(),
300
200
);
assert!(report.fill_events.iter().all(|fill| fill.quantity == 100));
assert!(report.fill_events.iter().all(|fill| fill.execution_timestamp.is_some_and(|at| at >= date.and_hms_opt(10, 15, 0).unwrap())));
assert_eq!(
report
.process_events
.iter()
.filter(|event| event.kind == ProcessEventKind::Trade)
.count(),
3
2
);
}
@@ -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"
}
@@ -0,0 +1,632 @@
use chrono::NaiveDate;
use fidc_core::stock_pool_execution::*;
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
FidcRiskControlConfig, Instrument, MatchingType, OrderIntent, PlatformExprStrategy,
PortfolioState, PriceField, StrategyDecision, platform_expr_config_from_value,
};
use rust_decimal::Decimal;
use std::collections::{BTreeMap, BTreeSet};
fn day(n: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 1, n).unwrap()
}
fn code(n: usize) -> String {
format!("{n:06}.SZ")
}
fn data(low_volume: bool) -> DataSet {
data_with_first_volume(if low_volume { 100 } else { 1_000_000 })
}
fn data_with_first_volume(first_volume: u64) -> DataSet {
data_with_suspension(first_volume, None)
}
fn data_with_suspension(first_volume: u64, suspension: Option<NaiveDate>) -> DataSet {
data_with_fund_rules(first_volume, suspension, false)
}
fn data_with_fund_rules(
first_volume: u64,
suspension: Option<NaiveDate>,
fund_rules: bool,
) -> DataSet {
let mut instruments: Vec<Instrument> = (1..=2)
.map(|n| Instrument {
symbol: code(n),
name: code(n),
board: if fund_rules && n == 2 {
"ETF".into()
} else {
"SZ".into()
},
round_lot: 100,
listed_at: Some(day(1)),
delisted_at: None,
status: "active".into(),
})
.collect();
instruments.push(Instrument {
symbol: "000300.SH".into(),
name: "fixture reference index".into(),
board: "INDEX".into(),
round_lot: 1,
listed_at: Some(day(1)),
delisted_at: None,
status: "active".into(),
});
let mut market = Vec::new();
let mut candidates = Vec::new();
for date in [day(2), day(5), day(6)] {
for n in 1..=2 {
let price = if fund_rules && n == 2 {
0.934
} else if n == 1 && date >= day(5) {
20.0
} else {
10.0
};
market.push(DailyMarketSnapshot {
date,
symbol: code(n),
timestamp: None,
day_open: price,
open: price,
high: price,
low: price,
close: price,
last_price: price,
bid1: price,
ask1: price,
prev_close: 10.,
volume: if n == 1 { first_volume } else { 1_000_000 },
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: None,
paused: n == 2 && suspension == Some(date),
upper_limit: 100.,
lower_limit: 0.1,
price_tick: if fund_rules && n == 2 { 0.001 } else { 0.01 },
});
candidates.push(CandidateEligibility {
date,
symbol: code(n),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: n == 2 && suspension == Some(date),
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
});
}
let mut reference = market.last().unwrap().clone();
reference.symbol = "000300.SH".into();
reference.day_open = 100.;
reference.open = 100.;
reference.high = 100.;
reference.low = 100.;
reference.close = 100.;
reference.last_price = 100.;
reference.bid1 = 100.;
reference.ask1 = 100.;
reference.prev_close = 100.;
reference.upper_limit = 1000.;
market.push(reference);
}
let benchmarks = [day(2), day(5), day(6)]
.into_iter()
.map(|date| BenchmarkSnapshot {
date,
benchmark: "000300.SH".into(),
open: 100.,
close: 100.,
prev_close: 100.,
volume: 1_000_000,
})
.collect();
let factors = [day(2), day(5), day(6)]
.into_iter()
.flat_map(|date| {
(1..=2).map(move |n| DailyFactorSnapshot {
date,
symbol: code(n),
market_cap_bn: 10.,
free_float_cap_bn: 10.,
pe_ttm: 10.,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.),
extra_factors: Default::default(),
})
})
.collect();
DataSet::from_components(instruments, market, factors, candidates, benchmarks).unwrap()
}
fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
let mut risk = FidcRiskControlConfig::default();
risk.trading_constraints.commission_rate = 0.;
risk.trading_constraints.minimum_commission = 0.;
risk.trading_constraints.transfer_fee_rate = 0.;
risk.trading_constraints.stamp_tax_rate_before_change = 0.;
risk.trading_constraints.stamp_tax_rate_after_change = 0.;
risk.trading_constraints.volume_limit_enabled = volume;
risk.trading_constraints.volume_percent = 0.25;
risk.trading_constraints.liquidity_limit_enabled = false;
BrokerSimulator::new(
ChinaAShareCostModel::from_trading_constraints(risk.trading_constraints),
ChinaEquityRuleHooks,
)
.with_matching_type(MatchingType::NextBarOpen)
.with_risk_config(risk)
}
fn contract(signal: NaiveDate, target: usize, preserve: bool) -> FrozenStockPoolIntent {
let symbols = vec![code(1), code(2)];
FrozenStockPoolIntent {
pool_id: "fixture-pool".into(),
signal_date: signal,
frozen_equity: Decimal::from(30000),
selection: StockPoolSelection {
trade_date: signal,
requested_symbols: symbols.clone(),
normal_trading_symbols: symbols.clone(),
risk_eligible_symbols: symbols,
final_symbols: vec![code(target)],
exclusion_reasons: BTreeMap::new(),
inherited_from_generation: None,
explicit_empty: false,
generation: Some(format!("g-{signal}")),
},
members: (1..=2)
.map(|n| StockPoolMemberSpec {
symbol: code(n),
requested_order: n as i32,
recommendation_reason: String::new(),
target_weight_bps: None,
stop_loss: None,
take_profit: None,
})
.collect(),
rule: StockPoolExecutionRule {
pricing_mode: POOL_PRICE_FIRST_TICK.into(),
..Default::default()
},
constraints: StockPoolDecisionConstraints {
target_holding_count: Some(1),
portfolio_policy: Some(StockPoolPortfolioPolicy {
schema_version: 1,
membership: MembershipPolicy::FollowCandidates,
rebalance_weights: !preserve,
}),
..Default::default()
},
invest_ratio_bps: 10000,
reserve_cash: Decimal::ZERO,
out_of_pool_policy: "hold".into(),
generation: format!("g-{signal}"),
}
}
fn decision(contract: FrozenStockPoolIntent) -> StrategyDecision {
StrategyDecision {
order_intents: vec![OrderIntent::StockPool {
contract: Box::new(contract),
}],
..Default::default()
}
}
#[test]
fn paused_execution_day_keeps_the_prior_slot_and_never_submits_an_exit() {
let data = data_with_suspension(1_000_000, Some(day(6)));
let broker = broker(false);
let mut account = PortfolioState::new(30_000.);
let first = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(contract(day(2), 2, false)),
)
.unwrap();
assert_eq!(first.fill_events.len(), 1);
let quantity = account.position(&code(2)).unwrap().quantity;
let mut replacement = contract(day(5), 1, false);
replacement
.members
.retain(|member| member.symbol != code(2));
replacement
.selection
.requested_symbols
.retain(|symbol| symbol != &code(2));
replacement.out_of_pool_policy = "reduce_to_zero_when_sellable".into();
let paused = broker
.execute_with_event_dates(
day(6),
day(5),
day(5),
&mut account,
&data,
&decision(replacement),
)
.unwrap();
assert!(paused.fill_events.is_empty(), "{paused:?}");
assert_eq!(account.position(&code(2)).unwrap().quantity, quantity);
assert!(
paused
.diagnostics
.iter()
.any(|line| line.contains("MARKET_SUSPENDED"))
);
}
#[test]
fn mixed_fund_and_stock_round_trip_uses_declared_ticks_and_asset_specific_fees() {
let data = data_with_fund_rules(1_000_000, None, true);
let mut costs = ChinaAShareCostModel::default();
costs.set_transfer_fee_rate(0.00001);
let broker = BrokerSimulator::new(costs, ChinaEquityRuleHooks)
.with_matching_type(MatchingType::NextBarOpen);
let mut account = PortfolioState::new(30_000.);
let mut entry = contract(day(2), 1, false);
entry.selection.final_symbols = vec![code(1), code(2)];
entry.constraints.target_holding_count = Some(2);
entry.rule.buy_offset_bps = 1;
entry.rule.sell_offset_bps = -1;
let buys = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(entry.clone()),
)
.unwrap();
assert_eq!(buys.fill_events.len(), 2, "{buys:?}");
let fund = buys
.fill_events
.iter()
.find(|fill| fill.symbol == code(2))
.unwrap();
assert_eq!(fund.quantity, 16000);
assert_eq!(fund.price, 0.934);
assert_eq!(fund.stamp_tax, 0.);
assert_eq!(fund.transfer_fee, 0.);
let stock = buys
.fill_events
.iter()
.find(|fill| fill.symbol == code(1))
.unwrap();
assert_eq!(stock.quantity, 700);
assert_eq!(stock.transfer_fee, 0.14);
entry.signal_date = day(5);
entry.selection.trade_date = day(5);
entry.generation = "exit".into();
entry.invest_ratio_bps = 0;
let sells = broker
.execute_with_event_dates(
day(6),
day(5),
day(5),
&mut account,
&data,
&decision(entry),
)
.unwrap();
assert_eq!(sells.fill_events.len(), 2, "{sells:?}");
let fund = sells
.fill_events
.iter()
.find(|fill| fill.symbol == code(2))
.unwrap();
assert_eq!(fund.stamp_tax, 0.);
assert_eq!(fund.transfer_fee, 0.);
let stock = sells
.fill_events
.iter()
.find(|fill| fill.symbol == code(1))
.unwrap();
assert_eq!(stock.stamp_tax, 7.);
assert_eq!(stock.transfer_fee, 0.14);
assert!(
(account.cash() - 29972.72).abs() < 0.000001,
"cash={}",
account.cash()
);
assert!(
account
.positions()
.values()
.all(|position| position.quantity == 0)
);
}
#[test]
fn new_daily_target_sells_old_member_then_buys_using_frozen_equity() {
let data = data(false);
let broker = broker(false);
let mut account = PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(2), 1000, 10.);
let report = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(contract(day(2), 2, false)),
)
.unwrap();
assert_eq!(report.fill_events.len(), 2, "{report:?}");
assert!(account.position(&code(1)).is_none_or(|p| p.quantity == 0));
assert_eq!(account.position(&code(2)).unwrap().quantity, 3000);
assert!(
(account.cash() - 10000.).abs() < 1e-8,
"next-open equity must not replace the frozen 30000 budget"
);
let unique = report
.fill_events
.iter()
.map(|fill| (fill.symbol.clone(), format!("{:?}", fill.side)))
.collect::<BTreeSet<_>>();
assert_eq!(unique.len(), 2);
let next = broker
.execute_with_event_dates(
day(6),
day(5),
day(5),
&mut account,
&data,
&decision(contract(day(5), 2, true)),
)
.unwrap();
assert!(
next.fill_events.is_empty(),
"preserved shares must not be rebalanced: {next:?}"
);
}
#[test]
fn partial_sell_does_not_release_a_slot_or_authorize_replacement() {
let data = data(true);
let broker = broker(true).with_matching_type(MatchingType::CurrentBarClose);
let mut account = PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(2), 1000, 10.);
let report = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(contract(day(2), 2, false)),
)
.unwrap();
assert!(account.position(&code(2)).is_none());
assert_eq!(account.position(&code(1)).unwrap().quantity, 975);
assert!(
report
.diagnostics
.iter()
.any(|text| text.contains("DEFERRED_POSITION_SLOTS"))
);
}
#[test]
fn actual_fill_protection_is_evaluated_on_execution_date() {
let data = data(false);
let broker = broker(false);
let mut account = PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(2), 1000, 10.);
let mut intent = contract(day(2), 2, false);
intent.rule.automatic_trade_protection.buy_protection_days = 3;
let report = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(intent),
)
.unwrap();
assert!(report.fill_events.is_empty(), "{report:?}");
assert_eq!(account.position(&code(1)).unwrap().quantity, 1000);
}
#[test]
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
let intent = contract(day(2), 1, false);
for quote_condition in ["", "price<5"] {
let program = StockPoolProgram {
schema_version: 1,
pool_id: "pool-fixture".into(),
version_id: "version-fixture".into(),
members: intent.members.clone(),
allocation_policy: serde_json::json!({"target_holding_count":1,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":false}}),
// Disabled natural trading must not disable an explicitly requested historical backtest.
timing_policy: serde_json::json!({"auto_execute":false,"pricing_mode":"first_tick","buy_condition":quote_condition}),
stop_take_policy: serde_json::json!({"stop_loss":null,"take_profit":0}),
out_of_pool_policy: "hold".into(),
};
let mut config=platform_expr_config_from_value("pool-fixture",&code(1),&serde_json::json!({
"stockPool":program,"signalSymbol":code(1),"benchmark":{"instrumentId":"000300.SH"},"universe":{"include":[code(1),code(2)]}
})).unwrap();
config.market_cap_field = "close".into();
config.market_cap_lower_expr = "0".into();
config.market_cap_upper_expr = "1000000000000".into();
config.stock_filter_expr = "true".into();
config.selection_limit_expr = "1".into();
config.selection_candidate_limit_expr = "2".into();
config.rank_expr = format!(
"decision_date == \"2026-01-02\" ? (symbol == \"{}\" ? 0 : 1) : (symbol == \"{}\" ? 0 : 1)",
code(1),
code(2)
);
config.matching_type = MatchingType::CurrentBarClose;
let result = BacktestEngine::new(
data(false),
PlatformExprStrategy::new(config),
broker(false).with_matching_type(MatchingType::CurrentBarClose),
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(6)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.run()
.unwrap();
if quote_condition.is_empty() {
assert_eq!(
result.fills.len(),
3,
"fills={:#?}, decisions={:#?}, days={:#?}",
result.fills,
result.risk_decisions,
result
.equity_curve
.iter()
.map(|point| (&point.date, &point.diagnostics))
.collect::<Vec<_>>()
);
assert_eq!(result.fills[0].symbol, code(1));
assert_eq!(result.fills[1].symbol, code(1));
assert_eq!(result.fills[2].symbol, code(2));
assert_eq!(result.fills[2].quantity, 6000);
} else {
assert!(
result.fills.is_empty(),
"configured quote condition must reach the actual executor"
);
}
}
}
#[test]
fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() {
// Generated by OmniQuant's actual handoff and compiler, not a hand-written
// replacement runtimeExpressions contract. It used to inject 0.93/1.07.
let spec: serde_json::Value = serde_json::from_str(include_str!(
"fixtures/stock_pool_disabled_stops_compiled.json"
))
.unwrap();
let config =
platform_expr_config_from_value("fixture_hold_without_stops", "000300.SH", &spec).unwrap();
assert!(config.stop_loss_expr.is_empty());
assert!(config.take_profit_expr.is_empty());
let result = BacktestEngine::new(
data(false),
PlatformExprStrategy::new(config),
broker(false).with_matching_type(MatchingType::CurrentBarClose),
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(6)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.run()
.unwrap();
assert_eq!(
result.fills.len(),
2,
"stock one doubles in price, but disabled stops and weight rebalancing must not sell it: {:?}",
result.fills
);
assert!(
result
.fills
.iter()
.all(|fill| fill.side == fidc_core::OrderSide::Buy)
);
assert_eq!(result.equity_curve.len(), 3);
}
#[test]
fn partial_backtest_fills_do_not_turn_into_completed_preserved_holdings() {
let data = data_with_first_volume(2000);
let broker = broker(true).with_matching_type(MatchingType::CurrentBarClose);
let mut account = PortfolioState::new(30000.);
let first = broker
.execute_with_event_dates(
day(2),
day(2),
day(2),
&mut account,
&data,
&decision(contract(day(2), 1, true)),
)
.unwrap();
let partial = account
.position(&code(1))
.map(|position| position.quantity)
.unwrap_or(0);
assert!(partial > 0 && partial < 3000, "{first:?}");
let second = broker
.execute_with_event_dates(
day(5),
day(5),
day(5),
&mut account,
&data,
&decision(contract(day(5), 1, true)),
)
.unwrap();
assert!(
account.position(&code(1)).unwrap().quantity > partial,
"partial entry must continue on the next valid execution: {second:?}"
);
}
#[test]
fn next_day_outside_policy_executes_after_the_first_exclusion_signal() {
let data = data(false);
let broker = broker(false).with_matching_type(MatchingType::CurrentBarClose);
let mut account = PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(1), 1000, 10.);
let outside = |signal| {
let mut value = contract(signal, 2, true);
value.members.retain(|member| member.symbol == code(2));
value.selection.requested_symbols = vec![code(2)];
value.selection.normal_trading_symbols = vec![code(2)];
value.selection.risk_eligible_symbols = vec![code(2)];
value.out_of_pool_policy = "reduce_next_trading_day".into();
value
};
let first = broker
.execute_with_event_dates(
day(2),
day(2),
day(2),
&mut account,
&data,
&decision(outside(day(2))),
)
.unwrap();
assert!(first.fill_events.is_empty(), "{first:?}");
let next = broker
.execute_with_event_dates(
day(5),
day(5),
day(5),
&mut account,
&data,
&decision(outside(day(5))),
)
.unwrap();
assert!(
account
.position(&code(1))
.is_none_or(|position| position.quantity == 0),
"{next:?}"
);
assert_eq!(account.position(&code(2)).unwrap().quantity, 3000);
}
@@ -0,0 +1,280 @@
use chrono::NaiveDate;
use fidc_core::stock_pool_execution::*;
use fidc_core::stock_pool_state::StockPoolExecutionState;
use rust_decimal::Decimal;
use serde_json::json;
use std::collections::BTreeMap;
fn day(value: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 9, value).unwrap()
}
fn member() -> StockPoolMemberSpec {
StockPoolMemberSpec {
symbol: "000001.SZ".into(),
requested_order: 0,
recommendation_reason: String::new(),
target_weight_bps: None,
stop_loss: None,
take_profit: None,
}
}
fn held(quantity: i64, closable: i64) -> Position {
Position {
symbol: "000001.SZ".into(),
quantity: quantity.into(),
closable_quantity: closable.into(),
average_cost: 10.into(),
}
}
fn quote() -> MarketSnapshot {
MarketSnapshot {
symbol: "000001.SZ".into(),
last_price: 10.into(),
prev_close: Some(10.into()),
volume: Some(1000000.into()),
turnover: Some(10000000.into()),
bid_price_1: Some(10.into()),
ask_price_1: Some(10.into()),
is_kcb: Some(false),
instrument_rules: None,
buy_sizing_price: None,
sell_sizing_price: None,
}
}
fn plan(
state: &StockPoolExecutionState,
at: NaiveDate,
members: &[StockPoolMemberSpec],
positions: &[Position],
cash: i64,
outside: &str,
) -> StockPoolPlan {
plan_at_price(state, at, members, positions, cash, outside, 10)
}
fn plan_at_price(
state: &StockPoolExecutionState,
at: NaiveDate,
members: &[StockPoolMemberSpec],
positions: &[Position],
cash: i64,
outside: &str,
price: i64,
) -> StockPoolPlan {
let symbols = members
.iter()
.map(|member| member.symbol.clone())
.collect::<Vec<_>>();
let selection = StockPoolSelection {
trade_date: at,
requested_symbols: symbols.clone(),
normal_trading_symbols: symbols.clone(),
risk_eligible_symbols: symbols.clone(),
final_symbols: symbols,
exclusion_reasons: BTreeMap::new(),
inherited_from_generation: None,
explicit_empty: false,
generation: Some("same-goal".into()),
};
let mut constraints = stock_pool_constraints_from_configuration(
&json!({"top_n_rebalance_policy":"preserve_existing"}),
&json!({}),
)
.unwrap();
constraints.pending_entry_symbols = state.pending_symbols();
constraints.next_day_outside_exit_symbols = state.next_day_exit_symbols(at);
let mut market = quote();
market.last_price = price.into();
market.bid_price_1 = Some(price.into());
market.ask_price_1 = Some(price.into());
build_stock_pool_target_plan_with_constraints(
&selection,
members,
&StockPoolExecutionRule::default(),
&AccountSnapshot {
total_equity: 10000.into(),
cash: cash.into(),
frozen_cash: Decimal::ZERO,
},
positions,
&[market],
10000,
Decimal::ZERO,
outside,
"preserve_existing",
&constraints,
"same-goal",
Decimal::ZERO,
Decimal::ZERO,
Decimal::ZERO,
)
.unwrap()
}
#[test]
fn fully_filled_entry_is_not_reopened_when_price_falls_before_next_observation() {
let members = vec![member()];
let calendar = vec![day(11), day(14)];
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
let first_plan = plan(&initial, day(11), &members, &[], 10000, "hold");
let state = initial.record_plan(day(11), "first-entry", &first_plan).unwrap();
assert_eq!(state.entries["000001.SZ"].completion_quantity, Some(1000.into()));
let state: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
let observed = state.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)]).unwrap();
assert!(!observed.pending_symbols().contains("000001.SZ"));
let next = plan_at_price(&observed, day(14), &members, &[held(1000, 1000)], 1000, "hold", 8);
assert_eq!(next.rows[0].target_quantity, 1000.into());
assert_eq!(next.rows[0].delta_quantity, Decimal::ZERO);
assert_eq!(next.rows[0].status, "PRESERVED_EXISTING_POSITION");
}
#[test]
fn cash_clipped_or_rejected_entry_does_not_claim_full_completion() {
let members = vec![member()];
let calendar = vec![day(11), day(14)];
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
let first_plan = plan(&initial, day(11), &members, &[], 5000, "hold");
assert_eq!(first_plan.rows[0].status, "REDUCE_TO_ALLOWED_QUANTITY");
let state = initial.record_plan(day(11), "limited-entry", &first_plan).unwrap();
assert_eq!(state.entries["000001.SZ"].completion_quantity, None);
for quantity in [0, 500] {
let positions = if quantity == 0 { vec![] } else { vec![held(quantity, quantity)] };
let observed = state.observe(day(14), day(14), &calendar, &members, &positions).unwrap();
assert!(observed.pending_symbols().contains("000001.SZ"));
}
}
#[test]
fn legacy_state_without_quantity_keeps_its_serialized_identity() {
let original = json!({
"schema_version":1,"last_execution_date":"2026-09-11",
"entries":{"000001.SZ":{"pending":true,"observed_holding":false,
"first_decision_date":"2026-09-11","latest_generation":"legacy",
"latest_target_value":"10000"}},"last_target_weights":{},"removed_since":{}
});
let state: StockPoolExecutionState = serde_json::from_value(original.clone()).unwrap();
state.validate().unwrap();
assert_eq!(serde_json::to_value(state).unwrap(), original);
}
#[test]
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
let members = vec![member()];
let calendar = vec![day(11), day(14)];
let first = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &members, &[])
.unwrap();
let initial = plan(&first, day(11), &members, &[], 10000, "hold");
assert_eq!(initial.rows[0].target_quantity, 1000.into());
let pending = first.record_plan(day(11), "same-goal", &initial).unwrap();
assert!(pending.entries["000001.SZ"].pending);
assert!(!pending.entries["000001.SZ"].observed_holding);
let persisted = serde_json::to_vec(&pending).unwrap();
let restored: StockPoolExecutionState = serde_json::from_slice(&persisted).unwrap();
let partial = restored
.observe(day(11), day(11), &calendar, &members, &[held(500, 0)])
.unwrap();
let retry = plan(&partial, day(11), &members, &[held(500, 0)], 5000, "hold");
assert_eq!(retry.rows[0].delta_quantity, 500.into(), "{retry:?}");
let pending = partial.record_plan(day(11), "same-goal", &retry).unwrap();
assert!(pending.entries["000001.SZ"].pending);
let filled = pending
.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)])
.unwrap();
let satisfied = plan(&filled, day(14), &members, &[held(1000, 1000)], 0, "hold");
assert_eq!(satisfied.rows[0].status, "PRESERVED_EXISTING_POSITION");
let completed = filled.record_plan(day(14), "new-day", &satisfied).unwrap();
assert!(!completed.entries["000001.SZ"].pending);
assert_eq!(
plan(
&completed,
day(14),
&members,
&[held(1000, 1000)],
0,
"hold"
)
.rows[0]
.status,
"PRESERVED_EXISTING_POSITION"
);
}
#[test]
fn removal_anchor_is_not_reset_by_rechecks_weekends_or_t_plus_one() {
let calendar = vec![day(11), day(14), day(15)];
let positions = vec![held(1000, 1000)];
let removed = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &[], &positions)
.unwrap();
assert_eq!(removed.removed_since["000001.SZ"], day(11));
assert_eq!(
plan(
&removed,
day(11),
&[],
&positions,
0,
"reduce_next_trading_day"
)
.rows[0]
.status,
"DEFERRED_T_PLUS_ONE"
);
assert!(
removed
.observe(day(12), day(12), &calendar, &[], &positions)
.is_err()
);
let restored: StockPoolExecutionState =
serde_json::from_str(&serde_json::to_string(&removed).unwrap()).unwrap();
let monday = restored
.observe(day(14), day(14), &calendar, &[], &[held(1000, 0)])
.unwrap();
assert_eq!(monday.removed_since["000001.SZ"], day(11));
assert_eq!(
plan(
&monday,
day(14),
&[],
&[held(1000, 0)],
0,
"reduce_next_trading_day"
)
.rows[0]
.delta_quantity,
Decimal::ZERO
);
let next = monday
.observe(day(15), day(15), &calendar, &[], &positions)
.unwrap();
let exit = plan(
&next,
day(15),
&[],
&positions,
0,
"reduce_next_trading_day",
);
assert_eq!(exit.rows[0].target_quantity, Decimal::ZERO);
assert_eq!(exit.rows[0].side, Some(OrderSide::Sell));
let returned = next
.observe(day(15), day(15), &calendar, &[member()], &positions)
.unwrap();
assert!(returned.removed_since.is_empty());
}
#[test]
fn cloned_preview_does_not_start_a_timer_and_next_open_uses_signal_removal_date() {
let state = StockPoolExecutionState::default();
let calendar = vec![day(11), day(14)];
let preview = state
.observe(day(11), day(14), &calendar, &[], &[held(1000, 1000)])
.unwrap();
assert!(state.removed_since.is_empty());
assert!(preview.next_day_exit_symbols(day(14)).contains("000001.SZ"));
let mut invalid = preview;
invalid.schema_version = 0;
assert!(invalid.validate().is_err());
}
@@ -0,0 +1,73 @@
# FIDC执行合同与开源框架审查
状态:源码审查和部分通用修复已完成。尚未完成多策略长区间A/B;不能据此宣称某个框架性能最优或FIDC生产闭环已通过。
## 固定版本
源码保存在`/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks`,未执行第三方项目的策略或交易程序。
| 框架 | 审查版本 | 主要参考位置 |
|---|---|---|
| NautilusTrader | 6f48cd8e713b72b878626e9a9757c6e4a026b708 | [报价类型](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/nautilus_trader/crates/model/src/data/quote.rs:51)、[归并](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/nautilus_trader/crates/backtest/src/node.rs:442) |
| LEAN | 8ee075a39918f2df6fe9e0a5944e366fb60d10dc | [股票撮合](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/Lean/Common/Orders/Fills/EquityFillModel.cs:482) |
| Zipline Reloaded | 943010b9da848e317fc520de87edade2b884d329 | [滑点与容量](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/zipline-reloaded/src/zipline/finance/slippage.py:464) |
| Backtrader | b853d7c90b6721476eb5a5ea3135224e33db1f14 | [Broker](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/backtrader/backtrader/brokers/bbroker.py:228) |
## 可采用的设计
### 事件与时钟
Nautilus把Quote、Trade、Bar区分为不同类型,保存事件与接收/初始化时钟;数据归并按`ts_init`排序,分块保留同一时间戳的完整组。这适合FIDC后续有界分钟流:Source声明观测类型、覆盖区间和可见时间,执行端持有独立账户状态,而非共享一个同时装着日终与盘中字段的可变字典。
FIDC本轮实际发现:以09:32:58行情给09:33订单定价时,成交时间也写成09:32:58。已修为实际成交不得早于订单执行时钟;价格仍引用原行情,容量扣减也继续绑定原行情,不能通过换一个订单时钟重新获得额度。
### 开盘和陈旧行情
LEAN的MarketOnOpenFill单独处理正式开盘成交标志、bar开始/结束时间和订单时间;不是任意取一个日线字段即可成交。但其默认模型也有整体成交和数据回退假设,不能直接当作真实市场流动性保证。
FIDC已统一预估与撮合的盘中价格选择。己方价、对手价和最近成交价各用自己的字段;缺失/无效时不改用另一种价,也不越过最新坏行情寻找更旧的可用价格。
### 成交量与滑点
Zipline按股票和bar累计已使用量,历史冲击窗口排除当前未完成交易日。FIDC应复用不可变历史校准,但每个账户自己的成交消耗必须独立;不能按订单次数重复分配同一个bar的额度。它在历史不足时的回退不是FIDC的默认准入策略。
FIDC新增`ParticipationRate`:按冻结JSON小数语义精确向下取整,避免浮点乘法和窄整数转换误差。例如58%乘50股为29,不应因二进制误差成为28;巨大市场量也不能在转u32时截断成另一个额度。比例解析在配置阶段完成,不放入逐成交腿热循环。
Backtrader把volume filler、滑点开关、cheat-on-open/close等模型选择显式区分。值得采用的是明确暴露假设;不照搬不带成交量限制的默认值,也不为了收益对齐开启能改变可见时间的行为。
## 已落地通用修复
- 精确参与率计算用于Broker和策略预估,同一观测的已消耗量跨订单共享、跨账户隔离。
- 实际执行时钟与参考行情时钟分开,修复成交早于下单的问题。
- 缺失的执行价不由其他价格或更旧行情代替;删除重复价格选择及不可达深度分支。
- Runner拒绝空身份、错日期、晚于请求时钟的行情、非有限价格、缺失或非整数数量。
- 多查询引用同一观测只有完全一致才合并;同一目标查询的源重复行和跨查询冲突均报错,不采用第一条。
- 目录缓存使用Linux变更通知,不能只凭mtime/目录大小判断没有新文件;通知不可用时重新扫描。事件溢出、目录替换、重命名、删除均失效重建。
这些改变不按策略ID、固定选股条件或10:17/10:18等时间分支。
## 仍未关闭的问题
1. **日线全天量仍进入早晨数量计算**Broker和projection两条路径都需接入容量模式。`VolumeCapacityMode``SessionCapacityAudit`目前只是已测试基础类型,未完成配置、撮合、日终结果和页面的完整接线。不得称P0已修。
2. **Source可能掩盖缺失**:当前目标分钟序列会先剔除无效close,再寻找旧的有效值;部分缺失volume/amount置0;备用Arrow包装器还会用请求时钟替代缺失行情时间。必须清理这些行为,并按数据/市场缺席合同分类。
3. **TWAP早期配额可能依赖未来可成交记录数**:当前实现使用筛选后的`eligible_quotes.len()`分配数量。应按冻结时间表切片,后续缺量只能影响后续执行,不改早期分配。
4. **集合竞价**:实时数据与日线/分钟模型必须区分委托进入竞价、等待撮合和实际成交,不能用竞价中间的零量判定股票停牌,不能提前使用最终清算价。
5. **数据种类/单位**:源观测类型与价格模型能力要明确;分钟OHLC不能冒充真实L1深度。盘口单位、名义bar区间和可见时间仍须逐源校验。
## 下一阶段实施合同
- 普通风险开关和参与率仍是同一个策略三端共享配置。历史回测的容量假设单独冻结,不能用研究审核模式绕过Paper/Live临单风控。
- 有真实可见观测时使用`execution_observation``completed_bar`须验证bar结束和可见时间,不靠日线日期猜15:00。
- 只有日线总量而没有开盘容量时,严格模式须在提交预检失败;用户可明确选择`session_capacity_audit`做研究。日终只判定容量验收,不回写早晨的数量、现金、持仓或净值。
- 不能自动关闭参与率限制,也不能悄悄替换为昨日量。
- Source、Runner、Engine、研究handoff、Strategy AI和UI成套发布;持有冻结合同的旧任务不改写。当前研究维护方尚未授权共享服务切换。
## 验证与基准
Engine当前680项通过、8忽略;Runner375项通过,真实Source的8个JSON/Arrow行情点通过严格解析。小样本中的14:59零量在serving Parquet中确实存在,15:00有成交;这是源事实核对,不是完整集合竞价逻辑验收。历史股票收盘竞价时段应按适用规则处理,不能将后来的规则回填到旧日期。[深交所2023年交易机制说明](https://investor.szse.cn/institute/rules/t20230629_601434.html)
上交所2026版规则于2026-07-06生效,且通知明确存在暂缓实施条文。后续交易时段/容量合同必须同时核对规则版本和实施范围。[上交所发布通知](https://www.sse.com.cn/lawandrules/sselawsrules2025/stocks/exchange/c/c_20260424_10816482.shtml)
下一组A/B应覆盖动态Top-N日线、静态组合、逐分钟策略、TWAP/VWAP及共享模型信号,分别检查同条件重复、时间变更、参数变更、短区间和五年区间。先关闭时点错误,再测准备/引擎/持久化/端到端耗时、峰值RSS、缓存代际与逐笔差异。不得用微型元数据读取速度外推完整回测性能。
本轮证据根:`/srv/fidc/canonical/run/research/historical-slippage-20260911/`。服务均未由本任务重启,没有提交回测任务或证券订单。