Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61bd14d001 | |||
| 531df39911 | |||
| e912e2a6f5 | |||
| b1f2fcb85c | |||
| 828690a51e | |||
| ffd23b9920 | |||
| 4ac9ee5058 | |||
| 848c1a514a | |||
| 099759ae67 | |||
| d646ca455d | |||
| 3b2a97fa84 | |||
| 2f2258f208 | |||
| 32a34fadd6 | |||
| 7ac87a90c4 | |||
| 5949d4cc69 | |||
| 24cb4805a7 | |||
| 053f880e34 | |||
| 4d3a9e0e5b | |||
| 4edc70c4c6 | |||
| 9804851133 | |||
| 6ffa0346aa | |||
| 29eafc79e2 | |||
| b6859a3360 | |||
| a00dea58b2 | |||
| 2445dc925a | |||
| 1e8d38f2ee | |||
| d2071e4311 | |||
| 0fba16342f | |||
| bd389de57f | |||
| bfbf898027 | |||
| 2b6d031a55 | |||
| 6160a74d2a | |||
| 4acecda79d | |||
| d847cb5c28 | |||
| fa0b316a8b | |||
| 21786187c9 |
Generated
+344
-8
@@ -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,18 +87,73 @@ 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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.59"
|
||||
@@ -100,7 +184,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -117,6 +201,20 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
@@ -239,11 +337,13 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
name = "fidc-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"ahash 0.8.12",
|
||||
"chrono",
|
||||
"compact_str",
|
||||
"indexmap",
|
||||
"rayon",
|
||||
"rhai",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -275,6 +375,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 +459,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 +711,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 +844,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 +871,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 +920,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
@@ -815,6 +968,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 +993,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 +1027,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 +1050,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 +1103,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 +1140,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 +1238,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 +1316,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 +1379,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 +1445,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 +1535,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 +1671,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 +1936,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 +1957,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"
|
||||
@@ -1738,3 +2068,9 @@ dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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` 对外提供策略运行服务。
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,8 @@ license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
compact_str = { version = "=0.10.0", features = ["serde"] }
|
||||
rust_decimal.workspace = true
|
||||
ahash.workspace = true
|
||||
chrono.workspace = true
|
||||
indexmap.workspace = true
|
||||
|
||||
+613
-256
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,674 @@
|
||||
//! 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 etf_activity(report:&mut BrokerExecutionReport,date:NaiveDate,symbol:&str,side:pool::OrderSide,detail:String) {
|
||||
report.process_events.push(ProcessEvent {date,kind:ProcessEventKind::EtfExecutionFallback,order_id:None,
|
||||
symbol:Some(symbol.into()),side:Some(if side==pool::OrderSide::Buy {OrderSide::Buy} else {OrderSide::Sell}),detail});
|
||||
}
|
||||
|
||||
fn pool_positions(
|
||||
portfolio: &PortfolioState,
|
||||
date: NaiveDate,
|
||||
) -> Result<Vec<pool::Position>, BacktestError> {
|
||||
portfolio
|
||||
.positions()
|
||||
.values()
|
||||
.filter(|p| p.quantity > 0)
|
||||
.map(|p| {
|
||||
Ok(pool::Position {
|
||||
symbol: p.symbol.clone(),
|
||||
quantity: Decimal::from(p.quantity),
|
||||
closable_quantity: Decimal::from(p.sellable_qty(date)),
|
||||
average_cost: decimal(p.average_cost, "position_cost")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
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 fallback = self.pool_etf_fallback_reference(date, data, symbol, execution_clock)?;
|
||||
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if let Some(reference) = fallback {
|
||||
let calibration = self.slippage_calibration(data, snapshot)?;
|
||||
(reference.price, snapshot.prev_close, None, None, None, None,
|
||||
self.quote_execution_price(snapshot, OrderSide::Buy, reference.price, None, calibration.as_ref())?,
|
||||
self.quote_execution_price(snapshot, OrderSide::Sell, reference.price, None, calibration.as_ref())?)
|
||||
} else if self
|
||||
.matching_type_uses_intraday_quotes()
|
||||
{
|
||||
let time = self
|
||||
.runtime_intraday_start_time
|
||||
.get()
|
||||
.or(self.intraday_execution_start_time)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"stock_pool_intraday_execution_clock_required".into(),
|
||||
)
|
||||
})?;
|
||||
let clock = execution_clock
|
||||
.unwrap_or(date.and_time(time))
|
||||
.max(date.and_time(time));
|
||||
let quote = data
|
||||
.execution_quotes_on(date, symbol)
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|quote| quote.timestamp <= clock)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"stock_pool_execution_quote_missing:{symbol}:{clock}"
|
||||
))
|
||||
})?;
|
||||
if !quote.last_price.is_finite() || quote.last_price <= 0.0 {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"stock_pool_execution_quote_invalid:{symbol}:{clock}"
|
||||
)));
|
||||
}
|
||||
let raw_buy = self
|
||||
.select_quote_reference_price(
|
||||
snapshot,
|
||||
quote,
|
||||
OrderSide::Buy,
|
||||
self.matching_type,
|
||||
)
|
||||
.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()
|
||||
}
|
||||
|
||||
fn pool_etf_fallback_reference(&self, date: NaiveDate, data: &DataSet, symbol: &str, clock: Option<NaiveDateTime>) -> Result<Option<crate::etf_execution::EtfFallbackReference>, BacktestError> {
|
||||
if !self.matching_type_uses_intraday_quotes() || !self.has_verified_etf_minute_absence(date, symbol) {
|
||||
return Ok(None);
|
||||
}
|
||||
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time)
|
||||
.ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: execution clock missing".into()))?;
|
||||
let at = clock.unwrap_or(date.and_time(time)).max(date.and_time(time));
|
||||
crate::etf_execution::reference(data, symbol, at).map(Some)
|
||||
}
|
||||
|
||||
pub(super) fn process_stock_pool_contract(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
contract: &pool::FrozenStockPoolIntent,
|
||||
intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut IntradayExecutionLedger,
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
commission_state: &mut BTreeMap<u64, f64>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
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)?;
|
||||
let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation);
|
||||
if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); }
|
||||
if self.has_open_orders() {
|
||||
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<_>>();
|
||||
// All delayed symbols in a generation share immutable configuration.
|
||||
// Do not duplicate an N-member pool N times in a large mixed pool.
|
||||
let mut deferred_configuration = None;
|
||||
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
|
||||
let mut fallback_references = BTreeMap::new();
|
||||
for symbol in "e_scope {
|
||||
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
|
||||
let condition = if side == pool::OrderSide::Buy { &contract.rule.buy_condition } else { &contract.rule.sell_condition };
|
||||
if !condition.trim().is_empty() {
|
||||
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: intraday condition evidence unavailable symbol={symbol} side={side:?}; daily reference is not a minute or tick signal")));
|
||||
}
|
||||
fallback_references.insert(symbol.clone(), reference);
|
||||
}
|
||||
}
|
||||
let quotes =
|
||||
self.pool_quote_inputs(date, data, "e_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.position_action_bases = execution_state.position_action_bases_for(&contract.generation);
|
||||
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
|
||||
let account = pool::AccountSnapshot {
|
||||
total_equity: contract.frozen_equity,
|
||||
cash: decimal(portfolio.cash(), "cash")?,
|
||||
frozen_cash: Decimal::ZERO,
|
||||
};
|
||||
constraints.automatic_permissions.clear();
|
||||
if contract.rule.automatic_trade_protection.enabled() {
|
||||
for symbol in &scope {
|
||||
let 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 side == pool::OrderSide::Buy {
|
||||
for (symbol, reference) in &fallback_references {
|
||||
if !reference.immediate {
|
||||
// The sell leg was queued, not filled. Keep its real
|
||||
// holdings/slots and do not finance buys with proceeds
|
||||
// from the following session.
|
||||
constraints.automatic_permissions.entry(symbol.clone()).or_default()
|
||||
.sell_denial.get_or_insert("etf_daily_open_deferred");
|
||||
}
|
||||
}
|
||||
}
|
||||
if self
|
||||
.risk_config
|
||||
.static_rules
|
||||
.forbid_same_day_rebuy_after_sell
|
||||
{
|
||||
constraints.same_day_sold_symbols.extend(
|
||||
self.same_day_sold_symbols
|
||||
.borrow()
|
||||
.get(&date)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
constraints.same_day_sold_symbols.extend(
|
||||
before_positions
|
||||
.iter()
|
||||
.filter(|symbol| portfolio.position(symbol).is_none_or(|p| p.quantity == 0))
|
||||
.cloned(),
|
||||
);
|
||||
let fee =
|
||||
|symbol: &str, side: pool::OrderSide, gross: Decimal| -> Result<Decimal, String> {
|
||||
let amount = gross
|
||||
.to_f64()
|
||||
.ok_or("stock_pool_cost_amount_out_of_range")?;
|
||||
decimal(
|
||||
self.cost_model
|
||||
.calculate_for_instrument(
|
||||
date,
|
||||
if side == pool::OrderSide::Buy {
|
||||
OrderSide::Buy
|
||||
} else {
|
||||
OrderSide::Sell
|
||||
},
|
||||
amount,
|
||||
data.instruments().get(symbol),
|
||||
)
|
||||
.total(),
|
||||
"fee",
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
};
|
||||
let plan = pool::build_stock_pool_target_plan_with_fee_model(
|
||||
&selection,
|
||||
&members,
|
||||
&contract.rule,
|
||||
&account,
|
||||
&positions,
|
||||
"es,
|
||||
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 mut updated = execution_state
|
||||
.record_plan(contract.signal_date, &contract.generation, &plan)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
for (symbol, reference) in &fallback_references {
|
||||
if !reference.immediate && let Some(entry) = updated.entries.get_mut(symbol) {
|
||||
// The signal only fixes money, not shares at a stale close.
|
||||
entry.completion_quantity = None;
|
||||
}
|
||||
}
|
||||
portfolio
|
||||
.set_stock_pool_execution_state(&contract.pool_id, updated)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
report.diagnostics.push(format!("stock_pool_plan phase={side:?} generation={} requested_bps={} effective_bps={} budget={}",contract.generation,plan.requested_invest_ratio_bps,plan.effective_invest_ratio_bps,plan.budget));
|
||||
let max_positions = constraints
|
||||
.target_holding_count
|
||||
.unwrap_or(selection.final_symbols.len());
|
||||
for row in plan.rows {
|
||||
if side == pool::OrderSide::Buy && row.side.is_none() {
|
||||
report.diagnostics.push(format!(
|
||||
"stock_pool_decision symbol={} status={} current={} target={} reason={}",
|
||||
row.symbol,
|
||||
row.status,
|
||||
row.current_quantity,
|
||||
row.target_quantity,
|
||||
row.reason
|
||||
));
|
||||
}
|
||||
if row.side != Some(side) {
|
||||
continue;
|
||||
}
|
||||
if let Some(reference) = fallback_references.get(&row.symbol) {
|
||||
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time).expect("fallback clock validated");
|
||||
let at = global_execution_cursor.unwrap_or(date.and_time(time)).max(date.and_time(time));
|
||||
if !reference.immediate {
|
||||
report.diagnostics.push(format!("etf_daily_open_fallback:deferred symbol={} signal_at={at} reference_date={} reference_price={} target_value={} execute_on={:?}", row.symbol, reference.reference_date, reference.price, row.target_value, reference.execute_on));
|
||||
let deferred = deferred_configuration.get_or_insert_with(|| (
|
||||
std::sync::Arc::new(contract.rule.clone()), std::sync::Arc::new(members.clone()),
|
||||
));
|
||||
let opening_date=reference.execute_on.map(|day|day.to_string()).unwrap_or_else(||"回测区间外(后续日历未加载)".into());
|
||||
etf_activity(report,date,&row.symbol,side,format!("ETF 顺延执行:信号 {at},参考 {} 收盘 {},目标金额 {},下一正式开盘日 {opening_date};未生成成交。",reference.reference_date,reference.price,row.target_value));
|
||||
self.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
|
||||
pool_id:contract.pool_id.clone(), generation:contract.generation.clone(), symbol:row.symbol.clone(),
|
||||
signal_date:contract.signal_date, signal_at:at, execute_on:reference.execute_on,
|
||||
target_value:row.target_value, target_weight_bps:row.target_weight_bps, side,
|
||||
max_positions, rule:std::sync::Arc::clone(&deferred.0), members:std::sync::Arc::clone(&deferred.1),
|
||||
reason:row.source_intent.clone().unwrap_or_else(||"stock_pool_target".into()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if side == pool::OrderSide::Buy
|
||||
&& portfolio
|
||||
.position(&row.symbol)
|
||||
.is_none_or(|p| p.quantity == 0)
|
||||
&& Self::positive_position_count(portfolio) >= max_positions
|
||||
{
|
||||
report.diagnostics.push(format!(
|
||||
"stock_pool_buy_deferred symbol={} reason=occupied_position_slots",
|
||||
row.symbol
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let target = row.target_quantity.to_i32().ok_or_else(|| {
|
||||
BacktestError::Execution("stock_pool_target_quantity_out_of_range".into())
|
||||
})?;
|
||||
let fallback_reason = fallback_references.contains_key(&row.symbol).then(|| format!("{}: etf_daily_open_fallback signal_date={} execution_date={date}", row.source_intent.as_deref().unwrap_or("stock_pool_target"), contract.signal_date));
|
||||
let reason = fallback_reason.as_deref().unwrap_or_else(|| row.source_intent.as_deref().unwrap_or("stock_pool_target"));
|
||||
let first_fill = report.fill_events.len();
|
||||
if fallback_references.contains_key(&row.symbol) {
|
||||
report.diagnostics.push(format!("etf_daily_open_fallback:opening symbol={} signal_date={} execution_date={date}", row.symbol, contract.signal_date));
|
||||
etf_activity(report,date,&row.symbol,side,format!("ETF 日线开盘回退:信号日 {},执行日 {date},使用正式日线开盘价;不是分钟成交行情。",contract.signal_date));
|
||||
}
|
||||
let mut execute = || if let Some(price) = row.limit_price {
|
||||
self.process_limit_target_shares(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
&row.symbol,
|
||||
target,
|
||||
price.to_f64().ok_or_else(|| {
|
||||
BacktestError::Execution("stock_pool_limit_price_out_of_range".into())
|
||||
})?,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
report,
|
||||
)
|
||||
} else {
|
||||
self.process_target_shares(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
&row.symbol,
|
||||
target,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
report,
|
||||
)
|
||||
};
|
||||
if fallback_references.contains_key(&row.symbol) {
|
||||
self.with_etf_daily_open(execute)?;
|
||||
for fill in &mut report.fill_events[first_fill..] {
|
||||
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
|
||||
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
|
||||
}
|
||||
} else { execute()?; }
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn pending_etf_target_count(&self) -> usize {
|
||||
self.deferred_etf_targets.borrow().len()
|
||||
}
|
||||
|
||||
/// Called at the opening clock, after settlement/corporate actions and
|
||||
/// auction callbacks. It never sends a stock order or replays a strategy.
|
||||
pub(crate) fn execute_deferred_etf_targets(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
if self.has_open_orders() {
|
||||
if self.pending_etf_target_count() > 0 {
|
||||
report.diagnostics.push("etf_daily_open_fallback:waiting_for_active_orders".into());
|
||||
}
|
||||
return Ok(report);
|
||||
}
|
||||
let due = self.deferred_etf_targets.borrow_mut().take_due(date);
|
||||
let dates = data.calendar().iter().collect::<Vec<_>>();
|
||||
for target in due {
|
||||
let instrument = data.instrument(&target.symbol).ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: instrument identity missing at execution".into()))?;
|
||||
if !instrument.is_exchange_traded_fund() { return Err(BacktestError::Execution("etf_daily_open_fallback: instrument identity changed".into())); }
|
||||
if let Some(reason) = instrument.dated_market_absence_reason(date) {
|
||||
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} date={date} reason={reason}", target.symbol));
|
||||
continue;
|
||||
}
|
||||
let snapshot = data.market(date, &target.symbol).ok_or_else(|| BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_missing symbol={} date={date}", target.symbol)))?;
|
||||
if !snapshot.open.is_finite() || snapshot.open <= 0.0 {
|
||||
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_invalid symbol={} date={date}", target.symbol)));
|
||||
}
|
||||
let position = portfolio.position(&target.symbol).filter(|p| p.quantity > 0);
|
||||
let before_quantity = position.map_or(0, |p| p.quantity);
|
||||
let permission = target.rule.automatic_trade_protection.evaluate(&target.symbol, date, &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:self.same_day_sold_symbols.borrow().iter().rev().find(|(day, symbols)| **day <= date && symbols.contains(&target.symbol)).map(|(day, _)| *day),
|
||||
}, data.calendar()).map_err(BacktestError::Execution)?;
|
||||
let denial = if target.side == pool::OrderSide::Buy {
|
||||
permission.buy_denial.or(permission.max_holding_exit.then_some("max_holding_exit_pending"))
|
||||
} else { permission.sell_denial };
|
||||
if let Some(denial) = denial {
|
||||
report.diagnostics.push(format!("etf_daily_open_fallback:protected symbol={} date={date} reason={denial}", target.symbol));
|
||||
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标受持有保护限制:{denial};未提交委托。"));
|
||||
continue;
|
||||
}
|
||||
if target.side == pool::OrderSide::Buy && before_quantity == 0 && Self::positive_position_count(portfolio) >= target.max_positions {
|
||||
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} reason=occupied_position_slots", target.symbol));
|
||||
continue;
|
||||
}
|
||||
let value = target.target_value.to_f64().ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: target value out of range".into()))?;
|
||||
let current_value = snapshot.open * f64::from(before_quantity);
|
||||
let satisfied = (target.side == pool::OrderSide::Buy && value <= current_value)
|
||||
|| (target.side == pool::OrderSide::Sell && value >= current_value);
|
||||
let reason = format!("{}: etf_daily_open_fallback signal_at={} execution_at={} target_value={}", target.reason, target.signal_at, date.and_time(crate::etf_execution::opening_time()), target.target_value);
|
||||
let mut sub = BrokerExecutionReport::default();
|
||||
if !satisfied {
|
||||
let (_, limit) = pool::resolve_stock_pool_order_price(&target.rule, &target.symbol, decimal(snapshot.open, "etf_open")?, target.side, decimal(snapshot.price_tick, "etf_tick")?).map_err(BacktestError::Execution)?;
|
||||
let intent = match limit {
|
||||
Some(limit) => OrderIntent::LimitTargetValue { symbol:target.symbol.clone(), target_value:value, limit_price:limit.to_f64().ok_or_else(|| BacktestError::Execution("ETF limit out of range".into()))?, reason:reason.clone() },
|
||||
None => OrderIntent::TargetValue { symbol:target.symbol.clone(), target_value:value, reason:reason.clone() },
|
||||
};
|
||||
let old_time = self.runtime_intraday_start_time.replace(Some(crate::etf_execution::opening_time()));
|
||||
let old_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin { created_date:Some(target.signal_at.date()), submission_time:Some(target.signal_at.time()), accepted_date:date }));
|
||||
let outcome = self.with_etf_daily_open(|| self.execute_with_event_dates(date, target.signal_date, target.signal_at.date(), portfolio, data, &StrategyDecision {
|
||||
order_intents:vec![OrderIntent::WithTimeInForce { intent:Box::new(intent), time_in_force:OrderTimeInForce::Day }], ..Default::default()
|
||||
}));
|
||||
self.runtime_intraday_start_time.set(old_time);
|
||||
self.runtime_resting_order_origin.set(old_origin);
|
||||
sub = outcome?;
|
||||
}
|
||||
// The actual open determines the full requested shares. A clipped
|
||||
// or rejected execution must not be recorded as completed entry.
|
||||
let order = sub.order_events.iter().rev().find(|order| order.symbol == target.symbol);
|
||||
let goal_quantity = order.map_or(before_quantity, |order| match order.side {
|
||||
OrderSide::Buy => before_quantity.saturating_add(order.requested_quantity),
|
||||
OrderSide::Sell => before_quantity.saturating_sub(order.requested_quantity),
|
||||
});
|
||||
let status = if satisfied || (order.is_none() && !self.has_open_orders()) { "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED" } else { "READY" };
|
||||
let positions = pool_positions(portfolio, date)?;
|
||||
let state = portfolio.stock_pool_execution_state(&target.pool_id)
|
||||
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?
|
||||
.record_targets(target.signal_date, &target.generation, [crate::stock_pool_state::StockPoolGoalObservation {
|
||||
symbol:&target.symbol, target_weight_bps:target.target_weight_bps, target_value:target.target_value,
|
||||
current_quantity:before_quantity.into(), target_quantity:goal_quantity.into(), status,
|
||||
}]).map_err(BacktestError::Execution)?
|
||||
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?;
|
||||
portfolio.set_stock_pool_execution_state(&target.pool_id, state).map_err(BacktestError::Execution)?;
|
||||
for fill in &mut sub.fill_events {
|
||||
fill.decision_date.get_or_insert(target.signal_date);
|
||||
fill.order_created_date.get_or_insert(target.signal_at.date());
|
||||
fill.execution_date.get_or_insert(date);
|
||||
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
|
||||
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
|
||||
}
|
||||
for order in &mut sub.order_events {
|
||||
order.decision_date.get_or_insert(target.signal_date);
|
||||
order.order_created_date.get_or_insert(target.signal_at.date());
|
||||
order.execution_date.get_or_insert(date);
|
||||
}
|
||||
report.diagnostics.push(reason);
|
||||
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标开盘处理:原信号 {},本次 {date} 09:30,冻结目标金额 {},持仓 {before_quantity} → {};按本日开盘价、资金与风控重新定量。",target.signal_at,target.target_value,portfolio.position(&target.symbol).map_or(0,|position|position.quantity)));
|
||||
report.order_events.extend(sub.order_events);
|
||||
report.fill_events.extend(sub.fill_events);
|
||||
report.position_events.extend(sub.position_events);
|
||||
report.account_events.extend(sub.account_events);
|
||||
report.process_events.extend(sub.process_events);
|
||||
report.diagnostics.extend(sub.diagnostics);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
use ahash::AHashMap;
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use compact_str::CompactString;
|
||||
use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
@@ -122,7 +123,7 @@ pub enum PriceField {
|
||||
pub struct DailyMarketSnapshot {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
pub symbol: CompactString,
|
||||
pub timestamp: Option<String>,
|
||||
pub day_open: f64,
|
||||
pub open: f64,
|
||||
@@ -199,13 +200,13 @@ impl DailyMarketSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
pub type NumericFactorMap = BTreeMap<Cow<'static, str>, f64>;
|
||||
pub use crate::numeric_factors::NumericFactorMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DailyFactorSnapshot {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
pub symbol: CompactString,
|
||||
pub market_cap_bn: f64,
|
||||
pub free_float_cap_bn: f64,
|
||||
pub pe_ttm: f64,
|
||||
@@ -232,7 +233,7 @@ pub struct BenchmarkSnapshot {
|
||||
pub struct CandidateEligibility {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
pub symbol: CompactString,
|
||||
pub is_st: bool,
|
||||
#[serde(default)]
|
||||
pub is_star_st: bool,
|
||||
@@ -1204,7 +1205,7 @@ impl SymbolPriceSeries {
|
||||
fn snapshot_at(&self, index: usize) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: self.dates[index],
|
||||
symbol: self.symbol.clone(),
|
||||
symbol: self.symbol.as_str().into(),
|
||||
timestamp: self.timestamps[index].clone(),
|
||||
day_open: self.day_opens[index],
|
||||
open: self.opens[index],
|
||||
@@ -2865,7 +2866,7 @@ impl DataSet {
|
||||
.filter_map(|snapshot| {
|
||||
factor_numeric_value(snapshot, &field).map(|value| FactorValue {
|
||||
date: snapshot.date,
|
||||
symbol: snapshot.symbol.clone(),
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
field: field.clone(),
|
||||
value,
|
||||
})
|
||||
@@ -2959,7 +2960,7 @@ impl DataSet {
|
||||
.or_else(|| row.extra_factors.get("margin_all"))
|
||||
.is_some_and(|value| *value > 0.0)
|
||||
})
|
||||
.map(|row| row.symbol.clone())
|
||||
.map(|row| row.symbol.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -3051,7 +3052,7 @@ impl DataSet {
|
||||
if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 {
|
||||
Some(FactorValue {
|
||||
date: snapshot.date,
|
||||
symbol: snapshot.symbol.clone(),
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
field: "price_change_rate".to_string(),
|
||||
value: snapshot.close / snapshot.prev_close - 1.0,
|
||||
})
|
||||
@@ -3173,7 +3174,7 @@ impl DataSet {
|
||||
if let Some(value) = factor_numeric_value(snapshot, field) {
|
||||
return Some(FactorValue {
|
||||
date: *factor_date,
|
||||
symbol: snapshot.symbol.clone(),
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
field: field.clone(),
|
||||
value,
|
||||
});
|
||||
@@ -3524,7 +3525,7 @@ impl DataSet {
|
||||
if let Some(value) = factor_numeric_value(snapshot, field) {
|
||||
rows.push(FactorValue {
|
||||
date: snapshot.date,
|
||||
symbol: snapshot.symbol.clone(),
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
field: output_field.clone(),
|
||||
value,
|
||||
});
|
||||
@@ -4322,7 +4323,7 @@ fn daily_market_price_bar(snapshot: &DailyMarketSnapshot) -> PriceBar {
|
||||
PriceBar {
|
||||
date: snapshot.date,
|
||||
timestamp: snapshot.timestamp.clone(),
|
||||
symbol: snapshot.symbol.clone(),
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
frequency: "1d".to_string(),
|
||||
open: snapshot.open,
|
||||
high: snapshot.high,
|
||||
@@ -4383,7 +4384,7 @@ fn normalize_factor_snapshots(
|
||||
{
|
||||
return Err(DataSetError::ReservedTypedFactorInExtraMap {
|
||||
date: snapshot.date,
|
||||
symbol: snapshot.symbol,
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
field: BACKWARD_ADJUSTMENT_FACTOR_FIELD,
|
||||
});
|
||||
}
|
||||
@@ -4392,7 +4393,7 @@ fn normalize_factor_snapshots(
|
||||
{
|
||||
return Err(DataSetError::InvalidBackwardAdjustmentFactor {
|
||||
date: snapshot.date,
|
||||
symbol: snapshot.symbol,
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
value,
|
||||
});
|
||||
}
|
||||
@@ -4428,7 +4429,7 @@ fn normalize_factor_snapshots(
|
||||
{
|
||||
return Err(DataSetError::ReservedTypedFactorInExtraMap {
|
||||
date: snapshot.date,
|
||||
symbol: snapshot.symbol,
|
||||
symbol: snapshot.symbol.to_string(),
|
||||
field: BACKWARD_ADJUSTMENT_FACTOR_FIELD,
|
||||
});
|
||||
}
|
||||
@@ -4510,21 +4511,21 @@ fn build_symbol_id_index(
|
||||
for rows in market_by_date.values() {
|
||||
for row in rows {
|
||||
if !symbols.contains(row.symbol.as_str()) {
|
||||
symbols.insert(row.symbol.clone());
|
||||
symbols.insert(row.symbol.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for rows in factor_by_date.values() {
|
||||
for row in rows {
|
||||
if !symbols.contains(row.symbol.as_str()) {
|
||||
symbols.insert(row.symbol.clone());
|
||||
symbols.insert(row.symbol.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for rows in candidate_by_date.values() {
|
||||
for row in rows {
|
||||
if !symbols.contains(row.symbol.as_str()) {
|
||||
symbols.insert(row.symbol.clone());
|
||||
symbols.insert(row.symbol.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4942,7 +4943,7 @@ fn build_fundamental_universe_for_date(
|
||||
continue;
|
||||
}
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
symbol: factor.symbol.clone(),
|
||||
symbol: factor.symbol.to_string(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn: decision_free_float_cap_bn(factor),
|
||||
});
|
||||
@@ -5012,7 +5013,7 @@ fn build_eligible_universe_for_date_from_factors(
|
||||
date,
|
||||
candidate,
|
||||
market,
|
||||
instruments.get(&factor.symbol),
|
||||
instruments.get(factor.symbol.as_str()),
|
||||
risk_config,
|
||||
)
|
||||
.is_some()
|
||||
@@ -5025,7 +5026,7 @@ fn build_eligible_universe_for_date_from_factors(
|
||||
}
|
||||
let free_float_cap_bn = decision_free_float_cap_bn(factor);
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
symbol: factor.symbol.clone(),
|
||||
symbol: factor.symbol.to_string(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
});
|
||||
@@ -5042,7 +5043,7 @@ fn build_eligible_universe_for_date_from_factors(
|
||||
pub(crate) fn missing_candidate_risk_state(date: NaiveDate, symbol: &str) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -5070,7 +5071,7 @@ mod tests {
|
||||
fn market_row(date: &str, prev_close: f64, volume: u64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: None,
|
||||
day_open: prev_close,
|
||||
open: prev_close,
|
||||
@@ -5352,10 +5353,10 @@ mod tests {
|
||||
for (symbol_index, symbol) in symbols.into_iter().enumerate().rev() {
|
||||
let close = 10.0 + date_index as f64 + symbol_index as f64;
|
||||
let mut market_row = market_row(&date_text, close, 1_000_000);
|
||||
market_row.symbol = symbol.to_string();
|
||||
market_row.symbol = symbol.into();
|
||||
let factor_row = DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 100.0 + close,
|
||||
free_float_cap_bn: 80.0 + close,
|
||||
pe_ttm: 0.0,
|
||||
@@ -5366,7 +5367,7 @@ mod tests {
|
||||
};
|
||||
let candidate_row = CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -5534,12 +5535,12 @@ mod tests {
|
||||
};
|
||||
let market = |symbol: &str, close: f64| {
|
||||
let mut row = market_row("2025-01-02", close, 1_000_000);
|
||||
row.symbol = symbol.to_string();
|
||||
row.symbol = symbol.into();
|
||||
row
|
||||
};
|
||||
let factor = |symbol: &str, market_cap_bn: f64| DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn: market_cap_bn,
|
||||
pe_ttm: 0.0,
|
||||
@@ -5550,7 +5551,7 @@ mod tests {
|
||||
};
|
||||
let candidate = |symbol: &str| CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -5773,7 +5774,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|date| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -5977,7 +5978,7 @@ mod tests {
|
||||
.enumerate()
|
||||
.map(|(index, symbol)| {
|
||||
let mut row = market_row("2025-01-02", 10.0 + index as f64 / 1000.0, 1_000_000);
|
||||
row.symbol = symbol.clone();
|
||||
row.symbol = symbol.clone().into();
|
||||
row
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -5986,7 +5987,7 @@ mod tests {
|
||||
.enumerate()
|
||||
.map(|(index, symbol)| DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.clone(),
|
||||
symbol: symbol.clone().into(),
|
||||
market_cap_bn: 10.0 + index as f64 / 1000.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -6000,7 +6001,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|symbol| CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.clone(),
|
||||
symbol: symbol.clone().into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -6289,14 +6290,14 @@ mod tests {
|
||||
fn factor_numeric_value_normalizes_fields_without_changing_aliases() {
|
||||
let snapshot = DailyFactorSnapshot {
|
||||
date: NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 12.5,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.25),
|
||||
extra_factors: BTreeMap::from([("custom_factor".into(), 3.5)]),
|
||||
extra_factors: From::from([("custom_factor".into(), 3.5)]),
|
||||
};
|
||||
|
||||
assert_eq!(factor_numeric_value(&snapshot, " MARKET_CAP "), Some(12.5));
|
||||
@@ -6312,14 +6313,14 @@ mod tests {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
let clean = normalize_factor_snapshots(vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 1.0,
|
||||
free_float_cap_bn: 1.0,
|
||||
pe_ttm: 1.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::from([(Cow::Borrowed("amount"), 10.0)]),
|
||||
extra_factors: From::from([(Cow::Borrowed("amount"), 10.0)]),
|
||||
}])
|
||||
.expect("normalize clean factor snapshot");
|
||||
assert!(matches!(
|
||||
@@ -6329,14 +6330,14 @@ mod tests {
|
||||
|
||||
let dirty = normalize_factor_snapshots(vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 1.0,
|
||||
free_float_cap_bn: 1.0,
|
||||
pe_ttm: 1.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::from([
|
||||
extra_factors: From::from([
|
||||
(Cow::Owned(" CUSTOM_FACTOR ".to_string()), 2.0),
|
||||
(Cow::Borrowed("bad_nan"), f64::NAN),
|
||||
]),
|
||||
@@ -6351,7 +6352,7 @@ mod tests {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
let snapshot = |adjustment_factor_backward1, extra_factors| DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 1.0,
|
||||
free_float_cap_bn: 1.0,
|
||||
pe_ttm: 1.0,
|
||||
@@ -6364,12 +6365,12 @@ mod tests {
|
||||
assert!(matches!(
|
||||
normalize_factor_snapshots(vec![snapshot(
|
||||
Some(1.0),
|
||||
BTreeMap::from([(Cow::Borrowed(BACKWARD_ADJUSTMENT_FACTOR_FIELD), 1.0)]),
|
||||
BTreeMap::from([(Cow::Borrowed(BACKWARD_ADJUSTMENT_FACTOR_FIELD), 1.0)]).into(),
|
||||
)]),
|
||||
Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
normalize_factor_snapshots(vec![snapshot(Some(0.0), BTreeMap::new())]),
|
||||
normalize_factor_snapshots(vec![snapshot(Some(0.0), BTreeMap::new().into())]),
|
||||
Err(DataSetError::InvalidBackwardAdjustmentFactor { .. })
|
||||
));
|
||||
for field in [
|
||||
@@ -6382,7 +6383,7 @@ mod tests {
|
||||
matches!(
|
||||
normalize_factor_snapshots(vec![snapshot(
|
||||
typed_value,
|
||||
BTreeMap::from([(Cow::Borrowed(field), 2.0)]),
|
||||
BTreeMap::from([(Cow::Borrowed(field), 2.0)]).into(),
|
||||
)]),
|
||||
Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
|
||||
),
|
||||
@@ -6478,14 +6479,14 @@ mod tests {
|
||||
}
|
||||
DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors,
|
||||
extra_factors: extra_factors.into(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
@@ -6537,14 +6538,14 @@ mod tests {
|
||||
.iter()
|
||||
.map(|date| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
@@ -6831,14 +6832,14 @@ mod tests {
|
||||
.zip(factors)
|
||||
.map(|(date, factor)| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(factor),
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
@@ -6929,14 +6930,14 @@ mod tests {
|
||||
.iter()
|
||||
.map(|date| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: (*date != dates[3]).then_some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
@@ -7008,7 +7009,7 @@ mod tests {
|
||||
};
|
||||
let market = |symbol: &str, prev_close: f64, close: f64| DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: prev_close,
|
||||
open: prev_close,
|
||||
@@ -7032,18 +7033,18 @@ mod tests {
|
||||
let factor =
|
||||
|symbol: &str, market_cap_bn: f64, free_float_cap_bn: f64| DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
};
|
||||
let candidate = |symbol: &str| CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -7108,7 +7109,7 @@ mod tests {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -7131,14 +7132,14 @@ mod tests {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 9.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![BenchmarkSnapshot {
|
||||
@@ -7193,7 +7194,7 @@ mod tests {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -7216,18 +7217,18 @@ mod tests {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 9.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -7268,14 +7269,14 @@ mod tests {
|
||||
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
|
||||
let factor = DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 12.0,
|
||||
free_float_cap_bn: 4.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
};
|
||||
|
||||
assert!((decision_market_cap_bn(&factor) - 12.0).abs() < 1e-9);
|
||||
|
||||
+177
-16
@@ -122,6 +122,7 @@ impl DailyEquityPoint {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BacktestResult {
|
||||
pub capacity_audit: crate::execution_capacity::CapacityAuditSummary,
|
||||
pub strategy_name: String,
|
||||
pub equity_curve: Vec<DailyEquityPoint>,
|
||||
pub benchmark_series: Vec<BenchmarkSnapshot>,
|
||||
@@ -168,6 +169,8 @@ pub enum BacktestTerminalAssetClass {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BacktestTerminalAudit {
|
||||
#[serde(default, skip_serializing_if = "is_zero_count")]
|
||||
pub deferred_etf_target_count: usize,
|
||||
pub status: BacktestTerminalStatus,
|
||||
pub last_execution_date: Option<NaiveDate>,
|
||||
pub stock_open_order_count: usize,
|
||||
@@ -184,6 +187,7 @@ pub struct BacktestTerminalAudit {
|
||||
impl Default for BacktestTerminalAudit {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
deferred_etf_target_count: 0,
|
||||
status: BacktestTerminalStatus::Clean,
|
||||
last_execution_date: None,
|
||||
stock_open_order_count: 0,
|
||||
@@ -199,6 +203,8 @@ impl Default for BacktestTerminalAudit {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero_count(value: &usize) -> bool { *value == 0 }
|
||||
|
||||
impl BacktestTerminalAudit {
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.status == BacktestTerminalStatus::Clean
|
||||
@@ -280,6 +286,7 @@ pub struct AnalyzerRiskSummary {
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AnalyzerReport {
|
||||
pub capacity_audit: crate::execution_capacity::CapacityAuditSummary,
|
||||
pub strategy_name: String,
|
||||
pub trades: Vec<AnalyzerTradeRow>,
|
||||
pub positions: Vec<AnalyzerPositionRow>,
|
||||
@@ -294,6 +301,7 @@ pub struct AnalyzerReport {
|
||||
impl BacktestResult {
|
||||
pub fn analyzer_report(&self) -> AnalyzerReport {
|
||||
AnalyzerReport {
|
||||
capacity_audit: self.capacity_audit.clone(),
|
||||
strategy_name: self.strategy_name.clone(),
|
||||
trades: self
|
||||
.fills
|
||||
@@ -784,6 +792,17 @@ where
|
||||
end_time: Option<NaiveTime>,
|
||||
symbols: &mut BTreeSet<String>,
|
||||
) -> Result<(), BacktestError> {
|
||||
// A missing point is not proof of an absent ETF minute dataset. Query
|
||||
// its complete formal session first; loader/contract failures propagate.
|
||||
if start_time.is_some() || end_time.is_some() {
|
||||
let mut etfs = symbols.iter().filter(|symbol| {
|
||||
self.broker.requires_etf_absence_check(&self.data, symbol)
|
||||
&& !self.execution_quote_request_cache.contains(&(execution_date, (*symbol).clone(), None, None))
|
||||
}).cloned().collect::<BTreeSet<_>>();
|
||||
if !etfs.is_empty() {
|
||||
self.load_missing_execution_quotes(execution_date, None, None, &mut etfs)?;
|
||||
}
|
||||
}
|
||||
let mut available = BTreeSet::new();
|
||||
for symbol in symbols.iter() {
|
||||
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
|
||||
@@ -810,6 +829,9 @@ where
|
||||
}
|
||||
*symbols = available;
|
||||
symbols.retain(|symbol| {
|
||||
if (start_time.is_some() || end_time.is_some()) && self.broker.has_verified_etf_minute_absence(execution_date, symbol) {
|
||||
return false;
|
||||
}
|
||||
let request_key = (execution_date, symbol.clone(), start_time, end_time);
|
||||
if self.execution_quote_request_cache.contains(&request_key) {
|
||||
return false;
|
||||
@@ -854,6 +876,7 @@ where
|
||||
}
|
||||
self.data.add_execution_quotes(quotes);
|
||||
if start_time.is_none() && end_time.is_none() {
|
||||
self.broker.record_complete_etf_minute_query(execution_date, &self.data, &requested_symbols);
|
||||
self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?;
|
||||
}
|
||||
for symbol in requested_symbols {
|
||||
@@ -890,7 +913,7 @@ where
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if market.volume > 0 && !has_quotes {
|
||||
if market.volume > 0 && !has_quotes && !self.broker.has_verified_etf_minute_absence(execution_date, symbol) {
|
||||
missing_active.push(symbol.clone());
|
||||
}
|
||||
}
|
||||
@@ -1392,6 +1415,7 @@ where
|
||||
let status = if open_order_count == 0
|
||||
&& pending_cash_flow_count == 0
|
||||
&& cash_receivable_count == 0
|
||||
&& self.broker.pending_etf_target_count() == 0
|
||||
{
|
||||
BacktestTerminalStatus::Clean
|
||||
} else {
|
||||
@@ -1399,6 +1423,7 @@ where
|
||||
};
|
||||
|
||||
BacktestTerminalAudit {
|
||||
deferred_etf_target_count: self.broker.pending_etf_target_count(),
|
||||
status,
|
||||
last_execution_date,
|
||||
stock_open_order_count,
|
||||
@@ -2102,6 +2127,7 @@ where
|
||||
.map(|(execution_date, _)| *execution_date)
|
||||
.collect::<Vec<_>>();
|
||||
let mut result = BacktestResult {
|
||||
capacity_audit: self.broker.capacity_audit_summary(),
|
||||
strategy_name: self.strategy.name().to_string(),
|
||||
benchmark_series: self
|
||||
.data
|
||||
@@ -2200,7 +2226,7 @@ where
|
||||
.and_then(|(_, decision_slot)| *decision_slot);
|
||||
let Some((decision_index, decision_date)) = decision_slot else {
|
||||
let mut process_events = Vec::new();
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
let mut report = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?;
|
||||
portfolio.update_prices_with_options(
|
||||
execution_date,
|
||||
&self.data,
|
||||
@@ -2562,6 +2588,9 @@ where
|
||||
"open_auction:post",
|
||||
)?;
|
||||
|
||||
let deferred_etfs = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?;
|
||||
merge_broker_report(&mut report, deferred_etfs);
|
||||
|
||||
publish_phase_event(
|
||||
&mut self.strategy,
|
||||
&mut self.process_event_bus,
|
||||
@@ -2849,9 +2878,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.drives_resting_quote_clock())
|
||||
{
|
||||
let unfiltered_minute_stream = self.subscriptions.is_empty();
|
||||
let mut full_minute_symbols = self.subscriptions.clone();
|
||||
if self.broker.drives_resting_quote_clock() {
|
||||
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 +2898,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();
|
||||
@@ -2893,18 +2929,26 @@ where
|
||||
.into_iter()
|
||||
.peekable();
|
||||
let mut minute_group = Vec::new();
|
||||
let mut last_minute_timestamp = None;
|
||||
// Merge the immutable quote stream with clock events. Equal
|
||||
// timestamps form one event; scheduled callbacks run before
|
||||
// `on_minute` below.
|
||||
loop {
|
||||
let next_quote_timestamp = minute_quotes.peek().map(|quote| quote.timestamp);
|
||||
let next_schedule_timestamp = minute_schedule_timestamps.peek().copied();
|
||||
let next_expiry_timestamp = self.broker.next_day_order_expiry(execution_date)
|
||||
.map(|time| execution_date.and_time(time))
|
||||
.filter(|time| last_minute_timestamp.is_none_or(|last| last < *time));
|
||||
let Some(minute_timestamp) =
|
||||
next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp)
|
||||
next_minute_event_timestamp(
|
||||
next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp),
|
||||
next_expiry_timestamp,
|
||||
)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let minute_time = minute_timestamp.time();
|
||||
last_minute_timestamp = Some(minute_timestamp);
|
||||
minute_group.clear();
|
||||
while minute_quotes
|
||||
.peek()
|
||||
@@ -2913,7 +2957,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 +3030,10 @@ where
|
||||
crate::strategy::StrategyDecision::default()
|
||||
};
|
||||
if requires_minute_callbacks {
|
||||
for "e in &minute_group {
|
||||
for quote in &minute_group {
|
||||
if !self.subscriptions.is_empty() && !self.subscriptions.contains("e.symbol) {
|
||||
continue;
|
||||
}
|
||||
minute_decision.merge_from(self.strategy.on_minute(
|
||||
&StrategyContext {
|
||||
execution_date,
|
||||
@@ -3098,6 +3146,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.drives_resting_quote_clock() {
|
||||
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);
|
||||
@@ -3382,6 +3452,16 @@ where
|
||||
execution_date,
|
||||
);
|
||||
let daily_fill_count = result.fills.len() - day_fill_start;
|
||||
for audit in self.broker.audit_completed_session_capacity(execution_date, &self.data)? {
|
||||
result.capacity_audit.observe(&audit);
|
||||
// Keep every audit in the durable event store, independent of
|
||||
// debug phase retention. It never changes earlier executions.
|
||||
result.process_events.push(ProcessEvent {
|
||||
date: execution_date, kind: ProcessEventKind::SessionCapacityAudit,
|
||||
order_id: None, symbol: Some(audit.symbol.clone()), side: None,
|
||||
detail: serde_json::to_string(&audit).map_err(|error| BacktestError::Execution(error.to_string()))?,
|
||||
});
|
||||
}
|
||||
let daily_order_count = result.order_events.len() - day_order_start;
|
||||
let execution_risk_decisions =
|
||||
risk_decisions_from_order_events(&result.order_events[day_order_start..]);
|
||||
@@ -3586,6 +3666,8 @@ where
|
||||
|
||||
let split_ratio = action.split_ratio();
|
||||
if (split_ratio - 1.0).abs() > f64::EPSILON {
|
||||
portfolio.adjust_stock_pool_split(&action.symbol, split_ratio)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let (delta_quantity, quantity_after, average_cost) = {
|
||||
let position = portfolio
|
||||
.position_mut_if_exists(&action.symbol)
|
||||
@@ -4097,6 +4179,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, .. }
|
||||
@@ -5339,7 +5425,7 @@ mod tests {
|
||||
fn market(date: NaiveDate, open: f64, close: f64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: Some(format!("{date} 15:00:00")),
|
||||
day_open: open,
|
||||
open,
|
||||
@@ -5393,21 +5479,21 @@ mod tests {
|
||||
fn factor(date: NaiveDate) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
symbol: SYMBOL.into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 12.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(date: NaiveDate) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
symbol: SYMBOL.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -5843,6 +5929,76 @@ 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 scenario in 0..5 {
|
||||
let partial = scenario == 1;
|
||||
let closing_only = matches!(scenario,2|3);
|
||||
let delayed = scenario == 4;
|
||||
let date = if closing_only { d(2026, 7, 6) } else if delayed { d(2026, 6, 2) } else { 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 unchanged = quote(9, 45, 10.2);
|
||||
let later = quote(10, 0, if closing_only { 10.2 } else { 9.8 });
|
||||
let last = if closing_only { quote(15, 0, if scenario == 2 { 9.8 } else { 10.2 }) } else { quote(10, 1, 9.8) };
|
||||
let mut post_close = quote(15, 5, 9.7);
|
||||
post_close.trading_phase = Some("post_close_fixed_price".into());
|
||||
let prior = date.pred_opt().unwrap();
|
||||
let markets = if delayed {vec![market(prior,10.2,10.2),market(date,10.2,9.8)]} else {vec![market(date,10.2,9.8)]};
|
||||
let candidates = if delayed {vec![candidate(prior),candidate(date)]} else {vec![candidate(date)]};
|
||||
let mut data = dataset_from_market_and_candidates(markets,candidates);
|
||||
data.add_execution_quotes(vec![first.clone()]);
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(if delayed {MatchingType::NextBarOpen} else {MatchingType::CurrentBarClose})
|
||||
.with_volume_limit(partial).with_volume_percent(0.01).with_liquidity_limit(false).with_inactive_limit(false);
|
||||
let broker = if delayed {broker} else {broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9,30,0).unwrap())};
|
||||
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(if delayed {prior} else {date}), end_date: Some(date),
|
||||
decision_lag_trading_days: usize::from(delayed), execution_price_field: if delayed {PriceField::Open} else {PriceField::Close},
|
||||
}).with_execution_quote_loader(move |request| {
|
||||
captured.lock().unwrap().push((request.start_time, request.end_time));
|
||||
Ok(vec![earlier.clone(), first.clone(), unchanged.clone(), later.clone(), last.clone(), post_close.clone()])
|
||||
});
|
||||
let result = engine.run().unwrap();
|
||||
if scenario == 3 {
|
||||
assert!(result.fills.is_empty(), "continuous DAY order must not migrate to post-close execution");
|
||||
assert_eq!(result.order_events.len(), 2, "only initial pending and expiry are state changes");
|
||||
assert_eq!(result.order_events.last().unwrap().status, crate::OrderStatus::Expired);
|
||||
continue;
|
||||
}
|
||||
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 if closing_only { date.and_hms_opt(15, 0, 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));
|
||||
assert_eq!(result.order_events.len(), if partial { 3 } else { 2 }, "unchanged pending attempts must not emit state transitions");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduled_event_detail_records_actual_time_only_for_timed_rules() {
|
||||
let timed = ScheduleRule::daily("timed", ScheduleStage::OnDay)
|
||||
@@ -6923,6 +7079,7 @@ mod tests {
|
||||
let third = d(2025, 1, 6);
|
||||
let fourth = d(2025, 1, 7);
|
||||
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
|
||||
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_volume_limit(true)
|
||||
.with_volume_percent(0.25);
|
||||
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
||||
@@ -6950,12 +7107,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bar_open_sell_volume_limit_rejects_execution_day_zero_volume() {
|
||||
fn next_bar_open_session_audit_flags_zero_volume_without_rewriting_fills() {
|
||||
let first = d(2025, 1, 2);
|
||||
let second = d(2025, 1, 3);
|
||||
let third = d(2025, 1, 6);
|
||||
let fourth = d(2025, 1, 7);
|
||||
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
|
||||
.with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_volume_limit(true)
|
||||
.with_volume_percent(0.25);
|
||||
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
|
||||
@@ -6976,7 +7134,10 @@ mod tests {
|
||||
broker,
|
||||
);
|
||||
|
||||
assert_round_trip_sell_canceled_with_reason(&result, "daily volume limit");
|
||||
assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Sell && fill.date == fourth));
|
||||
assert_eq!(result.capacity_audit.audit_passed, Some(false));
|
||||
assert_eq!(result.capacity_audit.failed_symbol_sessions, 1);
|
||||
assert!(result.process_events.iter().any(|event| event.kind == crate::ProcessEventKind::SessionCapacityAudit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Historical ETF execution fallback. Never manufactures an intraday bar.
|
||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||
use rust_decimal::Decimal;
|
||||
use crate::{BacktestError, DataSet};
|
||||
|
||||
pub(crate) fn opening_time() -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(9, 30, 0).expect("valid exchange opening time")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct EtfFallbackReference {
|
||||
pub price: f64,
|
||||
pub reference_date: NaiveDate,
|
||||
/// None means the next official session is outside the loaded calendar.
|
||||
/// No natural-day guess or price from beyond the requested run is used.
|
||||
pub execute_on: Option<NaiveDate>,
|
||||
pub immediate: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn reference(data: &DataSet, symbol: &str, at: NaiveDateTime) -> Result<EtfFallbackReference, BacktestError> {
|
||||
let fail = |reason: &str| BacktestError::Execution(format!(
|
||||
"etf_daily_open_fallback:{reason} symbol={symbol} signal_at={at}"
|
||||
));
|
||||
let instrument = data.instrument(symbol).ok_or_else(|| fail("instrument_identity_missing"))?;
|
||||
if !instrument.is_exchange_traded_fund() || instrument.listed_at.is_none() {
|
||||
return Err(fail("verified_etf_identity_required"));
|
||||
}
|
||||
if instrument.dated_market_absence_reason(at.date()).is_some() {
|
||||
return Err(fail("outside_instrument_lifecycle"));
|
||||
}
|
||||
if at.time() == opening_time() {
|
||||
let row = data.market(at.date(), symbol).ok_or_else(|| fail("daily_open_missing"))?;
|
||||
if !row.open.is_finite() || row.open <= 0.0 { return Err(fail("daily_open_invalid")); }
|
||||
return Ok(EtfFallbackReference { price: row.open, reference_date: at.date(), execute_on: Some(at.date()), immediate: true });
|
||||
}
|
||||
let previous = data.previous_trading_date(at.date(), 1).ok_or_else(|| fail("previous_official_session_missing"))?;
|
||||
let close = data.market(previous, symbol).map(|row| row.close).ok_or_else(|| fail("previous_completed_close_missing"))?;
|
||||
if !close.is_finite() || close <= 0.0 { return Err(fail("previous_completed_close_invalid")); }
|
||||
Ok(EtfFallbackReference {
|
||||
price: close, reference_date: previous, immediate: false,
|
||||
execute_on: if at.time() < opening_time() { Some(at.date()) } else { data.next_trading_date(at.date(), 1) },
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DeferredEtfTarget {
|
||||
pub pool_id: String,
|
||||
pub generation: String,
|
||||
pub symbol: String,
|
||||
pub signal_date: NaiveDate,
|
||||
pub signal_at: NaiveDateTime,
|
||||
pub execute_on: Option<NaiveDate>,
|
||||
pub target_value: Decimal,
|
||||
pub target_weight_bps: i32,
|
||||
pub side: crate::stock_pool_execution::OrderSide,
|
||||
pub max_positions: usize,
|
||||
pub rule: std::sync::Arc<crate::stock_pool_execution::StockPoolExecutionRule>,
|
||||
pub members: std::sync::Arc<Vec<crate::stock_pool_execution::StockPoolMemberSpec>>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Owned by one broker/run. Replacing a full pool generation supersedes older
|
||||
/// queued targets; order of the latest candidate list is retained.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct DeferredEtfTargets {
|
||||
generations: std::collections::BTreeMap<String, String>,
|
||||
rows: Vec<DeferredEtfTarget>,
|
||||
}
|
||||
|
||||
impl DeferredEtfTargets {
|
||||
pub fn replace_generation(&mut self, pool_id: &str, generation: &str) -> usize {
|
||||
if self.generations.get(pool_id).is_some_and(|old| old == generation) { return 0; }
|
||||
self.generations.insert(pool_id.into(), generation.into());
|
||||
let before = self.rows.len();
|
||||
self.rows.retain(|row| row.pool_id != pool_id);
|
||||
before - self.rows.len()
|
||||
}
|
||||
pub fn upsert(&mut self, row: DeferredEtfTarget) {
|
||||
if let Some(existing) = self.rows.iter_mut().find(|v| v.pool_id == row.pool_id && v.symbol == row.symbol) {
|
||||
*existing = row;
|
||||
} else { self.rows.push(row); }
|
||||
}
|
||||
pub fn take_due(&mut self, date: NaiveDate) -> Vec<DeferredEtfTarget> {
|
||||
let mut due = Vec::new();
|
||||
self.rows.retain(|row| {
|
||||
if row.execute_on.is_some_and(|day| day <= date) { due.push(row.clone()); false } else { true }
|
||||
});
|
||||
due.sort_by_key(|row| match row.side { crate::stock_pool_execution::OrderSide::Sell => 0, crate::stock_pool_execution::OrderSide::Buy => 1 });
|
||||
due
|
||||
}
|
||||
pub fn len(&self) -> usize { self.rows.len() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn target(symbol:&str,side:crate::stock_pool_execution::OrderSide,generation:&str)->DeferredEtfTarget {
|
||||
let date=NaiveDate::from_ymd_opt(2026,1,2).unwrap();
|
||||
DeferredEtfTarget {pool_id:"pool".into(),generation:generation.into(),symbol:symbol.into(),signal_date:date,signal_at:date.and_hms_opt(13,0,0).unwrap(),execute_on:NaiveDate::from_ymd_opt(2026,1,5),target_value:1000.into(),target_weight_bps:5000,side,max_positions:2,rule:Default::default(),members:std::sync::Arc::new(vec![]),reason:"fixture".into()}
|
||||
}
|
||||
#[test]
|
||||
fn latest_generation_overwrites_pending_targets_and_preserves_candidate_order() {
|
||||
use crate::stock_pool_execution::OrderSide::{Buy,Sell};
|
||||
let mut queue=DeferredEtfTargets::default();
|
||||
queue.replace_generation("pool","v1");
|
||||
queue.upsert(target("510300.SH",Buy,"v1"));
|
||||
queue.upsert(target("159915.SZ",Buy,"v1"));
|
||||
assert_eq!(queue.replace_generation("pool","v1"),0);
|
||||
assert_eq!(queue.replace_generation("pool","v2"),2);
|
||||
queue.upsert(target("560450.SH",Buy,"v2"));
|
||||
queue.upsert(target("159915.SZ",Sell,"v2"));
|
||||
queue.upsert(target("510300.SH",Buy,"v2"));
|
||||
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,1,2).unwrap()).is_empty());
|
||||
let due=queue.take_due(NaiveDate::from_ymd_opt(2026,1,5).unwrap());
|
||||
assert_eq!(due.iter().map(|v|v.symbol.as_str()).collect::<Vec<_>>(),vec!["159915.SZ","560450.SH","510300.SH"]);
|
||||
assert!(due.iter().all(|v|v.generation=="v2"));
|
||||
assert_eq!(queue.len(),0);
|
||||
}
|
||||
#[test]
|
||||
fn no_loaded_next_session_is_not_guessed_from_natural_days() {
|
||||
let mut queue=DeferredEtfTargets::default();
|
||||
let mut item=target("510300.SH",crate::stock_pool_execution::OrderSide::Buy,"v1");
|
||||
item.execute_on=None;
|
||||
queue.upsert(item);
|
||||
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,2,1).unwrap()).is_empty());
|
||||
assert_eq!(queue.len(),1);
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,8 @@ pub enum ProcessEventKind {
|
||||
AccountDepositWithdraw,
|
||||
AccountFinanceRepay,
|
||||
AccountManagementFee,
|
||||
SessionCapacityAudit,
|
||||
EtfExecutionFallback,
|
||||
}
|
||||
|
||||
impl ProcessEventKind {
|
||||
@@ -362,6 +364,8 @@ impl ProcessEventKind {
|
||||
Self::AccountDepositWithdraw => "account_deposit_withdraw",
|
||||
Self::AccountFinanceRepay => "account_finance_repay",
|
||||
Self::AccountManagementFee => "account_management_fee",
|
||||
Self::SessionCapacityAudit => "session_capacity_audit",
|
||||
Self::EtfExecutionFallback => "etf_execution_fallback",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +397,8 @@ impl ProcessEventKind {
|
||||
| Self::AccountDepositWithdraw
|
||||
| Self::AccountFinanceRepay
|
||||
| Self::AccountManagementFee
|
||||
| Self::SessionCapacityAudit
|
||||
| Self::EtfExecutionFallback
|
||||
| Self::Settlement
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Causal volume budgets. Session totals may audit fills, never size earlier orders.
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VolumeCapacityMode {
|
||||
#[default]
|
||||
ExecutionObservation,
|
||||
CompletedBar,
|
||||
SessionCapacityAudit,
|
||||
}
|
||||
|
||||
impl VolumeCapacityMode {
|
||||
pub fn validate(self, enabled: bool, has_execution_observations: bool) -> Result<(), CapacityError> {
|
||||
if !enabled { return Ok(()); }
|
||||
match self {
|
||||
Self::ExecutionObservation if !has_execution_observations => Err(CapacityError::MissingObservation),
|
||||
Self::CompletedBar => Err(CapacityError::MissingCompletedBar),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limits_execution_quantity(self) -> bool { self != Self::SessionCapacityAudit }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
pub enum CapacityError {
|
||||
#[error("execution capacity ratio must be finite and in (0, 1]")]
|
||||
InvalidRatio,
|
||||
#[error("execution capacity decimal cannot be represented exactly")]
|
||||
InvalidDecimal,
|
||||
#[error("execution capacity observation has invalid time bounds")]
|
||||
InvalidWindow,
|
||||
#[error("execution capacity is not visible: available={available_at}, execution={execution_at}")]
|
||||
NotVisible { available_at: NaiveDateTime, execution_at: NaiveDateTime },
|
||||
#[error("execution capacity observation belongs to another session")]
|
||||
WrongSession,
|
||||
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
|
||||
MissingObservation,
|
||||
#[error("completed_bar capacity requires declared bar end and availability; an undated daily total is not a completed observation")]
|
||||
MissingCompletedBar,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CapacityAuditSummary {
|
||||
pub mode: VolumeCapacityMode,
|
||||
pub enabled: bool,
|
||||
pub participation_rate: f64,
|
||||
pub audited_symbol_sessions: usize,
|
||||
pub failed_symbol_sessions: usize,
|
||||
pub audit_passed: Option<bool>,
|
||||
pub execution_time_capacity_proven: bool,
|
||||
}
|
||||
|
||||
impl CapacityAuditSummary {
|
||||
pub fn observe(&mut self, audit: &SessionCapacityAudit) {
|
||||
self.audited_symbol_sessions += 1;
|
||||
self.failed_symbol_sessions += usize::from(!audit.passed);
|
||||
self.audit_passed = Some(self.failed_symbol_sessions == 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ParticipationRate {
|
||||
numerator: u128,
|
||||
denominator: u128,
|
||||
}
|
||||
|
||||
impl ParticipationRate {
|
||||
pub fn new(rate: f64) -> Result<Self, CapacityError> {
|
||||
if !rate.is_finite() || rate <= 0.0 || rate > 1.0 {
|
||||
return Err(CapacityError::InvalidRatio);
|
||||
}
|
||||
if rate < 1e-20 {
|
||||
// Even u64::MAX shares at this rate cannot admit a single share.
|
||||
return Ok(Self { numerator: 0, denominator: 1 });
|
||||
}
|
||||
if rate == 1.0 {
|
||||
return Ok(Self { numerator: 1, denominator: 1 });
|
||||
}
|
||||
let text = rate.to_string();
|
||||
let digits = text.strip_prefix("0.").ok_or(CapacityError::InvalidDecimal)?;
|
||||
let digits = digits.trim_end_matches('0');
|
||||
let numerator = digits.parse::<u128>().map_err(|_| CapacityError::InvalidDecimal)?;
|
||||
let denominator = 10_u128.checked_pow(digits.len() as u32).ok_or(CapacityError::InvalidDecimal)?;
|
||||
if numerator > u128::MAX / u128::from(u64::MAX) {
|
||||
return Err(CapacityError::InvalidDecimal);
|
||||
}
|
||||
Ok(Self { numerator, denominator })
|
||||
}
|
||||
|
||||
pub fn total_shares(self, market_shares: u64) -> u64 {
|
||||
let total = u128::from(market_shares) * self.numerator / self.denominator;
|
||||
u64::try_from(total).expect("participation rate cannot exceed the market shares")
|
||||
}
|
||||
|
||||
pub fn remaining(self, market_shares: u64, consumed_shares: u64, requested: u32) -> u32 {
|
||||
self.total_shares(market_shares).saturating_sub(consumed_shares).min(u64::from(requested)) as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VolumeObservationKind {
|
||||
TradeIncrement,
|
||||
CompletedBar,
|
||||
CumulativeSession,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct VolumeObservation {
|
||||
pub kind: VolumeObservationKind,
|
||||
pub start: NaiveDateTime,
|
||||
pub end: NaiveDateTime,
|
||||
pub available_at: NaiveDateTime,
|
||||
pub shares: u64,
|
||||
}
|
||||
|
||||
impl VolumeObservation {
|
||||
pub fn visible_shares(self, execution_at: NaiveDateTime) -> Result<u64, CapacityError> {
|
||||
if self.start > self.end || self.available_at < self.end {
|
||||
return Err(CapacityError::InvalidWindow);
|
||||
}
|
||||
if self.available_at > execution_at {
|
||||
return Err(CapacityError::NotVisible { available_at: self.available_at, execution_at });
|
||||
}
|
||||
if self.start.date() != self.end.date() || self.end.date() != execution_at.date() {
|
||||
return Err(CapacityError::WrongSession);
|
||||
}
|
||||
Ok(self.shares)
|
||||
}
|
||||
|
||||
pub fn remaining(self, execution_at: NaiveDateTime, rate: ParticipationRate, consumed: u64, requested: u32) -> Result<u32, CapacityError> {
|
||||
Ok(rate.remaining(self.visible_shares(execution_at)?, consumed, requested))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionCapacityAudit {
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
pub filled_shares: u64,
|
||||
pub session_shares: u64,
|
||||
pub allowed_shares: u64,
|
||||
pub passed: bool,
|
||||
}
|
||||
|
||||
impl SessionCapacityAudit {
|
||||
pub fn new(date: NaiveDate, symbol: String, filled_shares: u64, session_shares: u64, rate: ParticipationRate) -> Self {
|
||||
let allowed_shares = rate.total_shares(session_shares);
|
||||
Self { date, symbol, filled_shares, session_shares, allowed_shares, passed: filled_shares <= allowed_shares }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decimal_participation_never_rounds_a_fractional_share_up_or_overflows() {
|
||||
assert_eq!(ParticipationRate::new(0.58).unwrap().total_shares(50), 29);
|
||||
assert_eq!(ParticipationRate::new(0.25).unwrap().total_shares(3), 0);
|
||||
assert_eq!(ParticipationRate::new(0.5).unwrap().total_shares(3), 1);
|
||||
assert_eq!(ParticipationRate::new(1.).unwrap().total_shares(u64::MAX), u64::MAX);
|
||||
assert_eq!(ParticipationRate::new(0.25).unwrap().remaining(u64::MAX, 0, u32::MAX), u32::MAX);
|
||||
assert_eq!(ParticipationRate::new(f64::MIN_POSITIVE).unwrap().total_shares(u64::MAX), 0);
|
||||
for rate in [0., -1., f64::NAN, f64::INFINITY, 1.001] {
|
||||
assert!(ParticipationRate::new(rate).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_volume_cannot_be_used_for_an_earlier_open() {
|
||||
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
|
||||
let opening = day.and_hms_opt(9,30,0).unwrap();
|
||||
let closing = day.and_hms_opt(15,0,0).unwrap();
|
||||
let observation = VolumeObservation { kind:VolumeObservationKind::CompletedBar, start:opening, end:closing, available_at:closing, shares:10000 };
|
||||
assert!(matches!(observation.visible_shares(opening), Err(CapacityError::NotVisible { .. })));
|
||||
assert_eq!(observation.remaining(closing, ParticipationRate::new(0.25).unwrap(), 1000, 5000).unwrap(), 1500);
|
||||
assert!(matches!(observation.visible_shares(closing+chrono::Duration::days(1)), Err(CapacityError::WrongSession)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_publication_and_invalid_bounds_are_not_treated_as_zero_volume() {
|
||||
let at = NaiveDate::from_ymd_opt(2025,1,2).unwrap().and_hms_opt(10,18,0).unwrap();
|
||||
let observation = VolumeObservation { kind:VolumeObservationKind::TradeIncrement, start:at, end:at, available_at:at+chrono::Duration::seconds(1), shares:0 };
|
||||
assert!(matches!(observation.visible_shares(at), Err(CapacityError::NotVisible { .. })));
|
||||
assert_eq!(VolumeObservation { available_at:at-chrono::Duration::seconds(1), ..observation }.visible_shares(at), Err(CapacityError::InvalidWindow));
|
||||
assert_eq!(VolumeObservation { available_at:at, ..observation }.visible_shares(at).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_audit_changes_verdict_not_executed_quantity() {
|
||||
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
|
||||
let rate = ParticipationRate::new(0.25).unwrap();
|
||||
let a = SessionCapacityAudit::new(day,"TEST".into(),1000,3000,rate);
|
||||
let b = SessionCapacityAudit::new(day,"TEST".into(),1000,5000,rate);
|
||||
assert!(!a.passed); assert!(b.passed);
|
||||
assert_eq!(a.filled_shares,b.filled_shares);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use crate::engine::BacktestError;
|
||||
|
||||
/// Cumulative TWAP entitlement depends on the frozen clock, not future quotes.
|
||||
pub(crate) struct TwapSchedule {
|
||||
start: NaiveDateTime,
|
||||
end: NaiveDateTime,
|
||||
quantity: u32,
|
||||
}
|
||||
|
||||
impl TwapSchedule {
|
||||
pub(crate) fn new(
|
||||
start: Option<NaiveDateTime>,
|
||||
end: Option<NaiveDateTime>,
|
||||
quantity: u32,
|
||||
) -> Result<Self, BacktestError> {
|
||||
let (Some(start), Some(end)) = (start, end) else {
|
||||
return Err(BacktestError::Execution("TWAP requires an explicit start and end clock".into()));
|
||||
};
|
||||
if end < start || start.date() != end.date() {
|
||||
return Err(BacktestError::Execution("TWAP requires an ordered same-session clock window".into()));
|
||||
}
|
||||
Ok(Self { start, end, quantity })
|
||||
}
|
||||
|
||||
pub(crate) fn due_quantity(&self, at: NaiveDateTime, filled: u32) -> u32 {
|
||||
if at < self.start {
|
||||
return 0;
|
||||
}
|
||||
let entitlement = if at >= self.end {
|
||||
self.quantity
|
||||
} else {
|
||||
let elapsed = (at - self.start).num_microseconds().expect("same-day interval") as u128;
|
||||
let duration = (self.end - self.start).num_microseconds().expect("same-day interval") as u128;
|
||||
(u128::from(self.quantity) * elapsed / duration) as u32
|
||||
};
|
||||
entitlement.saturating_sub(filled)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{Duration, NaiveDate};
|
||||
|
||||
#[test]
|
||||
fn clock_entitlements_are_exact_and_allow_backlog_without_future_quote_counts() {
|
||||
let start = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(10, 0, 0).unwrap();
|
||||
let end = start + Duration::minutes(10);
|
||||
let schedule = TwapSchedule::new(Some(start), Some(end), 1_000).unwrap();
|
||||
assert_eq!(schedule.due_quantity(start, 0), 0);
|
||||
assert_eq!(schedule.due_quantity(start + Duration::minutes(2), 0), 200);
|
||||
assert_eq!(schedule.due_quantity(start + Duration::minutes(5), 100), 400);
|
||||
assert_eq!(schedule.due_quantity(end, 100), 900);
|
||||
assert_eq!(schedule.due_quantity(end, 1_000), 0);
|
||||
assert!(TwapSchedule::new(Some(start), None, 1_000).is_err());
|
||||
assert!(TwapSchedule::new(Some(end), Some(start), 1_000).is_err());
|
||||
assert_eq!(TwapSchedule::new(Some(start), Some(start), 1_000).unwrap().due_quantity(start, 0), 1_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Indicator metadata is versioned independently from the numerical kernel.
|
||||
use crate::factor_events::{CONTRACT, OPERATORS, TA_REV};
|
||||
use serde_json::{Value, json};
|
||||
use ta_lib::abstract_api::{self, OptInputType};
|
||||
|
||||
pub fn catalog() -> Value {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut implementation = Sha256::new();
|
||||
for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(),
|
||||
include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(),
|
||||
include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);}
|
||||
let implementation_sha256=format!("{:x}",implementation.finalize());
|
||||
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
|
||||
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
|
||||
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
|
||||
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":parameter_domain(p.kind)})).collect::<Vec<_>>(),
|
||||
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
|
||||
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
|
||||
})).collect();
|
||||
json!({"contract":CONTRACT,"parameter_domain_contract":"fidc.indicator-parameter-domain/v1","expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
|
||||
"execution_context_contract":crate::pattern_context::CONTRACT,
|
||||
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
|
||||
"market_event_context_contract":crate::market_event_context::CONTRACT,
|
||||
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
|
||||
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
|
||||
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
|
||||
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
|
||||
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
|
||||
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
|
||||
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
|
||||
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
|
||||
}
|
||||
|
||||
pub(crate) fn parameter_domain(kind: OptInputType) -> Value {
|
||||
match kind {
|
||||
OptInputType::IntegerRange { min, max, default, .. } => json!({
|
||||
"value_type":"integer", "minimum":min, "maximum":max, "default":default,
|
||||
}),
|
||||
OptInputType::RealRange { min, max, default, precision, .. } => json!({
|
||||
"value_type":"number", "minimum":min, "maximum":max,
|
||||
"default":default, "display_precision":precision,
|
||||
}),
|
||||
OptInputType::IntegerList { values, default } => json!({
|
||||
"value_type":"integer", "default":default,
|
||||
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
|
||||
}),
|
||||
OptInputType::RealList { values, default } => json!({
|
||||
"value_type":"number", "default":default,
|
||||
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ pub mod broker;
|
||||
pub mod calendar;
|
||||
pub mod cost;
|
||||
pub mod data;
|
||||
mod numeric_factors;
|
||||
pub mod daily_patterns;
|
||||
pub mod pattern_context;
|
||||
pub mod session_events;
|
||||
pub mod factor_events;
|
||||
pub mod execution_capacity;
|
||||
mod etf_execution;
|
||||
mod execution_schedule;
|
||||
mod factor_event_catalog;
|
||||
pub mod factor_cross_section;
|
||||
pub mod market_event_context;
|
||||
pub mod engine;
|
||||
@@ -26,6 +31,12 @@ pub mod rules;
|
||||
pub mod scheduler;
|
||||
pub mod strategy;
|
||||
pub mod holding_policy;
|
||||
pub mod stock_pool_candidates;
|
||||
pub mod stock_pool_indicators;
|
||||
pub mod stock_pool_execution;
|
||||
pub mod stock_pool_index_policy;
|
||||
pub mod stock_pool_market_cap;
|
||||
pub mod stock_pool_state;
|
||||
pub mod signal_contract;
|
||||
pub mod strategy_ai;
|
||||
pub mod universe;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::ops::Index;
|
||||
|
||||
use serde::de::{MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// Sorted numeric fields stored contiguously, without a tree node per snapshot.
|
||||
#[derive(Clone, Default, PartialEq)]
|
||||
pub struct NumericFactorMap {
|
||||
entries: Vec<(Cow<'static, str>, f64)>,
|
||||
}
|
||||
|
||||
impl NumericFactorMap {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key))
|
||||
.ok()
|
||||
.map(|index| &self.entries[index].1)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, key: &str) -> Option<&mut f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key))
|
||||
.ok()
|
||||
.map(|index| &mut self.entries[index].1)
|
||||
}
|
||||
|
||||
pub fn contains_key(&self, key: &str) -> bool {
|
||||
self.get(key).is_some()
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, key: Cow<'static, str>, value: f64) -> Option<f64> {
|
||||
if self
|
||||
.entries
|
||||
.last()
|
||||
.is_none_or(|(last, _)| last.as_ref() < key.as_ref())
|
||||
{
|
||||
self.entries.push((key, value));
|
||||
return None;
|
||||
}
|
||||
match self
|
||||
.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key.as_ref()))
|
||||
{
|
||||
Ok(index) => Some(std::mem::replace(&mut self.entries[index].1, value)),
|
||||
Err(index) => {
|
||||
self.entries.insert(index, (key, value));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &str) -> Option<f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key))
|
||||
.ok()
|
||||
.map(|index| self.entries.remove(index).1)
|
||||
}
|
||||
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Cow<'static, str>, &mut f64) -> bool) {
|
||||
self.entries.retain_mut(|(key, value)| keep(key, value));
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> Iter<'_> {
|
||||
Iter(self.entries.iter())
|
||||
}
|
||||
pub fn keys(&self) -> impl DoubleEndedIterator<Item = &Cow<'static, str>> + ExactSizeIterator {
|
||||
self.entries.iter().map(|(key, _)| key)
|
||||
}
|
||||
pub fn values(&self) -> impl DoubleEndedIterator<Item = &f64> + ExactSizeIterator {
|
||||
self.entries.iter().map(|(_, value)| value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for NumericFactorMap {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_map().entries(self).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<&str> for NumericFactorMap {
|
||||
type Output = f64;
|
||||
fn index(&self, key: &str) -> &Self::Output {
|
||||
self.get(key).expect("numeric factor key not found")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Iter<'a>(std::slice::Iter<'a, (Cow<'static, str>, f64)>);
|
||||
impl<'a> Iterator for Iter<'a> {
|
||||
type Item = (&'a Cow<'static, str>, &'a f64);
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.0.next().map(|(k, v)| (k, v))
|
||||
}
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
self.0.size_hint()
|
||||
}
|
||||
}
|
||||
impl DoubleEndedIterator for Iter<'_> {
|
||||
fn next_back(&mut self) -> Option<Self::Item> {
|
||||
self.0.next_back().map(|(k, v)| (k, v))
|
||||
}
|
||||
}
|
||||
impl ExactSizeIterator for Iter<'_> {}
|
||||
impl<'a> IntoIterator for &'a NumericFactorMap {
|
||||
type Item = (&'a Cow<'static, str>, &'a f64);
|
||||
type IntoIter = Iter<'a>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
impl IntoIterator for NumericFactorMap {
|
||||
type Item = (Cow<'static, str>, f64);
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.entries.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<(Cow<'static, str>, f64)> for NumericFactorMap {
|
||||
fn from_iter<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(iter: T) -> Self {
|
||||
let mut entries: Vec<_> = iter.into_iter().collect();
|
||||
// Stable sorting preserves last-value-wins for repeated input keys.
|
||||
if !entries.windows(2).all(|pair| pair[0].0 <= pair[1].0) {
|
||||
entries.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
}
|
||||
entries.dedup_by(|later, earlier| {
|
||||
if later.0 == earlier.0 {
|
||||
earlier.1 = later.1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
Self { entries }
|
||||
}
|
||||
}
|
||||
impl Extend<(Cow<'static, str>, f64)> for NumericFactorMap {
|
||||
fn extend<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(&mut self, iter: T) {
|
||||
let mut incoming: Self = iter.into_iter().collect();
|
||||
if incoming.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.is_empty() {
|
||||
*self = incoming;
|
||||
return;
|
||||
}
|
||||
if self.entries.last().unwrap().0 < incoming.entries[0].0 {
|
||||
self.entries.append(&mut incoming.entries);
|
||||
return;
|
||||
}
|
||||
// Merge sorted sets in linear time; wide factor batches must not shift
|
||||
// the existing vector once per field. Existing keys keep their identity.
|
||||
let mut merged = Vec::with_capacity(self.len() + incoming.len());
|
||||
let mut old = std::mem::take(&mut self.entries).into_iter().peekable();
|
||||
let mut new = incoming.entries.into_iter().peekable();
|
||||
while let (Some(left), Some(right)) = (old.peek(), new.peek()) {
|
||||
match left.0.cmp(&right.0) {
|
||||
std::cmp::Ordering::Less => merged.push(old.next().unwrap()),
|
||||
std::cmp::Ordering::Greater => merged.push(new.next().unwrap()),
|
||||
std::cmp::Ordering::Equal => {
|
||||
let (key, _) = old.next().unwrap();
|
||||
merged.push((key, new.next().unwrap().1));
|
||||
}
|
||||
}
|
||||
}
|
||||
merged.extend(old);
|
||||
merged.extend(new);
|
||||
self.entries = merged;
|
||||
}
|
||||
}
|
||||
impl<const N: usize> From<[(Cow<'static, str>, f64); N]> for NumericFactorMap {
|
||||
fn from(entries: [(Cow<'static, str>, f64); N]) -> Self {
|
||||
entries.into_iter().collect()
|
||||
}
|
||||
}
|
||||
impl From<BTreeMap<Cow<'static, str>, f64>> for NumericFactorMap {
|
||||
fn from(entries: BTreeMap<Cow<'static, str>, f64>) -> Self {
|
||||
Self {
|
||||
entries: entries.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for NumericFactorMap {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut map = serializer.serialize_map(Some(self.len()))?;
|
||||
for (key, value) in self {
|
||||
map.serialize_entry(key, value)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for NumericFactorMap {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct Fields;
|
||||
impl<'de> Visitor<'de> for Fields {
|
||||
type Value = NumericFactorMap;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("a numeric factor map")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
|
||||
let mut entries = Vec::new();
|
||||
while let Some((key, value)) = map.next_entry::<String, f64>()? {
|
||||
entries.push((Cow::Owned(key), value));
|
||||
}
|
||||
Ok(entries.into_iter().collect())
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(Fields)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn updates_order_removal_and_values_match_tree_map() {
|
||||
let mut flat = NumericFactorMap::new();
|
||||
let mut tree = BTreeMap::new();
|
||||
let mut seed = 71_u64;
|
||||
for index in 0..10000 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
let key: Cow<'static, str> = Cow::Owned(format!("factor_{:04}", (seed >> 32) % 1000));
|
||||
let value = (index as f64 - 5000.0) / 7.0;
|
||||
if index % 11 == 0 {
|
||||
assert_eq!(flat.remove(key.as_ref()), tree.remove(key.as_ref()));
|
||||
} else {
|
||||
assert_eq!(flat.insert(key.clone(), value), tree.insert(key, value));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
);
|
||||
flat.retain(|_, value| *value > 100.0);
|
||||
tree.retain(|_, value| *value > 100.0);
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
std::mem::size_of::<NumericFactorMap>(),
|
||||
std::mem::size_of_val(&tree)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_load_is_sorted_and_keeps_last_value_for_each_field() {
|
||||
let input = vec![
|
||||
(Cow::Borrowed("z"), 2.0),
|
||||
(Cow::Borrowed("a"), 1.0),
|
||||
(Cow::Borrowed("z"), 3.0),
|
||||
(Cow::Borrowed("z"), 4.0),
|
||||
];
|
||||
let flat: NumericFactorMap = input.clone().into_iter().collect();
|
||||
let tree: BTreeMap<_, _> = input.into_iter().collect();
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(flat["z"], 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialization_keeps_the_map_contract_and_precise_numbers() {
|
||||
let input = [
|
||||
(Cow::Borrowed("zero"), -0.0),
|
||||
(Cow::Borrowed("tiny"), 1.0000000000000002),
|
||||
(Cow::Borrowed("large"), 9.123456789123456e20),
|
||||
];
|
||||
let flat = NumericFactorMap::from(input.clone());
|
||||
let tree = BTreeMap::from(input);
|
||||
let json = serde_json::to_string(&flat).unwrap();
|
||||
assert_eq!(json, serde_json::to_string(&tree).unwrap());
|
||||
let decoded: NumericFactorMap = serde_json::from_str(&json).unwrap();
|
||||
for (key, value) in &flat {
|
||||
assert_eq!(value.to_bits(), decoded[key.as_ref()].to_bits());
|
||||
}
|
||||
let duplicate: NumericFactorMap = serde_json::from_str(r#"{"x":1,"x":2}"#).unwrap();
|
||||
assert_eq!(duplicate["x"], 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_does_not_share_mutable_values() {
|
||||
let original = NumericFactorMap::from([(Cow::Borrowed("signal"), 1.0)]);
|
||||
let mut copy = original.clone();
|
||||
*copy.get_mut("signal").unwrap() = 0.0;
|
||||
copy.insert(Cow::Borrowed("other"), 2.0);
|
||||
assert_eq!(original["signal"], 1.0);
|
||||
assert!(!original.contains_key("other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_batch_merge_matches_tree_and_preserves_old_key_ownership() {
|
||||
let entries = (0..4096)
|
||||
.map(|index| (Cow::Owned(format!("f_{index:05}")), index as f64))
|
||||
.collect::<Vec<_>>();
|
||||
let mut flat: NumericFactorMap = entries.clone().into_iter().collect();
|
||||
let mut tree = BTreeMap::from_iter(entries);
|
||||
flat.insert(Cow::Borrowed("shared"), -0.0);
|
||||
tree.insert(Cow::Borrowed("shared"), -0.0);
|
||||
let incoming = (2048..8192)
|
||||
.rev()
|
||||
.map(|index| (Cow::Owned(format!("f_{index:05}")), -(index as f64)))
|
||||
.chain([(Cow::Owned("shared".to_owned()), 1.0)])
|
||||
.collect::<Vec<_>>();
|
||||
flat.extend(incoming.clone());
|
||||
tree.extend(incoming);
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
);
|
||||
assert!(matches!(flat.keys().last(), Some(Cow::Borrowed("shared"))));
|
||||
flat.extend([(Cow::Borrowed("zz"), f64::NAN)]);
|
||||
assert!(flat["zz"].is_nan());
|
||||
flat.extend(std::iter::empty());
|
||||
assert_eq!(flat.len(), tree.len() + 1);
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,11 @@ pub fn build_dataset_context(
|
||||
}
|
||||
|
||||
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
|
||||
// A runner bundle also contains source/extract copies. Follow the same
|
||||
// authoritative spec selection as the execution loader, not those copies.
|
||||
if let Some(spec) = value.get("strategySpec").or_else(|| value.get("strategy_spec")) {
|
||||
return specs_in_value(spec);
|
||||
}
|
||||
let mut specs = Vec::new();
|
||||
match value {
|
||||
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
|
||||
@@ -221,7 +226,15 @@ pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
|
||||
}
|
||||
}
|
||||
Value::Object(items) => {
|
||||
for v in items.values() {
|
||||
let typed_pool = items.get("stockPool").or_else(|| items.get("stock_pool"))
|
||||
.is_some_and(Value::is_object);
|
||||
for (key, v) in items {
|
||||
// The executable pool already supplies decoded expressions.
|
||||
// Its display/source serialization escapes those expressions
|
||||
// one more time and is not another executable program.
|
||||
if typed_pool && matches!(key.as_str(), "sourceCode" | "source_code") {
|
||||
continue;
|
||||
}
|
||||
specs.extend(specs_in_value(v)?);
|
||||
}
|
||||
}
|
||||
@@ -248,6 +261,31 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn structured_pool_conditions_are_not_rescanned_inside_serialized_source_code() {
|
||||
let pattern = json!({"template":"expression","parameters":{"history_window":20},
|
||||
"expression":{"kind":"operator","name":"GT","args":[{"kind":"field","name":"amount"},{"kind":"number","value":0}]}});
|
||||
let expr = format!("pattern_signal({})", serde_json::to_string(&pattern.to_string()).unwrap());
|
||||
let pool = json!({"schema_version":1,"pool_id":"fixture","version_id":"v1","members":[],
|
||||
"allocation_policy":{},"timing_policy":{},"stop_take_policy":{},"out_of_pool_policy":"hold",
|
||||
"exit_signals":[{"role":"risk_exit","when_expr":expr,"remaining_position_bps":5000,"reason":"fixture"}]});
|
||||
let source = format!("stock_pool.config({pool})");
|
||||
for (pool_key, source_key) in [("stockPool", "sourceCode"), ("stock_pool", "source_code")] {
|
||||
let value = json!({pool_key:pool,source_key:source,"runtimeExpressions":{"trading":{"buyFilterExpr":expr}}});
|
||||
assert_eq!(specs_in_value(&value).unwrap().len(), 2);
|
||||
for wrapper in ["strategySpec", "strategy_spec"] {
|
||||
let bundle = json!({wrapper:value,"strategy_source":{"source_code":source},
|
||||
"strategy_extract":{"parameters":{"source_code":source}}});
|
||||
assert_eq!(specs_in_value(&bundle).unwrap().len(), 2);
|
||||
}
|
||||
let mut invalid = value.clone();
|
||||
invalid[pool_key]["exit_signals"][0]["when_expr"] = json!("pattern_signal(not-json)");
|
||||
assert!(specs_in_value(&invalid).is_err(), "invalid actual conditions must still fail");
|
||||
assert!(specs_in_value(&json!({"strategySpec":invalid})).is_err());
|
||||
}
|
||||
assert_eq!(specs_in_value(&json!({"sourceCode":format!("risk.stop_loss({expr})")})).unwrap().len(),1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
|
||||
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
|
||||
@@ -298,7 +336,7 @@ mod tests {
|
||||
][n][i];
|
||||
market.push(DailyMarketSnapshot {
|
||||
date: *d,
|
||||
symbol: s.to_string(),
|
||||
symbol: (*s).into(),
|
||||
timestamp: None,
|
||||
day_open: c,
|
||||
open: c,
|
||||
@@ -321,7 +359,7 @@ mod tests {
|
||||
});
|
||||
factors.push(DailyFactorSnapshot {
|
||||
date: *d,
|
||||
symbol: s.to_string(),
|
||||
symbol: (*s).into(),
|
||||
market_cap_bn: 1.,
|
||||
free_float_cap_bn: 1.,
|
||||
pe_ttm: 10.,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
//! Stock pools emit one frozen framework intent, not a lossy code-strategy translation.
|
||||
use super::*;
|
||||
use crate::stock_pool_execution as pool;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
impl PlatformExprStrategy {
|
||||
pub(super) fn stock_pool_decision(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, BacktestError> {
|
||||
let program = self
|
||||
.config
|
||||
.stock_pool
|
||||
.as_ref()
|
||||
.ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))?
|
||||
.clone();
|
||||
if !self.config.stop_loss_expr.trim().is_empty() || !self.config.take_profit_expr.trim().is_empty()
|
||||
|| self.config.position_target_rules.len() != program.exit_signals.len()
|
||||
|| self.config.position_target_rules.iter().zip(&program.exit_signals).any(|(compiled, frozen)|
|
||||
compiled.when_expr != frozen.when_expr || compiled.remaining_position_bps != frozen.remaining_position_bps
|
||||
|| compiled.reason != frozen.reason || compiled.stock_pool_role != frozen.role)
|
||||
{
|
||||
return Err(BacktestError::Execution("stock_pool_exit_roles_required: exit rules must remain bound to the frozen stock_pool program".into()));
|
||||
}
|
||||
let mut constraints = pool::stock_pool_constraints_from_configuration(
|
||||
&program.allocation_policy,
|
||||
&program.stop_take_policy,
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
if let Some(policy) = constraints
|
||||
.market_timing_policy
|
||||
.as_ref()
|
||||
.filter(|policy| policy.enabled)
|
||||
{
|
||||
let before_close = !ctx.is_lagged_execution()
|
||||
&& ctx
|
||||
.active_datetime
|
||||
.is_some_and(|at| at.time() < NaiveTime::from_hms_opt(15, 0, 0).unwrap());
|
||||
let as_of = if before_close {
|
||||
ctx.data
|
||||
.previous_trading_date(ctx.decision_date, 1)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"market_timing_previous_completed_session_missing".into(),
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
ctx.decision_date
|
||||
};
|
||||
let required = policy
|
||||
.required_history()
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let mut dates = ctx
|
||||
.data
|
||||
.calendar()
|
||||
.iter()
|
||||
.filter(|date| *date <= as_of)
|
||||
.collect::<Vec<_>>();
|
||||
if dates.len() < required {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"market_timing_official_calendar_incomplete:required={required}:available={}",
|
||||
dates.len()
|
||||
)));
|
||||
}
|
||||
dates = dates.split_off(dates.len() - required);
|
||||
let index = policy.index_code.as_ref().expect("validated index policy");
|
||||
let closes = dates
|
||||
.iter()
|
||||
.map(|date| {
|
||||
let row = ctx.data.market(*date, index).ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"market_timing_completed_index_row_missing:{index}:{date}"
|
||||
))
|
||||
})?;
|
||||
Ok(crate::stock_pool_index_policy::IndexClose {
|
||||
date: *date,
|
||||
close: row.close,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, BacktestError>>()?;
|
||||
constraints.market_timing_input =
|
||||
Some(crate::stock_pool_index_policy::MarketTimingInput {
|
||||
index_code: index.clone(),
|
||||
as_of_date: as_of,
|
||||
official_dates: dates,
|
||||
closes,
|
||||
});
|
||||
}
|
||||
let rule = pool::normalize_stock_pool_execution_rule_with_exit_roles(
|
||||
Some(&program.timing_policy),
|
||||
!self.config.buy_filter_expr.trim().is_empty(),
|
||||
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::OrdinarySell),
|
||||
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::RiskExit),
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
if self.config.in_skip_window(ctx.decision_date) {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
let explicit_quote_condition = self.selection_quote_usage != StockFilterQuoteUsage::DailyOnly
|
||||
|| [self.config.buy_filter_expr.as_str(), self.config.stop_loss_expr.as_str(), self.config.take_profit_expr.as_str()]
|
||||
.into_iter().chain(self.config.position_target_rules.iter().map(|rule|rule.when_expr.as_str()))
|
||||
.any(|expression|Self::stock_filter_quote_usage_for_expr(expression)!=StockFilterQuoteUsage::DailyOnly);
|
||||
if explicit_quote_condition && ctx.active_datetime.is_some_and(|at|at.time()<NaiveTime::from_hms_opt(15,0,0).unwrap()) {
|
||||
for symbol in program.members.iter().map(|member|&member.symbol).chain(ctx.portfolio.positions().keys()) {
|
||||
if ctx.data.instrument(symbol).is_some_and(|instrument|instrument.is_exchange_traded_fund()&&instrument.dated_market_absence_reason(ctx.execution_date).is_none())
|
||||
&& self.scheduled_quote_at_time(ctx,ctx.execution_date,symbol,None).is_none()
|
||||
{
|
||||
return Err(BacktestError::Execution(format!("etf_intraday_condition_evidence_missing:{symbol}; completed daily references cannot make minute/tick conditions true")));
|
||||
}
|
||||
}
|
||||
}
|
||||
let day = self.day_state(ctx, ctx.decision_date)?;
|
||||
let (market_date, universe_date, factor_date) = self.selection_dates(ctx);
|
||||
let (low, high) = self.market_cap_band(ctx, &day)?;
|
||||
let (ranked, mut diagnostics, risk_decisions) = self.select_symbols(
|
||||
ctx,
|
||||
market_date,
|
||||
universe_date,
|
||||
factor_date,
|
||||
&day,
|
||||
low,
|
||||
high,
|
||||
usize::MAX,
|
||||
)?;
|
||||
let held = ctx
|
||||
.portfolio
|
||||
.positions()
|
||||
.values()
|
||||
.filter(|p| p.quantity > 0)
|
||||
.map(|p| p.symbol.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if !self.config.buy_filter_expr.trim().is_empty() {
|
||||
for symbol in &ranked {
|
||||
let stock =
|
||||
self.stock_state_with_factor_date(ctx, market_date, factor_date, symbol)?;
|
||||
if !self.eval_bool(ctx, &self.config.buy_filter_expr, &day, Some(&stock), None)? {
|
||||
constraints
|
||||
.buy_denials
|
||||
.insert(symbol.clone(), vec!["frozen_buy_condition_not_met".into()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (role, targets) in self.current_position_target_rules_by_role(ctx, ctx.decision_date, factor_date, &day)? {
|
||||
let output = match role { pool::StockPoolExitRole::OrdinarySell => &mut constraints.position_target_bps, pool::StockPoolExitRole::RiskExit => &mut constraints.independent_position_target_bps };
|
||||
for (symbol, (bps, _)) in targets { output.insert(symbol, bps); }
|
||||
}
|
||||
let limit = constraints.target_holding_count.unwrap_or(ranked.len());
|
||||
let final_symbols = ranked
|
||||
.iter()
|
||||
.take(limit)
|
||||
.cloned()
|
||||
.collect();
|
||||
let generation = format!(
|
||||
"stock-pool:{}:{}:{}",
|
||||
program.pool_id,
|
||||
program.version_id,
|
||||
ctx.active_datetime
|
||||
.map(|date| date.to_string())
|
||||
.unwrap_or_else(|| ctx.decision_date.to_string())
|
||||
);
|
||||
let selection = pool::StockPoolSelection {
|
||||
trade_date: ctx.decision_date,
|
||||
requested_symbols: ranked.clone(),
|
||||
normal_trading_symbols: ranked.clone(),
|
||||
risk_eligible_symbols: ranked.clone(),
|
||||
final_symbols,
|
||||
exclusion_reasons: BTreeMap::new(),
|
||||
inherited_from_generation: None,
|
||||
explicit_empty: program.members.is_empty()
|
||||
&& self.config.candidate_symbols_by_date.is_empty(),
|
||||
generation: Some(generation.clone()),
|
||||
};
|
||||
let by_symbol = program
|
||||
.members
|
||||
.iter()
|
||||
.map(|member| (member.symbol.as_str(), member))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut scope = ranked.clone();
|
||||
// Kept rules are execution metadata, not additional selection candidates.
|
||||
for symbol in held {
|
||||
if by_symbol.contains_key(symbol.as_str()) && !scope.contains(&symbol) {
|
||||
scope.push(symbol)
|
||||
}
|
||||
}
|
||||
let 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)]
|
||||
@@ -89,6 +91,8 @@ pub struct StrategyRebalanceSpec {
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyExecutionSpec {
|
||||
#[serde(default, alias = "volume_capacity_mode")]
|
||||
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
|
||||
#[serde(default)]
|
||||
pub frequency: Option<String>,
|
||||
#[serde(default, alias = "matching_type")]
|
||||
@@ -162,9 +166,22 @@ pub struct StrategyExecutionSpec {
|
||||
pub sell_then_buy_delay_slippage_rate: Option<f64>,
|
||||
}
|
||||
|
||||
impl StrategyRuntimeSpec {
|
||||
pub fn volume_capacity_mode(&self) -> Result<crate::execution_capacity::VolumeCapacityMode, String> {
|
||||
let engine = self.engine_config.as_ref().and_then(|config| config.volume_capacity_mode);
|
||||
let execution = self.execution.as_ref().and_then(|config| config.volume_capacity_mode);
|
||||
if engine.zip(execution).is_some_and(|(a, b)| a != b) {
|
||||
return Err("conflicting engine/execution volumeCapacityMode".into());
|
||||
}
|
||||
Ok(execution.or(engine).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyEngineConfig {
|
||||
#[serde(default, alias = "volume_capacity_mode")]
|
||||
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
|
||||
#[serde(default)]
|
||||
pub frequency: Option<String>,
|
||||
#[serde(default, alias = "template_id")]
|
||||
@@ -914,6 +931,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 +1671,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(
|
||||
@@ -1817,6 +1837,7 @@ pub fn platform_expr_config_from_spec(
|
||||
strategy_spec: Option<&StrategyRuntimeSpec>,
|
||||
) -> Result<PlatformExprStrategyConfig, String> {
|
||||
let mut cfg = PlatformExprStrategyConfig::generic();
|
||||
cfg.volume_capacity_mode = strategy_spec.map(StrategyRuntimeSpec::volume_capacity_mode).transpose()?.unwrap_or_default();
|
||||
cfg.strategy_name = strategy_id.to_string();
|
||||
if !signal_symbol.trim().is_empty() {
|
||||
cfg.signal_symbol = signal_symbol.trim().to_string();
|
||||
@@ -2138,6 +2159,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}")
|
||||
@@ -2305,6 +2339,7 @@ pub fn platform_expr_config_from_spec(
|
||||
));
|
||||
}
|
||||
cfg.position_target_rules.push(PlatformPositionTargetRule {
|
||||
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
|
||||
when_expr: when_expr.to_string(),
|
||||
remaining_position_bps: rule.remaining_position_bps,
|
||||
reason: rule
|
||||
@@ -2636,6 +2671,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 +2711,22 @@ 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 legacy_exit = !cfg.stop_loss_expr.trim().is_empty() || !cfg.take_profit_expr.trim().is_empty() || !cfg.position_target_rules.is_empty();
|
||||
if legacy_exit { return Err("stock_pool_exit_roles_required: regenerate this historical stock-pool strategy from its saved configuration; legacy risk expressions do not preserve ordinary/risk exit roles".into()); }
|
||||
let secondary_buy=!cfg.buy_filter_expr.trim().is_empty();
|
||||
pool.validate(secondary_buy,false)?;
|
||||
cfg.position_target_rules.extend(pool.exit_signals.iter().map(|signal| PlatformPositionTargetRule {
|
||||
when_expr: signal.when_expr.clone(), remaining_position_bps: signal.remaining_position_bps,
|
||||
reason: signal.reason.clone(), stock_pool_role: signal.role,
|
||||
}));
|
||||
cfg.stock_pool=Some(pool.clone());
|
||||
cfg.hold_until_exit_enabled=false;
|
||||
cfg.daily_top_up_enabled=false;
|
||||
cfg.daily_position_target_adjust_enabled=false;
|
||||
cfg.target_portfolio_daily_enabled=false;
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
@@ -3405,6 +3462,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
cfg.position_target_rules,
|
||||
vec![PlatformPositionTargetRule {
|
||||
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
|
||||
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
|
||||
remaining_position_bps: 5000,
|
||||
reason: "factor_reduce_position".to_string(),
|
||||
|
||||
@@ -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,25 @@ 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(crate) fn adjust_stock_pool_split(&mut self, symbol: &str, ratio: f64) -> Result<(), String> {
|
||||
let ratio = rust_decimal::Decimal::from_str_exact(&ratio.to_string())
|
||||
.map_err(|_| "stock_pool_execution_state_split_invalid".to_string())?;
|
||||
let adjusted = self.stock_pool_states.iter()
|
||||
.map(|(pool, state)| Ok((pool.clone(), state.adjust_for_split(symbol, ratio)?)))
|
||||
.collect::<Result<BTreeMap<_, _>, String>>()?;
|
||||
self.stock_pool_states = adjusted;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn initial_cash(&self) -> f64 {
|
||||
self.initial_cash.to_f64()
|
||||
}
|
||||
@@ -1417,8 +1438,6 @@ mod tests {
|
||||
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||
PriceField,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn cash_ledger_accumulates_micro_yuan_exactly() {
|
||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||
@@ -1620,7 +1639,7 @@ mod tests {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: prev_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: None,
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -1643,7 +1662,7 @@ mod tests {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: None,
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -1667,18 +1686,18 @@ mod tests {
|
||||
],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 50.0,
|
||||
free_float_cap_bn: 45.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -1733,7 +1752,7 @@ mod tests {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: None,
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -1756,18 +1775,18 @@ mod tests {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 50.0,
|
||||
free_float_cap_bn: 45.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -1819,7 +1838,7 @@ mod tests {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date: prev_date,
|
||||
symbol: "601028.SH".to_string(),
|
||||
symbol: "601028.SH".into(),
|
||||
timestamp: None,
|
||||
day_open: 10.2,
|
||||
open: 10.2,
|
||||
@@ -1902,7 +1921,7 @@ mod tests {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: buy_date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: None,
|
||||
day_open: 2.99,
|
||||
open: 2.99,
|
||||
@@ -1925,7 +1944,7 @@ mod tests {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: next_date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: None,
|
||||
day_open: 3.03,
|
||||
open: 3.03,
|
||||
|
||||
@@ -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 {
|
||||
@@ -867,7 +869,7 @@ mod tests {
|
||||
fn candidate(date: NaiveDate) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: "002633.SZ".to_string(),
|
||||
symbol: "002633.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -883,7 +885,7 @@ mod tests {
|
||||
fn market(date: NaiveDate, last_price: f64, lower_limit: f64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "002633.SZ".to_string(),
|
||||
symbol: "002633.SZ".into(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: last_price,
|
||||
open: last_price,
|
||||
@@ -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.to_string(),name:"fixture fund".into(),board:"ETF".into(),round_lot:100,listed_at:Some(d(2024,1,2)),delisted_at:None,status:"active".into()};
|
||||
let config=FidcRiskControlConfig::default();
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),None);
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.,&config),Some("invalid execution price"));
|
||||
candidate.is_paused=true;snapshot.paused=true;
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),Some("paused"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_quote_covers_missing_one_yuan_flag_but_not_other_risk_facts() {
|
||||
let day = d(2025, 2, 6);
|
||||
@@ -1031,7 +1047,7 @@ mod tests {
|
||||
config
|
||||
.static_rules
|
||||
.blacklisted_symbols
|
||||
.insert(candidate.symbol.clone());
|
||||
.insert(candidate.symbol.to_string());
|
||||
|
||||
let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &market, None, &config,
|
||||
@@ -1076,7 +1092,7 @@ mod tests {
|
||||
let mut candidate = candidate(date);
|
||||
let config = FidcRiskControlConfig::default();
|
||||
for symbol in ["688001.SH", "689001.SH", "000001.SZ"] {
|
||||
candidate.symbol = symbol.to_string();
|
||||
candidate.symbol = symbol.into();
|
||||
for is_kcb in [false, true] {
|
||||
candidate.is_kcb = is_kcb;
|
||||
let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
@@ -1184,7 +1200,7 @@ mod tests {
|
||||
fn configurable_bjse_filter_can_be_disabled() {
|
||||
let date = d(2025, 1, 2);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.symbol = "430047.BJ".to_string();
|
||||
candidate.symbol = "430047.BJ".into();
|
||||
candidate.allow_sell = true;
|
||||
let market = market(date, 6.27, 5.63);
|
||||
let default_selection =
|
||||
@@ -1216,7 +1232,7 @@ mod tests {
|
||||
fn concrete_kcb_reason_wins_over_generic_missing_risk_state() {
|
||||
let date = d(2025, 1, 2);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.symbol = "688506.SH".to_string();
|
||||
candidate.symbol = "688506.SH".into();
|
||||
candidate.is_kcb = true;
|
||||
candidate.risk_level_code = Some("missing_risk_state".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
|
||||
@@ -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
@@ -0,0 +1,150 @@
|
||||
//! Dated non-tradability and valuation are separate from execution quotations.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn validate(
|
||||
signal_date: NaiveDate,
|
||||
constraints: &StockPoolDecisionConstraints,
|
||||
current: &BTreeMap<String, (Decimal, Decimal, Decimal)>,
|
||||
) -> Result<(), String> {
|
||||
for (symbol, fact) in &constraints.frozen_positions {
|
||||
if constraints.execution_date != Some(fact.trade_date)
|
||||
|| fact.trade_date < signal_date
|
||||
|| fact.reason != "paused"
|
||||
|| fact.valuation_price <= Decimal::ZERO
|
||||
|| current.get(symbol).is_none_or(|row| row.0 <= Decimal::ZERO)
|
||||
{
|
||||
return Err(format!("stock_pool_frozen_position_invalid:{symbol}"));
|
||||
}
|
||||
}
|
||||
if constraints
|
||||
.prior_target_weights
|
||||
.iter()
|
||||
.any(|(symbol, weight)| {
|
||||
normalize_stock_symbol(symbol).as_ref() != Some(symbol)
|
||||
|| !(0..=10_000).contains(weight)
|
||||
})
|
||||
{
|
||||
return Err("stock_pool_prior_target_weights_invalid".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn valuation(
|
||||
symbol: &str,
|
||||
quotes: &HashMap<String, &MarketSnapshot>,
|
||||
frozen: &BTreeMap<String, FrozenStockPoolPosition>,
|
||||
) -> Result<Decimal, String> {
|
||||
frozen
|
||||
.get(symbol)
|
||||
.map(|fact| fact.valuation_price)
|
||||
.or_else(|| quotes.get(symbol).map(|quote| quote.last_price))
|
||||
.filter(|price| *price > Decimal::ZERO)
|
||||
.ok_or_else(|| format!("{symbol} confirmed holding valuation missing"))
|
||||
}
|
||||
|
||||
pub(super) fn weights(
|
||||
original: &[String],
|
||||
active: &[String],
|
||||
members: &[StockPoolMemberSpec],
|
||||
explicit: &BTreeMap<String, i32>,
|
||||
constraints: &StockPoolDecisionConstraints,
|
||||
reserved_slots: usize,
|
||||
target_count: usize,
|
||||
) -> Result<BTreeMap<String, i32>, String> {
|
||||
let count = original.len() + reserved_slots;
|
||||
let order = members
|
||||
.iter()
|
||||
.map(|member| (&member.symbol, member.requested_order))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut original_budget_symbols = original.to_vec();
|
||||
for symbol in constraints.frozen_positions.keys() {
|
||||
if order.contains_key(symbol) && !original_budget_symbols.contains(symbol) {
|
||||
original_budget_symbols.push(symbol.clone());
|
||||
}
|
||||
}
|
||||
if original_budget_symbols.len() != original.len() {
|
||||
original_budget_symbols
|
||||
.sort_by_key(|symbol| order.get(symbol).copied().unwrap_or(i32::MAX));
|
||||
}
|
||||
let initial = original_budget_symbols
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, symbol)| {
|
||||
let weight = if explicit.is_empty() {
|
||||
if count == 0 {
|
||||
0
|
||||
} else {
|
||||
10_000 / count as i32 + i32::from(index < 10_000 % count)
|
||||
}
|
||||
} else {
|
||||
*explicit.get(symbol).unwrap_or(&0)
|
||||
};
|
||||
(symbol.clone(), weight)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut frozen = BTreeMap::new();
|
||||
for symbol in constraints.frozen_positions.keys() {
|
||||
let weight = explicit
|
||||
.get(symbol)
|
||||
.copied()
|
||||
.or_else(|| constraints.prior_target_weights.get(symbol).copied())
|
||||
.or_else(|| {
|
||||
initial
|
||||
.iter()
|
||||
.find(|(key, _)| key == symbol)
|
||||
.map(|(_, weight)| *weight)
|
||||
})
|
||||
.ok_or_else(|| format!("stock_pool_frozen_position_target_weight_missing:{symbol}"))?;
|
||||
frozen.insert(symbol.clone(), weight);
|
||||
}
|
||||
let frozen_total = frozen.values().copied().sum::<i32>();
|
||||
if frozen_total > 10_000 {
|
||||
return Err("stock_pool_frozen_position_weights_exceed_budget".into());
|
||||
}
|
||||
let mut free = initial
|
||||
.into_iter()
|
||||
.filter(|(symbol, _)| !frozen.contains_key(symbol))
|
||||
.map(|(symbol, weight)| (symbol, weight as u32))
|
||||
.collect::<Vec<_>>();
|
||||
let total = free.iter().map(|(_, weight)| *weight).sum::<u32>();
|
||||
let available = (10_000 - frozen_total) as u32;
|
||||
// A paused holding removed from today's candidates still owns its prior
|
||||
// budget. Scale only the new tradable allocation, never the frozen leg.
|
||||
if total > available {
|
||||
let mut remainder = available;
|
||||
for (_, weight) in &mut free {
|
||||
*weight = (u64::from(*weight) * u64::from(available) / u64::from(total)) as u32;
|
||||
remainder -= *weight;
|
||||
}
|
||||
for (_, weight) in free.iter_mut().take(remainder as usize) {
|
||||
*weight += 1;
|
||||
}
|
||||
}
|
||||
let excluded = free
|
||||
.iter()
|
||||
.filter(|(symbol, _)| !active.contains(symbol))
|
||||
.map(|(symbol, _)| symbol.clone())
|
||||
.collect();
|
||||
let candidates = active
|
||||
.iter()
|
||||
.filter(|symbol| !frozen.contains_key(*symbol))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let allocated = crate::platform_expr_strategy::replenish_target_weight_bps(
|
||||
&free,
|
||||
&candidates,
|
||||
&excluded,
|
||||
target_count.saturating_sub(
|
||||
frozen
|
||||
.keys()
|
||||
.filter(|symbol| original.contains(symbol))
|
||||
.count(),
|
||||
),
|
||||
);
|
||||
frozen.extend(
|
||||
allocated
|
||||
.into_iter()
|
||||
.map(|(symbol, weight)| (symbol, weight as i32)),
|
||||
);
|
||||
Ok(frozen)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Index exposure is independent of relative-weight maintenance. Reduce the
|
||||
//! existing proportions only when the portfolio exceeds its index budget.
|
||||
use super::*;
|
||||
|
||||
pub(super) struct IndexCapTarget {
|
||||
pub quantity: Decimal,
|
||||
pub blocked_by_t1: bool,
|
||||
}
|
||||
|
||||
pub(super) fn remaining_index_targets(
|
||||
current: &BTreeMap<String, (Decimal, Decimal, Decimal)>,
|
||||
members: &HashMap<String, &StockPoolMemberSpec>,
|
||||
automatic: &BTreeMap<String, crate::holding_policy::AutomaticTradePermission>,
|
||||
already_planned: &[StockPoolPlanRow],
|
||||
quotes: &HashMap<String, &MarketSnapshot>,
|
||||
frozen: &BTreeMap<String, FrozenStockPoolPosition>,
|
||||
budget: Decimal,
|
||||
) -> Result<BTreeMap<String, IndexCapTarget>, String> {
|
||||
struct Entry {
|
||||
symbol: String,
|
||||
quantity: Decimal,
|
||||
minimum: Decimal,
|
||||
price: Decimal,
|
||||
}
|
||||
let other = already_planned
|
||||
.iter()
|
||||
.map(|row| (&row.symbol, row))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut fixed = Decimal::ZERO;
|
||||
let mut entries = Vec::new();
|
||||
for (symbol, (quantity, closable, _)) in current.iter().filter(|(_, row)| row.0 > Decimal::ZERO)
|
||||
{
|
||||
let price = super::frozen::valuation(symbol, quotes, frozen)?;
|
||||
let minimum = (*quantity - *closable).max(Decimal::ZERO);
|
||||
let remaining = other
|
||||
.get(symbol)
|
||||
.map(|row| row.target_quantity.min(*quantity))
|
||||
.unwrap_or(*quantity)
|
||||
.max(minimum);
|
||||
if frozen.contains_key(symbol)
|
||||
|| automatic
|
||||
.get(symbol)
|
||||
.is_some_and(|permission| permission.sell_denial.is_some())
|
||||
{
|
||||
fixed += *quantity * price;
|
||||
} else if members.contains_key(symbol) && remaining > Decimal::ZERO {
|
||||
entries.push(Entry {
|
||||
symbol: symbol.clone(),
|
||||
quantity: remaining,
|
||||
minimum,
|
||||
price,
|
||||
});
|
||||
} else {
|
||||
fixed += remaining * price;
|
||||
}
|
||||
}
|
||||
let mut remaining = entries
|
||||
.iter()
|
||||
.map(|row| row.quantity * row.price)
|
||||
.sum::<Decimal>();
|
||||
let mut available = (budget - fixed).max(Decimal::ZERO);
|
||||
if remaining <= available {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
// The highest non-sellable proportions are fixed first; the remainder
|
||||
// keeps its existing relative weights. No planned sale funds a new buy.
|
||||
entries.sort_by(|left, right| {
|
||||
(right.minimum / right.quantity)
|
||||
.cmp(&(left.minimum / left.quantity))
|
||||
.then_with(|| left.symbol.cmp(&right.symbol))
|
||||
});
|
||||
let mut result = BTreeMap::new();
|
||||
for row in entries {
|
||||
let scale = if remaining > Decimal::ZERO {
|
||||
(available / remaining).min(Decimal::ONE)
|
||||
} else {
|
||||
Decimal::ZERO
|
||||
};
|
||||
let desired = (row.quantity * scale).floor();
|
||||
let blocked = desired < row.minimum;
|
||||
let target = desired.max(row.minimum).min(row.quantity);
|
||||
remaining -= row.quantity * row.price;
|
||||
available = (available - target * row.price).max(Decimal::ZERO);
|
||||
result.insert(
|
||||
row.symbol,
|
||||
IndexCapTarget {
|
||||
quantity: target,
|
||||
blocked_by_t1: blocked,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Explicit index timing, shared by historical and online stock-pool planners.
|
||||
//! Inputs are completed official sessions, not a shortened available-row window.
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct MarketTimingPolicy {
|
||||
pub enabled: bool,
|
||||
pub index_code: Option<String>,
|
||||
pub fast_window: Option<usize>,
|
||||
pub slow_window: Option<usize>,
|
||||
pub volatility_window: Option<usize>,
|
||||
pub drawdown_window: Option<usize>,
|
||||
pub bull_exposure: Option<f64>,
|
||||
pub bear_exposure: Option<f64>,
|
||||
pub volatility_threshold: Option<f64>,
|
||||
pub volatility_cap: Option<f64>,
|
||||
pub drawdown_threshold: Option<f64>,
|
||||
pub drawdown_cap: Option<f64>,
|
||||
}
|
||||
|
||||
impl MarketTimingPolicy {
|
||||
pub fn from_allocation(value: &serde_json::Value) -> Result<Self, String> {
|
||||
let raw = value.get("market_timing").filter(|value| !value.is_null());
|
||||
let policy = raw
|
||||
.map(|raw| serde_json::from_value::<Self>(raw.clone()))
|
||||
.transpose()
|
||||
.map_err(|error| format!("market_timing_invalid:{error}"))?
|
||||
.unwrap_or_default();
|
||||
policy.validate()?;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if let Some(index) = &self.index_code {
|
||||
let valid = index.split_once('.').is_some_and(|(code, exchange)| {
|
||||
(6..=12).contains(&code.len())
|
||||
&& code
|
||||
.bytes()
|
||||
.all(|v| v.is_ascii_uppercase() || v.is_ascii_digit())
|
||||
&& matches!(exchange, "SH" | "SZ" | "CSI" | "CNI")
|
||||
});
|
||||
if !valid {
|
||||
return Err("market_timing_index_code_invalid".into());
|
||||
}
|
||||
} else if self.enabled {
|
||||
return Err("market_timing_index_code_required".into());
|
||||
}
|
||||
for (key, value) in [
|
||||
("fast_window", self.fast_window),
|
||||
("slow_window", self.slow_window),
|
||||
("volatility_window", self.volatility_window),
|
||||
("drawdown_window", self.drawdown_window),
|
||||
] {
|
||||
match value {
|
||||
Some(value) if !(2..=250).contains(&value) => {
|
||||
return Err(format!("market_timing_{key}_must_be_2_to_250"));
|
||||
}
|
||||
None if self.enabled => return Err(format!("market_timing_{key}_required")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let (Some(fast), Some(slow)) = (self.fast_window, self.slow_window) {
|
||||
if fast >= slow {
|
||||
return Err("market_timing_fast_window_must_be_less_than_slow_window".into());
|
||||
}
|
||||
}
|
||||
for (key, value) in [
|
||||
("bull_exposure", self.bull_exposure),
|
||||
("bear_exposure", self.bear_exposure),
|
||||
("volatility_threshold", self.volatility_threshold),
|
||||
("volatility_cap", self.volatility_cap),
|
||||
("drawdown_threshold", self.drawdown_threshold),
|
||||
("drawdown_cap", self.drawdown_cap),
|
||||
] {
|
||||
match value {
|
||||
Some(value) if !value.is_finite() || !(0.0..=1.0).contains(&value) => {
|
||||
return Err(format!("market_timing_{key}_must_be_in_0_to_1"));
|
||||
}
|
||||
None if self.enabled => return Err(format!("market_timing_{key}_required")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn required_history(&self) -> Result<usize, String> {
|
||||
self.validate()?;
|
||||
if !self.enabled {
|
||||
return Ok(0);
|
||||
}
|
||||
Ok(self
|
||||
.slow_window
|
||||
.unwrap()
|
||||
.max(self.volatility_window.unwrap() + 1)
|
||||
.max(self.drawdown_window.unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IndexClose {
|
||||
pub date: NaiveDate,
|
||||
pub close: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MarketTimingInput {
|
||||
pub index_code: String,
|
||||
pub as_of_date: NaiveDate,
|
||||
pub official_dates: Vec<NaiveDate>,
|
||||
pub closes: Vec<IndexClose>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MarketTimingEvaluation {
|
||||
pub index_code: String,
|
||||
pub as_of_date: NaiveDate,
|
||||
pub window_start: NaiveDate,
|
||||
pub session_count: usize,
|
||||
pub fast_ma: f64,
|
||||
pub slow_ma: f64,
|
||||
pub volatility: f64,
|
||||
pub drawdown: f64,
|
||||
pub exposure: f64,
|
||||
pub reason_codes: Vec<String>,
|
||||
pub policy_sha256: String,
|
||||
pub input_sha256: String,
|
||||
pub kernel_sha256: String,
|
||||
}
|
||||
|
||||
pub fn implementation_sha256() -> String {
|
||||
format!(
|
||||
"{:x}",
|
||||
Sha256::digest(include_bytes!("stock_pool_index_policy.rs"))
|
||||
)
|
||||
}
|
||||
|
||||
pub fn evaluate(
|
||||
policy: &MarketTimingPolicy,
|
||||
input: &MarketTimingInput,
|
||||
decision_date: NaiveDate,
|
||||
) -> Result<MarketTimingEvaluation, String> {
|
||||
let needed = policy.required_history()?;
|
||||
if needed == 0 {
|
||||
return Err("market_timing_disabled_has_no_evaluation".into());
|
||||
}
|
||||
if policy.index_code.as_deref() != Some(input.index_code.as_str()) {
|
||||
return Err("market_timing_input_index_mismatch".into());
|
||||
}
|
||||
if input.as_of_date > decision_date {
|
||||
return Err("market_timing_future_input".into());
|
||||
}
|
||||
if input.official_dates.len() != needed
|
||||
|| input.closes.len() != needed
|
||||
|| input.official_dates.last() != Some(&input.as_of_date)
|
||||
|| input
|
||||
.official_dates
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] >= pair[1])
|
||||
{
|
||||
return Err("market_timing_official_calendar_incomplete".into());
|
||||
}
|
||||
if input
|
||||
.closes
|
||||
.iter()
|
||||
.zip(&input.official_dates)
|
||||
.any(|(row, date)| row.date != *date || !row.close.is_finite() || row.close <= 0.)
|
||||
{
|
||||
return Err("market_timing_completed_index_rows_incomplete".into());
|
||||
}
|
||||
let closes = input.closes.iter().map(|row| row.close).collect::<Vec<_>>();
|
||||
let mean = |window: usize| closes[needed - window..].iter().sum::<f64>() / window as f64;
|
||||
let fast_ma = mean(policy.fast_window.unwrap());
|
||||
let slow_ma = mean(policy.slow_window.unwrap());
|
||||
let returns = closes[needed - policy.volatility_window.unwrap() - 1..]
|
||||
.windows(2)
|
||||
.map(|pair| pair[1] / pair[0] - 1.)
|
||||
.collect::<Vec<_>>();
|
||||
let average = returns.iter().sum::<f64>() / returns.len() as f64;
|
||||
let volatility = (returns
|
||||
.iter()
|
||||
.map(|value| (value - average).powi(2))
|
||||
.sum::<f64>()
|
||||
/ (returns.len() - 1) as f64)
|
||||
.sqrt();
|
||||
let peak = closes[needed - policy.drawdown_window.unwrap()..]
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let drawdown = 1. - closes[needed - 1] / peak;
|
||||
let bullish = fast_ma > slow_ma;
|
||||
let mut exposure = if bullish {
|
||||
policy.bull_exposure.unwrap()
|
||||
} else {
|
||||
policy.bear_exposure.unwrap()
|
||||
};
|
||||
let mut reason_codes = vec![
|
||||
if bullish {
|
||||
"fast_above_slow"
|
||||
} else {
|
||||
"fast_not_above_slow"
|
||||
}
|
||||
.into(),
|
||||
];
|
||||
if volatility >= policy.volatility_threshold.unwrap() {
|
||||
exposure = exposure.min(policy.volatility_cap.unwrap());
|
||||
reason_codes.push("volatility_cap".into());
|
||||
}
|
||||
if drawdown >= policy.drawdown_threshold.unwrap() {
|
||||
exposure = exposure.min(policy.drawdown_cap.unwrap());
|
||||
reason_codes.push("drawdown_cap".into());
|
||||
}
|
||||
if [fast_ma, slow_ma, volatility, drawdown, exposure]
|
||||
.iter()
|
||||
.any(|value| !value.is_finite())
|
||||
{
|
||||
return Err("market_timing_nonfinite_result".into());
|
||||
}
|
||||
let hash = |value: &serde_json::Value| -> Result<String, String> {
|
||||
Ok(format!(
|
||||
"{:x}",
|
||||
Sha256::digest(serde_json::to_vec(value).map_err(|error| error.to_string())?)
|
||||
))
|
||||
};
|
||||
Ok(MarketTimingEvaluation {
|
||||
index_code: input.index_code.clone(),
|
||||
as_of_date: input.as_of_date,
|
||||
window_start: input.official_dates[0],
|
||||
session_count: needed,
|
||||
fast_ma,
|
||||
slow_ma,
|
||||
volatility,
|
||||
drawdown,
|
||||
exposure,
|
||||
reason_codes,
|
||||
policy_sha256: hash(&serde_json::to_value(policy).map_err(|error| error.to_string())?)?,
|
||||
input_sha256: hash(&serde_json::to_value(input).map_err(|error| error.to_string())?)?,
|
||||
kernel_sha256: implementation_sha256(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn policy() -> MarketTimingPolicy {
|
||||
serde_json::from_value(serde_json::json!({"enabled":true,"index_code":"000300.SH","fast_window":10,"slow_window":30,
|
||||
"volatility_window":20,"drawdown_window":60,"bull_exposure":1.0,"bear_exposure":0.3,"volatility_threshold":0.025,
|
||||
"volatility_cap":0.3,"drawdown_threshold":0.08,"drawdown_cap":0.2})).unwrap()
|
||||
}
|
||||
fn input(values: Vec<f64>) -> MarketTimingInput {
|
||||
let dates = (0..values.len())
|
||||
.map(|i| {
|
||||
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Duration::days(i as i64)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
MarketTimingInput {
|
||||
index_code: "000300.SH".into(),
|
||||
as_of_date: *dates.last().unwrap(),
|
||||
closes: dates
|
||||
.iter()
|
||||
.zip(values)
|
||||
.map(|(date, close)| IndexClose { date: *date, close })
|
||||
.collect(),
|
||||
official_dates: dates,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn trend_and_caps_match_the_declared_math_and_keep_zero_meaningful() {
|
||||
for (prices, expected) in [
|
||||
((0..60).map(|i| 100. + i as f64).collect(), 1.),
|
||||
((0..60).map(|i| 200. - i as f64).collect(), 0.2),
|
||||
(
|
||||
(0..59)
|
||||
.map(|i| 100. + i as f64 * 0.1)
|
||||
.chain([150.])
|
||||
.collect(),
|
||||
0.3,
|
||||
),
|
||||
] {
|
||||
let input = input(prices);
|
||||
let result = evaluate(&policy(), &input, input.as_of_date).unwrap();
|
||||
assert_eq!(result.exposure, expected);
|
||||
assert_eq!(result.session_count, 60);
|
||||
assert_eq!(result.input_sha256.len(), 64);
|
||||
}
|
||||
let input = input(vec![100.; 60]);
|
||||
let mut zero = policy();
|
||||
zero.bear_exposure = Some(0.);
|
||||
assert_eq!(
|
||||
evaluate(&zero, &input, input.as_of_date).unwrap().exposure,
|
||||
0.
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn missing_or_duplicate_or_future_rows_never_shorten_the_window() {
|
||||
let base = input(vec![100.; 60]);
|
||||
let mut invalid = base.clone();
|
||||
invalid.closes.remove(5);
|
||||
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
|
||||
let mut invalid = base.clone();
|
||||
invalid.closes[5].date = invalid.closes[4].date;
|
||||
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
|
||||
let mut invalid = base.clone();
|
||||
invalid.closes[5].close = f64::NAN;
|
||||
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
|
||||
assert!(
|
||||
evaluate(
|
||||
&policy(),
|
||||
&base,
|
||||
base.as_of_date - chrono::Duration::days(1)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut invalid = base.clone();
|
||||
invalid.index_code = "000852.SH".into();
|
||||
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn disabled_is_optional_but_enabled_parameters_are_not_invented() {
|
||||
assert_eq!(
|
||||
MarketTimingPolicy::from_allocation(&serde_json::Value::Null)
|
||||
.unwrap()
|
||||
.required_history()
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
MarketTimingPolicy::from_allocation(
|
||||
&serde_json::json!({"market_timing":{"enabled":true}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut invalid = policy();
|
||||
invalid.fast_window = invalid.slow_window;
|
||||
assert!(invalid.validate().is_err());
|
||||
let mut invalid = policy();
|
||||
invalid.volatility_window = Some(250);
|
||||
assert_eq!(invalid.required_history().unwrap(), 251);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Shared causal indicator calculation for stock-pool screening.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::factor_events::{self, Expr, Frame};
|
||||
|
||||
pub fn implementation_sha256() -> String {
|
||||
use sha2::{Digest,Sha256};
|
||||
let mut identity=Sha256::new();
|
||||
identity.update(include_bytes!("stock_pool_indicators.rs"));
|
||||
identity.update(factor_events::catalog()["expression_kernel_sha256"].as_str().expect("native kernel identity"));
|
||||
format!("{:x}",identity.finalize())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IndicatorSpec {
|
||||
pub indicator: String,
|
||||
pub field: String,
|
||||
pub window: usize,
|
||||
#[serde(default)]
|
||||
pub output: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct InputSeries {
|
||||
pub frame: Frame,
|
||||
/// Source-admitted historical suspension/lifecycle gaps, not guessed from
|
||||
/// missing prices. The pure endpoint never certifies those source facts.
|
||||
#[serde(default)]
|
||||
pub admitted_missing_dates: BTreeSet<NaiveDate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Request {
|
||||
pub specs: BTreeMap<String, IndicatorSpec>,
|
||||
pub series: Vec<InputSeries>,
|
||||
pub output_start_date: NaiveDate,
|
||||
pub output_end_date: NaiveDate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OutputSeries {
|
||||
pub symbol: String,
|
||||
pub indices: Vec<usize>,
|
||||
pub values: BTreeMap<String, Vec<Option<f64>>>,
|
||||
}
|
||||
|
||||
pub fn evaluate(request: Request) -> Result<Vec<OutputSeries>, String> {
|
||||
let row_count: usize = request.series.iter().map(|series| series.frame.timestamps.len()).sum();
|
||||
if request.specs.is_empty() || request.specs.len() > 64 || row_count > 60_000
|
||||
|| row_count.saturating_mul(request.specs.len()) > 1_000_000
|
||||
|| request.output_start_date > request.output_end_date {
|
||||
return Err("stock_pool_indicator_request_budget_or_range_invalid".into());
|
||||
}
|
||||
for spec in request.specs.values() {
|
||||
if !matches!(spec.indicator.as_str(), "ma" | "ema" | "kdj") || !(2..=10_000).contains(&spec.window)
|
||||
|| !matches!(spec.field.as_str(), "close" | "volume" | "high" | "low")
|
||||
|| (spec.indicator == "kdj" && !matches!(spec.output.as_deref(), Some("k" | "d" | "j"))) {
|
||||
return Err("stock_pool_indicator_spec_invalid".into());
|
||||
}
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut result = Vec::new();
|
||||
for series in request.series {
|
||||
let frame = series.frame;
|
||||
frame.validate()?;
|
||||
if frame.frequency != "1d" { return Err("stock_pool_indicators_require_daily_source".into()); }
|
||||
if series.admitted_missing_dates.iter().any(|day| !frame.timestamps.iter().any(|stamp| stamp.date_naive() == *day)) {
|
||||
return Err("admitted_missing_date_is_outside_the_input_frame".into());
|
||||
}
|
||||
if !seen.insert(frame.symbol.clone()) { return Err("stock_pool_indicator_duplicate_symbol".into()); }
|
||||
let indices = frame.timestamps.iter().enumerate().filter(|(_, stamp)| {
|
||||
let day = stamp.date_naive(); request.output_start_date <= day && day <= request.output_end_date
|
||||
}).map(|(index, _)| index).collect::<Vec<_>>();
|
||||
let mut values = BTreeMap::new();
|
||||
for (key, spec) in &request.specs {
|
||||
let fields: Vec<&str> = if spec.indicator == "kdj" { vec!["high", "low", "close"] } else { vec![&spec.field] };
|
||||
for field in &fields {
|
||||
let raw = frame.fields.get(*field).ok_or_else(|| format!("indicator_field_missing:{field}"))?;
|
||||
if raw.len() != frame.timestamps.len() { return Err("indicator_field_length_mismatch".into()); }
|
||||
}
|
||||
for index in 0..frame.timestamps.len() {
|
||||
let valid = fields.iter().all(|field| frame.fields[*field][index].is_some_and(f64::is_finite));
|
||||
if valid {
|
||||
if fields.iter().any(|field| frame.fields[*field][index].is_some_and(|value| if *field == "volume" { value < 0.0 } else { value <= 0.0 })) {
|
||||
return Err(format!("indicator_input_value_invalid:{}:{}", frame.symbol, frame.timestamps[index]));
|
||||
}
|
||||
if spec.indicator == "kdj" {
|
||||
let (hi, lo, close) = (frame.fields["high"][index].unwrap(), frame.fields["low"][index].unwrap(), frame.fields["close"][index].unwrap());
|
||||
if hi < lo || close < lo || close > hi { return Err(format!("indicator_ohlc_invalid:{}:{}", frame.symbol, frame.timestamps[index])); }
|
||||
}
|
||||
}
|
||||
else if !series.admitted_missing_dates.contains(&frame.timestamps[index].date_naive()) {
|
||||
return Err(format!("unclassified_indicator_input_gap:{}:{}", frame.symbol, frame.timestamps[index]));
|
||||
}
|
||||
}
|
||||
let native_values = {
|
||||
// The native KDJ has its own initialisation and lookback. Do
|
||||
// not recreate it as STOCH or an independently seeded loop.
|
||||
// Preserve admitted NULL rows too: the shared kernel defines
|
||||
// gap/warmup semantics; compressing the calendar changes them.
|
||||
let expression: Expr = serde_json::from_value(if spec.indicator == "kdj" {
|
||||
json!({"kind":"indicator","name":"KDJ","inputs":[],
|
||||
"parameters":{"optInFastK_Period":spec.window,"optInSlowK_Period":3,
|
||||
"optInSlowK_MAType":13,"optInSlowD_Period":3,"optInSlowD_MAType":13},
|
||||
"output":match spec.output.as_deref() { Some("k") => 0, Some("d") => 1, _ => 2 }})
|
||||
} else {
|
||||
json!({"kind":"indicator","name":if spec.indicator == "ma" {"SMA"} else {"EMA"},
|
||||
"inputs":[{"kind":"field","name":spec.field}],"parameters":{"optInTimePeriod":spec.window}})
|
||||
}).map_err(|error| error.to_string())?;
|
||||
factor_events::evaluate(&expression, &frame)?.values
|
||||
};
|
||||
values.insert(key.clone(), indices.iter().map(|index| native_values[*index]).collect());
|
||||
}
|
||||
result.push(OutputSeries { symbol: frame.symbol, indices, values });
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn request() -> Request {
|
||||
serde_json::from_value(json!({"specs":{"ma":{"indicator":"ma","field":"close","window":3},"ema":{"indicator":"ema","field":"close","window":3},"j":{"indicator":"kdj","field":"close","window":3,"output":"j"}},
|
||||
"series":[{"frame":{"symbol":"000001.SZ","frequency":"1d","decision_at":"2026-09-10T17:00:00+08:00",
|
||||
"timestamps":["2026-09-07T15:00:00+08:00","2026-09-08T15:00:00+08:00","2026-09-09T15:00:00+08:00","2026-09-10T15:00:00+08:00"],
|
||||
"available_at":["2026-09-07T16:00:00+08:00","2026-09-08T16:00:00+08:00","2026-09-09T16:00:00+08:00","2026-09-10T16:00:00+08:00"],
|
||||
"fields":{"close":[1.,2.,3.,4.],"high":[2.,3.,4.,5.],"low":[0.5,1.,2.,3.]}}}],
|
||||
"output_start_date":"2026-09-09","output_end_date":"2026-09-10"})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn uses_shared_ma_ema_and_preserves_recursive_prefix_before_output_window() {
|
||||
let rows = evaluate(request()).unwrap();
|
||||
assert_eq!(rows[0].indices,vec![2,3]);
|
||||
assert_eq!(rows[0].values["ma"],vec![Some(2.),Some(3.)]);
|
||||
assert_eq!(rows[0].values["ema"],vec![Some(2.),Some(3.)]);
|
||||
assert_eq!(rows[0].values["j"], vec![None, None]); // KDJ has a longer native lookback.
|
||||
let mut later = request(); later.output_start_date = NaiveDate::from_ymd_opt(2026,9,10).unwrap();
|
||||
assert_eq!(evaluate(later).unwrap()[0].values["ema"],vec![rows[0].values["ema"][1]]);
|
||||
}
|
||||
#[test]
|
||||
fn kdj_uses_the_same_frozen_native_kernel_as_event_conditions() {
|
||||
let mut data = request();
|
||||
let frame = &mut data.series[0].frame;
|
||||
for i in 4..16 {
|
||||
let day = NaiveDate::from_ymd_opt(2026,9,7).unwrap() + chrono::Duration::days(i);
|
||||
frame.timestamps.push(chrono::DateTime::parse_from_rfc3339(&format!("{day}T15:00:00+08:00")).unwrap());
|
||||
frame.available_at.push(chrono::DateTime::parse_from_rfc3339(&format!("{day}T16:00:00+08:00")).unwrap());
|
||||
frame.fields.get_mut("close").unwrap().push(Some(i as f64 + 1.));
|
||||
frame.fields.get_mut("high").unwrap().push(Some(i as f64 + 2.));
|
||||
frame.fields.get_mut("low").unwrap().push(Some(i as f64));
|
||||
}
|
||||
frame.decision_at = chrono::DateTime::parse_from_rfc3339("2026-09-23T17:00:00+08:00").unwrap();
|
||||
data.output_start_date=NaiveDate::from_ymd_opt(2026,9,7).unwrap();
|
||||
data.output_end_date=NaiveDate::from_ymd_opt(2026,9,22).unwrap();
|
||||
let native:Expr=serde_json::from_value(json!({"kind":"indicator","name":"KDJ","inputs":[],"parameters":{"optInFastK_Period":3},"output":2})).unwrap();
|
||||
let expected=factor_events::evaluate(&native,frame).unwrap().values;
|
||||
assert!(expected.iter().any(Option::is_some));
|
||||
assert_eq!(evaluate(data).unwrap()[0].values["j"],expected);
|
||||
}
|
||||
#[test]
|
||||
fn missing_inputs_require_source_admission_and_future_inputs_fail() {
|
||||
let mut data = request(); data.series[0].frame.fields.get_mut("close").unwrap()[1] = None;
|
||||
assert!(evaluate(data).unwrap_err().contains("unclassified_indicator_input_gap"));
|
||||
let mut data = request(); data.series[0].frame.available_at[3] = chrono::DateTime::parse_from_rfc3339("2026-09-11T16:00:00+08:00").unwrap();
|
||||
assert!(evaluate(data).is_err());
|
||||
let mut data=request();data.series[0].frame.fields.get_mut("close").unwrap()[1]=None;
|
||||
data.series[0].admitted_missing_dates.insert(NaiveDate::from_ymd_opt(2026,9,8).unwrap());
|
||||
let native:Expr=serde_json::from_value(json!({"kind":"indicator","name":"EMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}})).unwrap();
|
||||
let expected=factor_events::evaluate(&native,&data.series[0].frame).unwrap().values;
|
||||
assert_eq!(expected[2..], [None,None]);
|
||||
assert_eq!(evaluate(data).unwrap()[0].values["ema"],expected[2..]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Configurable index-to-market-cap band. Values are CNY, not implicit yi.
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IndexMarketCapPolicy {
|
||||
pub schema_version: u32,
|
||||
pub index_code: String,
|
||||
pub field: String,
|
||||
pub value_unit: String,
|
||||
pub index_low: f64,
|
||||
pub index_high: f64,
|
||||
pub lower_at_low: f64,
|
||||
pub lower_at_high: f64,
|
||||
pub upper_at_low: f64,
|
||||
pub upper_at_high: f64,
|
||||
}
|
||||
|
||||
impl IndexMarketCapPolicy {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.schema_version != 1 || self.value_unit != "CNY"
|
||||
|| !matches!(self.field.as_str(), "market_cap" | "float_market_cap")
|
||||
{ return Err("index_market_cap_contract_invalid".into()); }
|
||||
let index = self.index_code.split_once('.').is_some_and(|(code, exchange)| {
|
||||
(6..=12).contains(&code.len())
|
||||
&& code.bytes().all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
|
||||
&& matches!(exchange, "SH" | "SZ" | "CSI" | "CNI")
|
||||
});
|
||||
if !index { return Err("index_market_cap_index_invalid".into()); }
|
||||
if [self.index_low,self.index_high,self.lower_at_low,self.lower_at_high,self.upper_at_low,self.upper_at_high]
|
||||
.iter().any(|value| !value.is_finite() || *value <= 0.)
|
||||
|| self.index_low >= self.index_high || self.lower_at_low > self.upper_at_low
|
||||
|| self.lower_at_high > self.upper_at_high
|
||||
{ return Err("index_market_cap_bounds_invalid".into()); }
|
||||
Ok(())
|
||||
}
|
||||
pub fn band(&self, close: f64) -> Result<(f64, f64), String> {
|
||||
self.validate()?;
|
||||
if !close.is_finite() || close <= 0. { return Err("index_market_cap_close_invalid".into()); }
|
||||
let t = (close.clamp(self.index_low,self.index_high) - self.index_low) / (self.index_high-self.index_low);
|
||||
Ok((self.lower_at_low + t*(self.lower_at_high-self.lower_at_low),
|
||||
self.upper_at_low + t*(self.upper_at_high-self.upper_at_low)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IndexMarketCapRow { pub date: NaiveDate, pub close: f64 }
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Request {
|
||||
pub policy: IndexMarketCapPolicy,
|
||||
pub official_dates: Vec<NaiveDate>,
|
||||
pub index_code: String,
|
||||
pub closes: Vec<IndexMarketCapRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct Band { pub date: NaiveDate, pub index_close: f64, pub lower: f64, pub upper: f64 }
|
||||
|
||||
pub fn implementation_sha256() -> String { format!("{:x}", Sha256::digest(include_bytes!("stock_pool_market_cap.rs"))) }
|
||||
|
||||
pub fn evaluate(input: &Request) -> Result<Vec<Band>, String> {
|
||||
input.policy.validate()?;
|
||||
if input.index_code != input.policy.index_code || input.official_dates.is_empty()
|
||||
|| input.official_dates.len() > 4000 || input.official_dates.len() != input.closes.len()
|
||||
|| input.official_dates.windows(2).any(|pair| pair[0]>=pair[1])
|
||||
|| input.closes.iter().zip(&input.official_dates).any(|(row, day)| row.date != *day)
|
||||
{ return Err("index_market_cap_calendar_or_identity_mismatch".into()); }
|
||||
input.closes.iter().map(|row| {
|
||||
let (lower,upper)=input.policy.band(row.close)?;
|
||||
Ok(Band{date:row.date,index_close:row.close,lower,upper})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn policy()->IndexMarketCapPolicy {
|
||||
serde_json::from_value(serde_json::json!({"schema_version":1,"index_code":"000300.SH","field":"market_cap","value_unit":"CNY",
|
||||
"index_low":4000,"index_high":6000,"lower_at_low":2000000000_f64,"lower_at_high":3000000000_f64,
|
||||
"upper_at_low":5000000000_f64,"upper_at_high":8000000000_f64})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn interpolates_declared_endpoints_and_clamps_without_business_defaults(){
|
||||
assert_eq!(policy().band(3000.).unwrap(),(2e9,5e9));
|
||||
assert_eq!(policy().band(5000.).unwrap(),(2.5e9,6.5e9));
|
||||
assert_eq!(policy().band(7000.).unwrap(),(3e9,8e9));
|
||||
let mut decreasing=policy();decreasing.lower_at_low=3e9;decreasing.lower_at_high=2e9;
|
||||
assert_eq!(decreasing.band(5000.).unwrap(),(2.5e9,6.5e9));
|
||||
assert!(policy().band(f64::NAN).is_err());
|
||||
let mut invalid=policy();invalid.value_unit="亿元".into();assert!(invalid.validate().is_err());
|
||||
invalid=policy();invalid.index_high=4000.;assert!(invalid.validate().is_err());
|
||||
invalid=policy();invalid.lower_at_low=9e9;assert!(invalid.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn missing_duplicate_or_mismatched_index_inputs_do_not_shrink_the_calendar(){
|
||||
let day=NaiveDate::from_ymd_opt(2026,9,11).unwrap();
|
||||
let mut input=Request{policy:policy(),official_dates:vec![day],index_code:"000300.SH".into(),closes:vec![IndexMarketCapRow{date:day,close:5000.}]};
|
||||
assert_eq!(evaluate(&input).unwrap()[0].lower,2.5e9);
|
||||
input.official_dates.push(day);assert!(evaluate(&input).is_err());input.official_dates.pop();
|
||||
input.index_code="932000.CSI".into();assert!(evaluate(&input).is_err());
|
||||
input.index_code="000300.SH".into();input.closes.clear();assert!(evaluate(&input).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Durable intent progress, deliberately separate from actual-fill holding
|
||||
//! protection. A published target starts no holding/protection timer.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::stock_pool_execution::{
|
||||
Position, StockPoolMemberSpec, StockPoolPlan, normalize_stock_symbol,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StockPoolEntryProgress {
|
||||
pub pending: bool,
|
||||
pub observed_holding: bool,
|
||||
pub first_decision_date: NaiveDate,
|
||||
pub latest_generation: String,
|
||||
pub latest_target_value: Decimal,
|
||||
/// Fully funded entry goal, fixed at the last plan. Reconcile against
|
||||
/// actual holdings before repricing, never against today's market value.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub completion_quantity: Option<Decimal>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StockPoolPositionActionBasis {
|
||||
pub generation: String,
|
||||
pub first_execution_date: NaiveDate,
|
||||
pub quantity: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StockPoolExecutionState {
|
||||
pub schema_version: u32,
|
||||
pub last_execution_date: Option<NaiveDate>,
|
||||
pub entries: BTreeMap<String, StockPoolEntryProgress>,
|
||||
#[serde(default)]
|
||||
pub last_target_weights: BTreeMap<String, i32>,
|
||||
/// First signal excluding an actually held member; not an acquisition date.
|
||||
pub removed_since: BTreeMap<String, NaiveDate>,
|
||||
/// Signal progress, not a fill or holding-period fact. Kept across retries
|
||||
/// and later execution sessions until a new generation supersedes it.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub position_action_bases: BTreeMap<String, StockPoolPositionActionBasis>,
|
||||
}
|
||||
|
||||
pub struct StockPoolGoalObservation<'a> {
|
||||
pub symbol: &'a str,
|
||||
pub target_weight_bps: i32,
|
||||
pub target_value: Decimal,
|
||||
pub current_quantity: Decimal,
|
||||
pub target_quantity: Decimal,
|
||||
pub status: &'a str,
|
||||
}
|
||||
|
||||
impl Default for StockPoolExecutionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema_version: 1,
|
||||
last_execution_date: None,
|
||||
entries: BTreeMap::new(),
|
||||
last_target_weights: BTreeMap::new(),
|
||||
removed_since: BTreeMap::new(),
|
||||
position_action_bases: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StockPoolExecutionState {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.schema_version != 1
|
||||
|| self.entries.len() > 10000
|
||||
|| self.removed_since.len() > 10000
|
||||
|| self.position_action_bases.len() > 10000
|
||||
{
|
||||
return Err("stock_pool_execution_state_invalid_schema_or_size".into());
|
||||
}
|
||||
for symbol in self
|
||||
.entries
|
||||
.keys()
|
||||
.chain(self.removed_since.keys())
|
||||
.chain(self.last_target_weights.keys())
|
||||
.chain(self.position_action_bases.keys())
|
||||
{
|
||||
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
|
||||
return Err("stock_pool_execution_state_invalid_symbol".into());
|
||||
}
|
||||
}
|
||||
if self.last_target_weights.len() > 10000
|
||||
|| self
|
||||
.last_target_weights
|
||||
.values()
|
||||
.any(|value| !(0..=10000).contains(value))
|
||||
{
|
||||
return Err("stock_pool_execution_state_invalid_weights".into());
|
||||
}
|
||||
if self.entries.values().any(|entry| {
|
||||
entry.latest_target_value < Decimal::ZERO
|
||||
|| entry.completion_quantity.is_some_and(|quantity| quantity <= Decimal::ZERO)
|
||||
|| entry.latest_generation.is_empty()
|
||||
|| self
|
||||
.last_execution_date
|
||||
.is_none_or(|last| entry.first_decision_date > last)
|
||||
}) || self
|
||||
.removed_since
|
||||
.values()
|
||||
.any(|day| self.last_execution_date.is_none_or(|last| *day > last))
|
||||
{
|
||||
return Err("stock_pool_execution_state_invalid_goal_or_clock".into());
|
||||
}
|
||||
if self.position_action_bases.values().any(|basis| {
|
||||
basis.generation.trim().is_empty() || basis.quantity <= Decimal::ZERO
|
||||
|| self.last_execution_date.is_none_or(|date| basis.first_execution_date > date)
|
||||
}) {
|
||||
return Err("stock_pool_execution_state_invalid_action_basis".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn observe(
|
||||
&self,
|
||||
decision_date: NaiveDate,
|
||||
execution_date: NaiveDate,
|
||||
official_dates: &[NaiveDate],
|
||||
members: &[StockPoolMemberSpec],
|
||||
positions: &[Position],
|
||||
) -> Result<Self, String> {
|
||||
self.validate()?;
|
||||
if decision_date > execution_date
|
||||
|| !official_dates.contains(&execution_date)
|
||||
|| !official_dates.contains(&decision_date)
|
||||
|| official_dates.windows(2).any(|pair| pair[0] >= pair[1])
|
||||
|| self
|
||||
.last_execution_date
|
||||
.is_some_and(|last| last > execution_date)
|
||||
{
|
||||
return Err("stock_pool_execution_state_requires_monotone_official_clock".into());
|
||||
}
|
||||
let mut next = self.clone();
|
||||
next.last_execution_date = Some(execution_date);
|
||||
let members = members
|
||||
.iter()
|
||||
.map(|member| member.symbol.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let held = positions
|
||||
.iter()
|
||||
.filter(|position| position.quantity > Decimal::ZERO)
|
||||
.map(|position| position.symbol.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
next.entries.retain(|symbol, entry| {
|
||||
// Confirmed flat starts a new cycle. A still-unfilled fresh target
|
||||
// may remain pending while the latest pool still requests it.
|
||||
!(entry.observed_holding && !held.contains(symbol))
|
||||
&& (members.contains(symbol) || held.contains(symbol))
|
||||
});
|
||||
next.last_target_weights
|
||||
.retain(|symbol, _| members.contains(symbol) || held.contains(symbol));
|
||||
for (symbol, entry) in &mut next.entries {
|
||||
entry.observed_holding |= held.contains(symbol);
|
||||
if entry.pending
|
||||
&& entry.completion_quantity.is_some_and(|goal| {
|
||||
positions.iter().any(|position| {
|
||||
&position.symbol == symbol && position.quantity >= goal
|
||||
})
|
||||
})
|
||||
{
|
||||
entry.pending = false;
|
||||
}
|
||||
}
|
||||
next.removed_since
|
||||
.retain(|symbol, _| held.contains(symbol) && !members.contains(symbol));
|
||||
for symbol in held.difference(&members) {
|
||||
next.removed_since
|
||||
.entry(symbol.clone())
|
||||
.or_insert(decision_date);
|
||||
}
|
||||
next.validate()?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn pending_symbols(&self) -> BTreeSet<String> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.pending)
|
||||
.map(|(symbol, _)| symbol.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn next_day_exit_symbols(&self, execution_date: NaiveDate) -> BTreeSet<String> {
|
||||
self.removed_since
|
||||
.iter()
|
||||
.filter(|(_, removed)| **removed < execution_date)
|
||||
.map(|(symbol, _)| symbol.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn record_plan(
|
||||
&self,
|
||||
decision_date: NaiveDate,
|
||||
generation: &str,
|
||||
plan: &StockPoolPlan,
|
||||
) -> Result<Self, String> {
|
||||
self.record_targets(
|
||||
decision_date,
|
||||
generation,
|
||||
plan.rows.iter().filter(|row| !plan.position_action_bases.contains_key(&row.symbol)).map(|row| StockPoolGoalObservation {
|
||||
symbol: &row.symbol,
|
||||
target_weight_bps: row.target_weight_bps,
|
||||
target_value: row.target_value,
|
||||
current_quantity: row.current_quantity,
|
||||
target_quantity: row.target_quantity,
|
||||
status: &row.status,
|
||||
}),
|
||||
)?.record_position_action_bases(generation, &plan.position_action_bases)
|
||||
}
|
||||
|
||||
pub fn position_action_bases_for(&self, generation: &str) -> BTreeMap<String, Decimal> {
|
||||
self.position_action_bases.iter()
|
||||
.filter(|(_, basis)| basis.generation == generation)
|
||||
.map(|(symbol, basis)| (symbol.clone(), basis.quantity))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A verified split changes the share unit, not the intended reduction or
|
||||
/// entry completion. Never infer a split from a changed holding quantity.
|
||||
pub fn adjust_for_split(&self, symbol: &str, ratio: Decimal) -> Result<Self, String> {
|
||||
self.validate()?;
|
||||
if ratio <= Decimal::ZERO || normalize_stock_symbol(symbol).as_deref() != Some(symbol) {
|
||||
return Err("stock_pool_execution_state_split_invalid".into());
|
||||
}
|
||||
let scale = |quantity: Decimal| quantity.checked_mul(ratio)
|
||||
.map(|value| value.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero))
|
||||
.ok_or_else(|| "stock_pool_execution_state_split_overflow".to_string());
|
||||
let mut next = self.clone();
|
||||
if let Some(entry) = next.entries.get_mut(symbol) {
|
||||
if let Some(quantity) = entry.completion_quantity {
|
||||
let quantity = scale(quantity)?;
|
||||
entry.completion_quantity = (quantity > Decimal::ZERO).then_some(quantity);
|
||||
}
|
||||
}
|
||||
if let Some(basis) = next.position_action_bases.get_mut(symbol) {
|
||||
basis.quantity = scale(basis.quantity)?;
|
||||
if basis.quantity == Decimal::ZERO { next.position_action_bases.remove(symbol); }
|
||||
}
|
||||
next.validate()?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn record_position_action_bases(
|
||||
&self,
|
||||
generation: &str,
|
||||
quantities: &BTreeMap<String, Decimal>,
|
||||
) -> Result<Self, String> {
|
||||
self.validate()?;
|
||||
if generation.trim().is_empty() {
|
||||
return Err("stock_pool_execution_state_action_generation_missing".into());
|
||||
}
|
||||
let first_execution_date = self.last_execution_date
|
||||
.ok_or("stock_pool_execution_state_action_clock_missing")?;
|
||||
let mut next = self.clone();
|
||||
next.position_action_bases.retain(|_, basis| basis.generation == generation);
|
||||
for (symbol, quantity) in quantities {
|
||||
if let Some(basis) = next.position_action_bases.get(symbol) {
|
||||
if basis.quantity != *quantity {
|
||||
return Err(format!("stock_pool_execution_state_action_basis_changed:{symbol}"));
|
||||
}
|
||||
} else {
|
||||
next.position_action_bases.insert(symbol.clone(), StockPoolPositionActionBasis {
|
||||
generation: generation.into(), first_execution_date, quantity: *quantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
next.validate()?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn record_targets<'a>(
|
||||
&self,
|
||||
decision_date: NaiveDate,
|
||||
generation: &str,
|
||||
rows: impl IntoIterator<Item = StockPoolGoalObservation<'a>>,
|
||||
) -> Result<Self, String> {
|
||||
self.validate()?;
|
||||
if generation.is_empty()
|
||||
|| self
|
||||
.last_execution_date
|
||||
.is_none_or(|date| decision_date > date)
|
||||
{
|
||||
return Err("stock_pool_execution_state_plan_clock_invalid".into());
|
||||
}
|
||||
let mut next = self.clone();
|
||||
for row in rows {
|
||||
if row.status == "AUTOMATIC_TRADE_PROTECTED" {
|
||||
continue;
|
||||
}
|
||||
if row.target_weight_bps > 0 {
|
||||
next.last_target_weights
|
||||
.insert(row.symbol.into(), row.target_weight_bps);
|
||||
}
|
||||
let eligible = row.target_weight_bps > 0 && row.target_value > Decimal::ZERO;
|
||||
let completion_quantity = (row.status == "READY"
|
||||
&& row.target_quantity > row.current_quantity)
|
||||
.then_some(row.target_quantity);
|
||||
let satisfied = matches!(
|
||||
row.status,
|
||||
"ALREADY_SATISFIED"
|
||||
| "ENTRY_TARGET_ALREADY_SATISFIED"
|
||||
| "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED"
|
||||
);
|
||||
if row.current_quantity == Decimal::ZERO && eligible && satisfied {
|
||||
next.entries.remove(row.symbol);
|
||||
continue;
|
||||
}
|
||||
if let Some(entry) = next.entries.get_mut(row.symbol) {
|
||||
entry.latest_generation = generation.into();
|
||||
entry.latest_target_value = row.target_value;
|
||||
if entry.pending && completion_quantity.is_some() {
|
||||
entry.completion_quantity = completion_quantity;
|
||||
}
|
||||
entry.observed_holding |= row.current_quantity > Decimal::ZERO;
|
||||
if entry.pending && eligible && satisfied {
|
||||
entry.pending = false;
|
||||
}
|
||||
} else if eligible && row.current_quantity == Decimal::ZERO && !satisfied {
|
||||
next.entries.insert(
|
||||
row.symbol.into(),
|
||||
StockPoolEntryProgress {
|
||||
pending: true,
|
||||
observed_holding: false,
|
||||
first_decision_date: decision_date,
|
||||
latest_generation: generation.into(),
|
||||
latest_target_value: row.target_value,
|
||||
completion_quantity,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
next.validate()?;
|
||||
Ok(next)
|
||||
}
|
||||
}
|
||||
@@ -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 { .. }
|
||||
@@ -3179,7 +3184,7 @@ mod tests {
|
||||
.enumerate()
|
||||
.map(|(index, date)| DailyMarketSnapshot {
|
||||
date: *date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: 10.0 + index as f64,
|
||||
open: 10.0 + index as f64,
|
||||
@@ -3205,21 +3210,21 @@ mod tests {
|
||||
.iter()
|
||||
.map(|date| DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 9.0,
|
||||
pe_ttm: 12.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let candidate_rows = dates
|
||||
.iter()
|
||||
.map(|date| CandidateEligibility {
|
||||
date: *date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
|
||||
@@ -83,7 +83,7 @@ impl SelectionContext<'_> {
|
||||
for factor in self.data.factor_snapshot_rows_on(self.decision_date) {
|
||||
if self
|
||||
.dynamic_universe
|
||||
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(&factor.symbol))
|
||||
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(factor.symbol.as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -307,7 +307,7 @@ mod tests {
|
||||
fn market(symbol: &str, price: f64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: d(),
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some("2025-01-02 10:00:00".to_string()),
|
||||
day_open: price,
|
||||
open: price,
|
||||
@@ -333,7 +333,7 @@ mod tests {
|
||||
fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date: d(),
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn: market_cap_bn,
|
||||
pe_ttm: 10.0,
|
||||
@@ -347,7 +347,7 @@ mod tests {
|
||||
fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date: d(),
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
|
||||
@@ -107,6 +107,7 @@ fn action(quantity: &str, when: &str) -> PlatformTradeAction {
|
||||
}
|
||||
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.signal_symbol = "000001.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false;
|
||||
@@ -119,7 +120,7 @@ fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
|
||||
action("-100", "decision_date >= \"2026-09-14\""),
|
||||
];
|
||||
config.matching_type = MatchingType::CurrentBarClose;
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
BacktestEngine::new(
|
||||
data(),
|
||||
@@ -276,6 +277,7 @@ fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
|
||||
)
|
||||
.unwrap();
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.signal_symbol = "000001.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.strategy_name = "protection_test".into();
|
||||
@@ -294,7 +296,7 @@ fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let result = BacktestEngine::new(
|
||||
dataset,
|
||||
|
||||
@@ -15,7 +15,7 @@ fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
fn candidate() -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date: d(2024, 1, 3),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -31,7 +31,7 @@ fn candidate() -> CandidateEligibility {
|
||||
fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: d(2024, 1, 3),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2024-01-03 10:18:00".to_string()),
|
||||
day_open: open,
|
||||
open,
|
||||
@@ -256,7 +256,7 @@ fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() {
|
||||
|
||||
let snapshot = DailyMarketSnapshot {
|
||||
date: d(2024, 4, 7),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2024-04-07 10:18:00".to_string()),
|
||||
day_open: 2.53,
|
||||
open: 2.53,
|
||||
|
||||
@@ -113,7 +113,7 @@ impl Strategy for BuyAndHoldStrategy {
|
||||
fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -139,21 +139,21 @@ fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
|
||||
fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stock_candidate(date: NaiveDate) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -195,7 +195,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: buy_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-01 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -218,7 +218,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: ex_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -241,7 +241,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: payable_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -266,42 +266,42 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: buy_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: ex_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: payable_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: buy_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -314,7 +314,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: ex_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -327,7 +327,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: payable_date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -394,7 +394,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
.with_minimum_commission(0.0),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
),
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
|
||||
BacktestConfig {
|
||||
initial_cash: 11_008.0,
|
||||
benchmark_code: "000300.SH".to_string(),
|
||||
@@ -475,7 +475,7 @@ fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
),
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
|
||||
BacktestConfig {
|
||||
initial_cash: 11_008.0,
|
||||
benchmark_code: "000300.SH".to_string(),
|
||||
|
||||
@@ -105,7 +105,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||
fixture_instruments(),
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some(format!("{date} 15:00:00")),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -128,7 +128,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -139,7 +139,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -170,7 +170,7 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Close,
|
||||
)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
@@ -235,7 +235,7 @@ fn engine_skips_decision_quote_symbol_plan_without_loader() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Close,
|
||||
)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
@@ -269,7 +269,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-05 15:00:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -292,7 +292,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-06 15:00:00".to_string()),
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -317,7 +317,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -328,7 +328,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -341,7 +341,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -354,7 +354,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -391,7 +391,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Last,
|
||||
)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(t(10, 40, 0));
|
||||
let config = BacktestConfig {
|
||||
@@ -439,7 +439,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-05 15:00:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -462,7 +462,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-06 15:00:00".to_string()),
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -487,7 +487,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -498,7 +498,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -511,7 +511,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -524,7 +524,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -590,7 +590,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Last,
|
||||
)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(t(10, 40, 0));
|
||||
let config = BacktestConfig {
|
||||
@@ -674,7 +674,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-05 15:00:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -697,7 +697,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2026-01-06 15:00:00".to_string()),
|
||||
day_open: 10.5,
|
||||
open: 10.5,
|
||||
@@ -722,7 +722,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -733,7 +733,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -746,7 +746,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -759,7 +759,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -796,7 +796,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Last,
|
||||
)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(t(10, 40, 0));
|
||||
let config = BacktestConfig {
|
||||
|
||||
@@ -71,7 +71,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -94,7 +94,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 5.0,
|
||||
open: 5.0,
|
||||
@@ -117,7 +117,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: delist_date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 5.05,
|
||||
open: 5.05,
|
||||
@@ -140,7 +140,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-06 10:18:00".to_string()),
|
||||
day_open: 5.1,
|
||||
open: 5.1,
|
||||
@@ -165,53 +165,53 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 30.0,
|
||||
free_float_cap_bn: 28.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: delist_date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 30.5,
|
||||
free_float_cap_bn: 28.5,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 31.0,
|
||||
free_float_cap_bn: 29.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -224,7 +224,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -237,7 +237,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: delist_date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -250,7 +250,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -295,7 +295,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
BuyThenHoldStrategy,
|
||||
@@ -364,7 +364,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -387,7 +387,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 20.0,
|
||||
open: 20.0,
|
||||
@@ -410,7 +410,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 21.0,
|
||||
open: 21.0,
|
||||
@@ -435,42 +435,42 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 30.0,
|
||||
free_float_cap_bn: 28.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 31.0,
|
||||
free_float_cap_bn: 29.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -483,7 +483,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -496,7 +496,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -548,7 +548,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
BuyThenHoldStrategy,
|
||||
|
||||
@@ -49,7 +49,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -72,18 +72,18 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 100.0,
|
||||
free_float_cap_bn: 80.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -109,7 +109,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
|
||||
fn market_row(date: NaiveDate, symbol: &str, open: f64, close: f64) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: open,
|
||||
open,
|
||||
@@ -139,7 +139,7 @@ fn factor_row(
|
||||
) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 100.0,
|
||||
free_float_cap_bn: 80.0,
|
||||
pe_ttm: 10.0,
|
||||
@@ -153,7 +153,7 @@ fn factor_row(
|
||||
fn candidate_row(date: NaiveDate, symbol: &str) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -219,7 +219,7 @@ fn two_day_futures_data() -> DataSet {
|
||||
("stock_connect_north_bound".into(), 1.0),
|
||||
("industry_citics_l1".into(), 10.0),
|
||||
("fundamental_net_profit".into(), 99.0),
|
||||
]),
|
||||
]).into(),
|
||||
),
|
||||
factor_row(
|
||||
d2,
|
||||
@@ -232,7 +232,7 @@ fn two_day_futures_data() -> DataSet {
|
||||
("stock_connect_north_bound".into(), 1.0),
|
||||
("industry_citics_l1".into(), 10.0),
|
||||
("fundamental_net_profit".into(), 101.0),
|
||||
]),
|
||||
]).into(),
|
||||
),
|
||||
],
|
||||
vec![
|
||||
@@ -1094,7 +1094,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -1117,7 +1117,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 10.1,
|
||||
open: 10.1,
|
||||
@@ -1142,31 +1142,31 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 21.0,
|
||||
free_float_cap_bn: 19.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -1179,7 +1179,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -1219,7 +1219,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
strategy,
|
||||
@@ -1260,7 +1260,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut compact_engine = BacktestEngine::new(
|
||||
compact_data,
|
||||
compact_strategy,
|
||||
@@ -1337,7 +1337,7 @@ fn engine_executes_open_auction_decisions_before_on_day() {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 09:25:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -1360,18 +1360,18 @@ fn engine_executes_open_auction_decisions_before_on_day() {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -1401,7 +1401,7 @@ fn engine_executes_open_auction_decisions_before_on_day() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::DayOpen,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
strategy,
|
||||
@@ -1438,7 +1438,7 @@ fn engine_executes_futures_order_intents_against_future_account() {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -1461,18 +1461,18 @@ fn engine_executes_futures_order_intents_against_future_account() {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 100.0,
|
||||
free_float_cap_bn: 80.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -1497,7 +1497,7 @@ fn engine_executes_futures_order_intents_against_future_account() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
FuturesOrderStrategy,
|
||||
@@ -1569,7 +1569,7 @@ fn platform_runtime_actions_execute_generic_futures_open_and_close() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
two_day_futures_data(),
|
||||
PlatformExprStrategy::new(cfg),
|
||||
@@ -1609,7 +1609,7 @@ fn engine_settles_configured_futures_expiration_at_settlement() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
single_day_anchor_data(date),
|
||||
FuturesOrderStrategy,
|
||||
@@ -1657,7 +1657,7 @@ fn engine_aggregates_futures_account_into_nav_and_metrics() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
single_day_anchor_data(date),
|
||||
FuturesOrderStrategy,
|
||||
@@ -1700,7 +1700,7 @@ fn engine_matches_pending_futures_limit_order_with_data_driven_costs() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
two_day_futures_data(),
|
||||
FuturesLimitOrderStrategy,
|
||||
@@ -1753,7 +1753,7 @@ fn engine_reports_pending_futures_order_at_backtest_boundary() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
two_day_futures_data(),
|
||||
FuturesLimitOrderStrategy,
|
||||
@@ -1805,7 +1805,7 @@ fn engine_rejects_futures_limit_orders_not_aligned_to_tick() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
two_day_futures_data(),
|
||||
FuturesInvalidTickLimitStrategy,
|
||||
@@ -1836,7 +1836,7 @@ fn engine_allows_disabling_futures_limit_tick_validation() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
two_day_futures_data(),
|
||||
FuturesInvalidTickLimitStrategy,
|
||||
@@ -1883,7 +1883,7 @@ fn engine_rejects_futures_limit_orders_outside_price_limits() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
two_day_futures_data(),
|
||||
FuturesAboveUpperLimitStrategy,
|
||||
@@ -1935,7 +1935,7 @@ fn engine_rejects_futures_orders_when_trading_phase_is_closed() {
|
||||
},
|
||||
],
|
||||
vec![market_row(date, "000001.SZ", 10.0, 10.0), future_market],
|
||||
vec![factor_row(date, "000001.SZ", BTreeMap::new())],
|
||||
vec![factor_row(date, "000001.SZ", BTreeMap::new().into())],
|
||||
vec![candidate_row(date, "000001.SZ")],
|
||||
vec![benchmark_row(date)],
|
||||
Vec::new(),
|
||||
@@ -1958,7 +1958,7 @@ fn engine_rejects_futures_orders_when_trading_phase_is_closed() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
FuturesClosedPhaseOrderStrategy,
|
||||
@@ -2011,7 +2011,7 @@ fn engine_sweeps_futures_order_book_depth_when_available() {
|
||||
market_row(date, "000001.SZ", 10.0, 10.0),
|
||||
market_row(date, "IF2501", 4000.0, 4000.0),
|
||||
],
|
||||
vec![factor_row(date, "000001.SZ", BTreeMap::new())],
|
||||
vec![factor_row(date, "000001.SZ", BTreeMap::new().into())],
|
||||
vec![candidate_row(date, "000001.SZ")],
|
||||
vec![benchmark_row(date)],
|
||||
Vec::new(),
|
||||
@@ -2066,7 +2066,7 @@ fn engine_sweeps_futures_order_book_depth_when_available() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Last,
|
||||
)
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::MinuteBestCounterparty);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
@@ -2111,7 +2111,7 @@ fn strategy_context_exposes_advanced_data_helpers() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
two_day_futures_data(),
|
||||
AdvancedDataApiProbeStrategy {
|
||||
@@ -2154,7 +2154,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
}).collect(),
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -2177,18 +2177,18 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
}, market_row(date, "000002.SZ", 20.0, 20.4)],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -2265,7 +2265,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Last,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let loader_requests = Arc::new(Mutex::new(Vec::<ExecutionQuoteRequest>::new()));
|
||||
let loader_requests_for_callback = Arc::clone(&loader_requests);
|
||||
let mut engine = BacktestEngine::new(
|
||||
@@ -2381,7 +2381,7 @@ fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Last,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
strategy,
|
||||
@@ -2446,7 +2446,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
.map(
|
||||
|(date, open, close, prev_close, volume)| DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: open,
|
||||
open,
|
||||
@@ -2473,14 +2473,14 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
.into_iter()
|
||||
.map(|date| DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let candidates = [
|
||||
@@ -2491,7 +2491,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
.into_iter()
|
||||
.map(|(date, is_paused, is_st)| CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -2579,7 +2579,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
strategy,
|
||||
@@ -2619,7 +2619,7 @@ fn strategy_context_exposes_final_order_runtime_view() {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -2642,18 +2642,18 @@ fn strategy_context_exposes_final_order_runtime_view() {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -2683,7 +2683,7 @@ fn strategy_context_exposes_final_order_runtime_view() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Close,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
strategy,
|
||||
@@ -2838,7 +2838,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -2861,7 +2861,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -2886,31 +2886,31 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -2923,7 +2923,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -2959,7 +2959,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Close,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
AccountFlowStrategy,
|
||||
@@ -3035,7 +3035,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -3058,7 +3058,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 9.7,
|
||||
open: 9.7,
|
||||
@@ -3083,31 +3083,31 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 21.0,
|
||||
free_float_cap_bn: 19.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -3120,7 +3120,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -3157,7 +3157,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let strategy = LimitCarryStrategy { issued: false };
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
@@ -3211,7 +3211,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-30 09:25:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -3234,7 +3234,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-31 09:25:00".to_string()),
|
||||
day_open: 10.1,
|
||||
open: 10.1,
|
||||
@@ -3257,7 +3257,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date3,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-02-03 09:25:00".to_string()),
|
||||
day_open: 10.2,
|
||||
open: 10.2,
|
||||
@@ -3282,42 +3282,42 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 21.0,
|
||||
free_float_cap_bn: 19.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date3,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 22.0,
|
||||
free_float_cap_bn: 20.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -3330,7 +3330,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -3343,7 +3343,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date3,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -3394,7 +3394,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::DayOpen,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
strategy,
|
||||
@@ -3469,7 +3469,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-30 09:25:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -3492,7 +3492,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-31 09:25:00".to_string()),
|
||||
day_open: 10.1,
|
||||
open: 10.1,
|
||||
@@ -3515,7 +3515,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date3,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-02-03 09:25:00".to_string()),
|
||||
day_open: 10.2,
|
||||
open: 10.2,
|
||||
@@ -3540,42 +3540,42 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 21.0,
|
||||
free_float_cap_bn: 19.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date3,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 22.0,
|
||||
free_float_cap_bn: 20.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -3588,7 +3588,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -3601,7 +3601,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date3,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -3649,7 +3649,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::DayOpen,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
strategy,
|
||||
@@ -3741,7 +3741,7 @@ fn engine_installs_process_mods_on_event_bus() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::DayOpen,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
HookProbeStrategy {
|
||||
@@ -3778,7 +3778,7 @@ fn engine_installs_enabled_process_mods_from_loader() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::DayOpen,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
HookProbeStrategy {
|
||||
@@ -3855,7 +3855,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
||||
[
|
||||
DailyMarketSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -3878,7 +3878,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: *date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
timestamp: Some(format!("{date} 10:18:00")),
|
||||
day_open: 20.0,
|
||||
open: 20.0,
|
||||
@@ -3908,25 +3908,25 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
||||
[
|
||||
DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 8.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
market_cap_bn: 12.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 12.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
]
|
||||
})
|
||||
@@ -3937,7 +3937,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
||||
[
|
||||
CandidateEligibility {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -3950,7 +3950,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: *date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
symbol: "000002.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -3981,7 +3981,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
strategy,
|
||||
@@ -4041,7 +4041,7 @@ fn engine_exposes_current_process_context_to_strategies() {
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
@@ -4064,18 +4064,18 @@ fn engine_exposes_current_process_context_to_strategies() {
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
extra_factors: Default::default(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
@@ -4105,7 +4105,7 @@ fn engine_exposes_current_process_context_to_strategies() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Last,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
strategy,
|
||||
@@ -4191,8 +4191,8 @@ fn engine_rejects_an_unexplained_missing_holding_close() {
|
||||
market_row(date2, "000001.SZ", 20.0, 20.2),
|
||||
],
|
||||
vec![
|
||||
factor_row(date1, "601028.SH", BTreeMap::new()),
|
||||
factor_row(date2, "000001.SZ", BTreeMap::new()),
|
||||
factor_row(date1, "601028.SH", BTreeMap::new().into()),
|
||||
factor_row(date2, "000001.SZ", BTreeMap::new().into()),
|
||||
],
|
||||
vec![
|
||||
candidate_row(date1, "601028.SH"),
|
||||
@@ -4215,7 +4215,7 @@ fn engine_rejects_an_unexplained_missing_holding_close() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
BuyMissingRowThenHoldStrategy,
|
||||
@@ -4269,8 +4269,8 @@ fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
|
||||
market_row(date2, "000001.SZ", 20.0, 20.2),
|
||||
],
|
||||
vec![
|
||||
factor_row(date1, "601028.SH", BTreeMap::new()),
|
||||
factor_row(date2, "000001.SZ", BTreeMap::new()),
|
||||
factor_row(date1, "601028.SH", BTreeMap::new().into()),
|
||||
factor_row(date2, "000001.SZ", BTreeMap::new().into()),
|
||||
],
|
||||
vec![
|
||||
candidate_row(date1, "601028.SH"),
|
||||
@@ -4290,6 +4290,7 @@ fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
|
||||
)
|
||||
.expect("dataset");
|
||||
let mut config = PlatformExprStrategyConfig::microcap_rotation();
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.strategy_name = "missing-row-platform-risk".to_string();
|
||||
config.benchmark_symbol = "000300.SH".to_string();
|
||||
config.signal_symbol = "000001.SZ".to_string();
|
||||
@@ -4314,7 +4315,7 @@ fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
PlatformExprStrategy::new(config),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"strategyId": "fixture_hold_without_stops_backtest",
|
||||
"version": "1.0.0",
|
||||
"market": "CN_A",
|
||||
"benchmark": {
|
||||
"instrumentId": "000300.SH",
|
||||
"fallbackInstrumentId": "000300.SH",
|
||||
"note": "必须使用真实指数链路;若 000852.SH 不可用,应直接报错而不是退化到其他标的。"
|
||||
},
|
||||
"universe": {
|
||||
"exclude": [],
|
||||
"implementationNotes": [
|
||||
"ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量和费用由 riskPolicy / RiskLimits 统一执行",
|
||||
"上市日期与退市日期取自 instrument 结构化字段,不再使用股票名称做 ST/退市判断",
|
||||
"盘中 current_price / last_price 由策略交易时刻批量 tick 查询驱动"
|
||||
],
|
||||
"include": [
|
||||
"000001.SZ",
|
||||
"000002.SZ"
|
||||
]
|
||||
},
|
||||
"selectors": [
|
||||
{
|
||||
"type": "dynamicRange",
|
||||
"field": "market_cap",
|
||||
"lowerExpr": "0",
|
||||
"upperExpr": "1000000000000",
|
||||
"mapping": "close -> strategy_factory_source_lake.runtime_fields.close"
|
||||
},
|
||||
{
|
||||
"type": "filter",
|
||||
"expr": "(close > 0)"
|
||||
},
|
||||
{
|
||||
"type": "rank",
|
||||
"orderBy": [
|
||||
"market_cap asc"
|
||||
],
|
||||
"limitExpr": "2"
|
||||
}
|
||||
],
|
||||
"rebalance": {
|
||||
"frequencyDays": 1,
|
||||
"tradeTimes": [],
|
||||
"dailyApproximation": "日线回测按 matching_type 撮合;分钟线回测按交易时刻分钟价格撮合",
|
||||
"schedule": {
|
||||
"frequency": "daily"
|
||||
}
|
||||
},
|
||||
"risk": {
|
||||
"takeProfitExpr": "",
|
||||
"stopLossExpr": "",
|
||||
"indexThrottleExpr": "max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0))",
|
||||
"stopTakeReferencePriceMode": "position_average_entry_price"
|
||||
},
|
||||
"seasonality": {
|
||||
"skipWindows": []
|
||||
},
|
||||
"execution": {
|
||||
"selectionGranularity": "strategy_factory_source_lake.daily_source_rows_v1",
|
||||
"executionGranularity": "daily_or_minute_bar",
|
||||
"priceSource": "current_bar_close_or_next_bar_open_or_minute_bar",
|
||||
"matchingType": "current_bar_close",
|
||||
"rebalanceCashMode": "sell_then_buy",
|
||||
"slippageModel": "none",
|
||||
"slippageValue": 0,
|
||||
"riskPolicy": {
|
||||
"rejectStSelection": false,
|
||||
"rejectStarStSelection": false,
|
||||
"rejectPausedSelection": false,
|
||||
"rejectInactiveSelection": false,
|
||||
"rejectNewListingSelection": false,
|
||||
"rejectKcbSelection": false,
|
||||
"rejectBjseSelection": false,
|
||||
"rejectOneYuanSelection": false,
|
||||
"rejectUpperLimitSelection": false,
|
||||
"rejectLowerLimitSelection": false,
|
||||
"rejectStBuy": true,
|
||||
"rejectStarStBuy": true,
|
||||
"rejectPausedBuy": true,
|
||||
"rejectInactiveBuy": true,
|
||||
"rejectNewListingBuy": true,
|
||||
"rejectKcbBuy": true,
|
||||
"rejectBjseBuy": true,
|
||||
"rejectOneYuanBuy": true,
|
||||
"rejectUpperLimitBuy": true,
|
||||
"rejectPausedSell": true,
|
||||
"rejectInactiveSell": true,
|
||||
"rejectLowerLimitSell": true,
|
||||
"respectAllowBuySell": true,
|
||||
"forbidSameDayRebuyAfterSell": true,
|
||||
"blacklistEnabled": true,
|
||||
"blacklistedSymbols": [],
|
||||
"allowMarketOrders": true,
|
||||
"liveTradingEnabled": false,
|
||||
"volumeLimitEnabled": true,
|
||||
"liquidityLimitEnabled": true,
|
||||
"volumePercent": 0.25,
|
||||
"maxOrderQuantity": 1000000,
|
||||
"maxOrderNotional": 100000000,
|
||||
"maxSymbolPosition": 10000000,
|
||||
"commissionRate": 0.0003,
|
||||
"minimumCommission": 5,
|
||||
"stampTaxRateBeforeChange": 0.001,
|
||||
"stampTaxRateAfterChange": 0.0005,
|
||||
"stampTaxChangeDate": "2023-08-28"
|
||||
},
|
||||
"sourceLanguage": "engine-script",
|
||||
"sourceKind": "platform-strategy",
|
||||
"extractor": "omniquant-engine-script-v2",
|
||||
"sellThenBuyDelaySlippageRate": 0,
|
||||
"strictValueBudget": true
|
||||
},
|
||||
"factorRefs": [
|
||||
"close"
|
||||
],
|
||||
"runtimeExpressions": {
|
||||
"prelude": "",
|
||||
"schedule": {
|
||||
"frequency": "daily"
|
||||
},
|
||||
"selection": {
|
||||
"limitExpr": "2",
|
||||
"candidateLimitExpr": "2",
|
||||
"marketCapField": "close",
|
||||
"marketCapLowerExpr": "0",
|
||||
"marketCapUpperExpr": "1000000000000",
|
||||
"stockFilterExpr": "(close > 0)"
|
||||
},
|
||||
"risk": {
|
||||
"exposureExpr": "max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0))",
|
||||
"stopLossExpr": "",
|
||||
"takeProfitExpr": "",
|
||||
"stopTakeReferencePriceMode": "position_average_entry_price"
|
||||
},
|
||||
"allocation": {
|
||||
"buyScaleExpr": "1.0"
|
||||
},
|
||||
"ordering": {
|
||||
"rankBy": "market_cap",
|
||||
"rankExpr": "(symbol == \"000001.SZ\" || symbol == \"000002.SZ\") ? (symbol == \"000001.SZ\" ? (0) : (1)) : 2",
|
||||
"rankOrder": "asc"
|
||||
},
|
||||
"trading": {
|
||||
"rotationEnabled": true,
|
||||
"subscriptionGuardRequired": false,
|
||||
"stage": "on_day",
|
||||
"actions": []
|
||||
}
|
||||
},
|
||||
"engineConfig": {
|
||||
"templateId": "fixture_hold_without_stops_backtest",
|
||||
"benchmarkSymbol": "000300.SH",
|
||||
"signalSymbol": "000300.SH",
|
||||
"rankLimit": 2,
|
||||
"refreshRate": 1,
|
||||
"rsiRate": 1.0001,
|
||||
"dynamicRange": {
|
||||
"baseIndexLevel": 2000,
|
||||
"baseCapFloor": 7,
|
||||
"capSpan": 1000000000000,
|
||||
"xs": 0.008
|
||||
},
|
||||
"stopLossMultiplier": null,
|
||||
"takeProfitMultiplier": null,
|
||||
"matchingType": "current_bar_close",
|
||||
"rebalanceCashMode": "sell_then_buy",
|
||||
"slippageModel": "none",
|
||||
"slippageValue": 0,
|
||||
"riskPolicy": {
|
||||
"rejectStSelection": false,
|
||||
"rejectStarStSelection": false,
|
||||
"rejectPausedSelection": false,
|
||||
"rejectInactiveSelection": false,
|
||||
"rejectNewListingSelection": false,
|
||||
"rejectKcbSelection": false,
|
||||
"rejectBjseSelection": false,
|
||||
"rejectOneYuanSelection": false,
|
||||
"rejectUpperLimitSelection": false,
|
||||
"rejectLowerLimitSelection": false,
|
||||
"rejectStBuy": true,
|
||||
"rejectStarStBuy": true,
|
||||
"rejectPausedBuy": true,
|
||||
"rejectInactiveBuy": true,
|
||||
"rejectNewListingBuy": true,
|
||||
"rejectKcbBuy": true,
|
||||
"rejectBjseBuy": true,
|
||||
"rejectOneYuanBuy": true,
|
||||
"rejectUpperLimitBuy": true,
|
||||
"rejectPausedSell": true,
|
||||
"rejectInactiveSell": true,
|
||||
"rejectLowerLimitSell": true,
|
||||
"respectAllowBuySell": true,
|
||||
"forbidSameDayRebuyAfterSell": true,
|
||||
"blacklistEnabled": true,
|
||||
"blacklistedSymbols": [],
|
||||
"allowMarketOrders": true,
|
||||
"liveTradingEnabled": false,
|
||||
"volumeLimitEnabled": true,
|
||||
"liquidityLimitEnabled": true,
|
||||
"volumePercent": 0.25,
|
||||
"maxOrderQuantity": 1000000,
|
||||
"maxOrderNotional": 100000000,
|
||||
"maxSymbolPosition": 10000000,
|
||||
"commissionRate": 0.0003,
|
||||
"minimumCommission": 5,
|
||||
"stampTaxRateBeforeChange": 0.001,
|
||||
"stampTaxRateAfterChange": 0.0005,
|
||||
"stampTaxChangeDate": "2023-08-28"
|
||||
},
|
||||
"skipWindows": [],
|
||||
"rebalanceSchedule": {
|
||||
"frequency": "daily"
|
||||
},
|
||||
"dividendReinvestment": false,
|
||||
"sellThenBuyDelaySlippageRate": 0,
|
||||
"strictValueBudget": true
|
||||
},
|
||||
"stockPool": {
|
||||
"schema_version": 1,
|
||||
"pool_id": "fixture-pool",
|
||||
"version_id": "fixture-version",
|
||||
"members": [
|
||||
{
|
||||
"symbol": "000001.SZ",
|
||||
"requested_order": 0,
|
||||
"recommendation_reason": "",
|
||||
"target_weight_bps": null,
|
||||
"stop_loss": null,
|
||||
"take_profit": null
|
||||
},
|
||||
{
|
||||
"symbol": "000002.SZ",
|
||||
"requested_order": 1,
|
||||
"recommendation_reason": "",
|
||||
"target_weight_bps": null,
|
||||
"stop_loss": null,
|
||||
"take_profit": null
|
||||
}
|
||||
],
|
||||
"allocation_policy": {
|
||||
"target_holding_count": 2,
|
||||
"portfolio_policy": {
|
||||
"schema_version": 1,
|
||||
"membership": "retain_holdings",
|
||||
"rebalance_weights": false
|
||||
},
|
||||
"invest_ratio_bps": 10000,
|
||||
"reserve_cash": 0
|
||||
},
|
||||
"timing_policy": {
|
||||
"schema_version": 1,
|
||||
"auto_execute": true,
|
||||
"freeze_time": "00:00",
|
||||
"window_start": "09:30",
|
||||
"window_end": "15:00",
|
||||
"trigger_mode": "scheduled_bar",
|
||||
"pricing_mode": "first_tick",
|
||||
"automatic_trade_protection": {
|
||||
"buy_protection_days": 0,
|
||||
"sell_cooldown_days": 0,
|
||||
"max_holding_days": 0,
|
||||
"locks": []
|
||||
}
|
||||
},
|
||||
"stop_take_policy": {
|
||||
"stop_loss": null,
|
||||
"take_profit": null
|
||||
},
|
||||
"out_of_pool_policy": "hold"
|
||||
},
|
||||
"signalSymbol": "000300.SH",
|
||||
"sourceCode": "strategy(\"fixture_hold_without_stops_backtest\") {\n mode(\"rotation\")\n market(\"CN_A\")\n benchmark(\"000300.SH\")\n signal(\"000300.SH\")\n rebalance.every_days(1)\n universe.include([\"000001.SZ\", \"000002.SZ\"])\n selection.limit(2)\n selection.candidate_limit(2)\n selection.market_cap_band(field=\"close\", lower=0, upper=1000000000000)\n filter.stock_expr(close > 0)\n ordering.rank_expr((symbol == \"000001.SZ\" || symbol == \"000002.SZ\") ? (symbol == \"000001.SZ\" ? (0) : (1)) : 2, \"asc\")\n risk.index_exposure(max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0)))\n allocation.buy_scale(1.0)\n stock_pool.config({\"schema_version\":1,\"pool_id\":\"fixture-pool\",\"version_id\":\"fixture-version\",\"members\":[{\"symbol\":\"000001.SZ\",\"requested_order\":0,\"recommendation_reason\":\"\",\"target_weight_bps\":null,\"stop_loss\":null,\"take_profit\":null},{\"symbol\":\"000002.SZ\",\"requested_order\":1,\"recommendation_reason\":\"\",\"target_weight_bps\":null,\"stop_loss\":null,\"take_profit\":null}],\"allocation_policy\":{\"target_holding_count\":2,\"portfolio_policy\":{\"schema_version\":1,\"membership\":\"retain_holdings\",\"rebalance_weights\":false},\"invest_ratio_bps\":10000,\"reserve_cash\":0},\"timing_policy\":{\"schema_version\":1,\"auto_execute\":true,\"freeze_time\":\"00:00\",\"window_start\":\"09:30\",\"window_end\":\"15:00\",\"trigger_mode\":\"scheduled_bar\",\"pricing_mode\":\"first_tick\",\"automatic_trade_protection\":{\"buy_protection_days\":0,\"sell_cooldown_days\":0,\"max_holding_days\":0,\"locks\":[]}},\"stop_take_policy\":{\"stop_loss\":null,\"take_profit\":null},\"out_of_pool_policy\":\"hold\"})\n risk.reference_price_mode(\"position_average_entry_price\")\n execution.matching_type(\"current_bar_close\")\n}\n",
|
||||
"mode": "rotation"
|
||||
}
|
||||
@@ -17,7 +17,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
|
||||
.iter()
|
||||
.map(|date| DailyMarketSnapshot {
|
||||
date: *date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: None,
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
|
||||
@@ -0,0 +1,909 @@
|
||||
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).into(),
|
||||
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).into(),
|
||||
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).into(),
|
||||
market_cap_bn: 10.,
|
||||
free_float_cap_bn: 10.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote {
|
||||
date: row.date, symbol: row.symbol.to_string(), timestamp: row.date.and_hms_opt(9, 30, 0).unwrap(),
|
||||
last_price: row.open, bid1: row.open, ask1: row.open, bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: row.volume, amount_delta: row.open * row.volume as f64,
|
||||
trading_phase: Some("synthetic_observation_fixture".into()),
|
||||
}).collect();
|
||||
DataSet::from_components_with_actions_and_quotes(instruments, market, factors, candidates, benchmarks, vec![], quotes).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_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap())
|
||||
.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_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.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 ordinary_sell_has_one_order_owner_before_broker_execution() {
|
||||
let data = data(false);
|
||||
let broker = broker(false);
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
account.position_mut(&code(1)).buy(day(2), 1000, 10.);
|
||||
let mut intent = contract(day(2), 1, false);
|
||||
intent.rule.sell_trigger_mode = POOL_SELL_CONDITION.into();
|
||||
intent.rule.sell_condition = "price>0".into();
|
||||
let report = broker.execute_with_event_dates(
|
||||
day(5), day(2), day(2), &mut account, &data, &decision(intent),
|
||||
).unwrap();
|
||||
let sells=report.fill_events.iter().filter(|row|row.symbol==code(1)).collect::<Vec<_>>();
|
||||
assert_eq!(sells.len(),1,"{report:?}");
|
||||
assert_eq!(sells[0].quantity,1000,"{report:?}");
|
||||
let owners=report.order_events.iter().filter(|row|row.symbol==code(1)).map(|row|row.order_id).collect::<BTreeSet<_>>();
|
||||
assert_eq!(owners.len(),1,"{report:?}");
|
||||
assert_eq!(account.position(&code(1)).map(|row|row.quantity).unwrap_or(0),0);
|
||||
// The replacement may enter only after the single sell has settled.
|
||||
let replacement=report.fill_events.iter().find(|row|row.symbol==code(2)).unwrap();
|
||||
assert_eq!(replacement.quantity,3000,"{report:?}");
|
||||
assert_eq!(report.account_events[0].cash_after,40000.);
|
||||
assert_eq!(report.account_events[1].cash_before,40000.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeating_the_same_partial_exit_generation_does_not_reduce_again() {
|
||||
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),1,true);
|
||||
intent.constraints.independent_position_target_bps.insert(code(1),5000);
|
||||
let first=broker.execute_with_event_dates(day(5),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
|
||||
assert_eq!(first.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),500);
|
||||
let repeated=broker.execute_with_event_dates(day(5),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
|
||||
assert!(repeated.fill_events.iter().all(|fill|fill.symbol!=code(1)),"same generation must keep its first partial-exit target: {repeated:?}");
|
||||
assert_eq!(account.position(&code(1)).unwrap().quantity,500);
|
||||
let next_day=broker.execute_with_event_dates(day(6),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
|
||||
assert!(next_day.fill_events.iter().all(|fill|fill.symbol!=code(1)),"{next_day:?}");
|
||||
assert_eq!(account.position(&code(1)).unwrap().quantity,500);
|
||||
intent.generation="a-new-reduction-signal".into();
|
||||
let new_signal=broker.execute_with_event_dates(day(6),day(6),day(6),&mut account,&data,&decision(intent)).unwrap();
|
||||
assert_eq!(new_signal.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),300);
|
||||
}
|
||||
|
||||
#[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 {
|
||||
exit_signals: vec![],
|
||||
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 parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
|
||||
for (ordinary, risk, quote, sold) in [
|
||||
(Some(0),None,"price<1",0),
|
||||
(None,Some(0),"price<1",3000),
|
||||
(Some(0),Some(5000),"price<1",1500),
|
||||
(Some(0),Some(5000),"price>1",3000),
|
||||
(None,Some(5000),"",1500),
|
||||
] {
|
||||
let exits=ordinary.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::OrdinarySell,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"ordinary fixture".into()})
|
||||
.chain(risk.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::RiskExit,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"risk fixture".into()})).collect::<Vec<_>>();
|
||||
let program=StockPoolProgram{schema_version:1,pool_id:"typed-exits".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
|
||||
allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
|
||||
timing_policy:serde_json::json!({"pricing_mode":"first_tick","sell_trigger_mode":"condition","sell_condition":quote}),
|
||||
stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(),exit_signals:exits};
|
||||
let mut config=platform_expr_config_from_value("typed-exits","000300.SH",&serde_json::json!({"stockPool":program,"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="1000000".into();
|
||||
config.stock_filter_expr="close>0".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));
|
||||
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(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Close,
|
||||
}).run().unwrap();
|
||||
assert_eq!(result.fills.iter().filter(|fill|fill.date==day(2)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Buy).map(|fill|fill.quantity).sum::<u32>(),3000,"exit-only criteria must not suppress a new entry: {result:?}");
|
||||
let sold_quantity=result.fills.iter().filter(|fill|fill.date==day(5)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Sell).map(|fill|fill.quantity).sum::<u32>();
|
||||
assert_eq!(sold_quantity,sold,"ordinary={ordinary:?} risk={risk:?} quote={quote}: {result:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
fn etf_fallback_fixture(time: chrono::NaiveTime) -> DataSet {
|
||||
let mut parts = data_with_fund_rules(1_000_000, None, true).snapshot_components();
|
||||
let previous = NaiveDate::from_ymd_opt(2025,12,31).unwrap();
|
||||
for instrument in &mut parts.instruments { instrument.listed_at = Some(NaiveDate::from_ymd_opt(2025,12,1).unwrap()); }
|
||||
let mut past_market = parts.market.iter().filter(|row| row.date == day(2)).cloned().collect::<Vec<_>>();
|
||||
for row in &mut past_market { row.date=previous; if row.symbol == code(2) { row.close=5.; row.open=5.; row.high=5.; row.low=5.; row.last_price=5.; } }
|
||||
parts.market.extend(past_market);
|
||||
let mut past_factors=parts.factors.iter().filter(|row|row.date==day(2)).cloned().collect::<Vec<_>>();
|
||||
for row in &mut past_factors {row.date=previous;}
|
||||
parts.factors.extend(past_factors);
|
||||
let mut past_candidates=parts.candidates.iter().filter(|row|row.date==day(2)).cloned().collect::<Vec<_>>();
|
||||
for row in &mut past_candidates {row.date=previous;}
|
||||
parts.candidates.extend(past_candidates);
|
||||
for factor in &mut parts.factors { if factor.symbol==code(2) {factor.market_cap_bn=f64::NAN;factor.free_float_cap_bn=f64::NAN;} }
|
||||
let mut past_benchmark = parts.benchmarks[0].clone(); past_benchmark.date=previous; parts.benchmarks.push(past_benchmark);
|
||||
for row in &mut parts.market {
|
||||
if row.symbol==code(2) && row.date>=day(2) {
|
||||
row.open=if row.date==day(2) {10.} else {4.}; row.day_open=row.open;
|
||||
row.close=40.; row.last_price=40.; row.high=40.; row.low=row.open; row.prev_close=5.;
|
||||
}
|
||||
}
|
||||
parts.execution_quotes.retain(|row| row.symbol==code(1));
|
||||
for quote in &mut parts.execution_quotes { quote.timestamp=quote.date.and_time(time); }
|
||||
DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap()
|
||||
}
|
||||
|
||||
struct EtfPoolSignal { at:chrono::NaiveTime, condition:String }
|
||||
impl fidc_core::strategy::Strategy for EtfPoolSignal {
|
||||
fn name(&self)->&str {"ETF fallback fixture"}
|
||||
fn requires_minute_callbacks(&self)->bool {false}
|
||||
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {vec![self.at]}
|
||||
fn decision_quote_symbols(&mut self,_:&fidc_core::strategy::StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError> {Ok(BTreeSet::from([code(1),code(2)]))}
|
||||
fn on_day(&mut self,ctx:&fidc_core::strategy::StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {
|
||||
if ctx.execution_date!=day(2) {return Ok(StrategyDecision::default());}
|
||||
let mut intent=contract(day(2),1,true);
|
||||
intent.selection.final_symbols=vec![code(1),code(2)];
|
||||
intent.constraints.target_holding_count=Some(2);
|
||||
intent.rule.buy_condition=self.condition.clone();
|
||||
Ok(decision(intent))
|
||||
}
|
||||
}
|
||||
|
||||
fn run_etf_fallback(time:chrono::NaiveTime,end:NaiveDate,enabled:bool,condition:&str,loader_fails:bool,volume_limit:bool)->Result<fidc_core::BacktestResult,fidc_core::BacktestError> {
|
||||
let broker=broker(volume_limit).with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time)
|
||||
.with_historical_etf_open_fallback(enabled);
|
||||
BacktestEngine::new(etf_fallback_fixture(time),EtfPoolSignal{at:time,condition:condition.into()},broker,BacktestConfig{
|
||||
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(end),decision_lag_trading_days:0,execution_price_field:PriceField::Last,
|
||||
}).with_execution_quote_loader(Box::new(move |_| {
|
||||
if loader_fails {Err(fidc_core::BacktestError::Execution("fixture_source_unavailable".into()))} else {Ok(vec![])}
|
||||
})).run()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_etf_open_uses_real_open_without_creating_minute_bars() {
|
||||
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(9,30,0).unwrap(),day(6),true,"",false,false).unwrap();
|
||||
let etf=result.fills.iter().filter(|fill| fill.symbol==code(2)).collect::<Vec<_>>();
|
||||
assert_eq!(etf.len(),1,"{:?}",result.fills);
|
||||
assert_eq!((etf[0].date,etf[0].price,etf[0].quantity),(day(2),10.,1500));
|
||||
assert_eq!(etf[0].execution_timestamp,Some(day(2).and_hms_opt(9,30,0).unwrap()));
|
||||
assert!(etf[0].reason.contains("etf_daily_open_fallback"));
|
||||
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)&&fill.date==day(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_etf_late_signal_freezes_money_and_requantifies_at_next_official_open() {
|
||||
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(6),true,"",false,false).unwrap();
|
||||
let etf=result.fills.iter().filter(|fill| fill.symbol==code(2)).collect::<Vec<_>>();
|
||||
assert_eq!(etf.len(),1,"{:?}",result.fills);
|
||||
assert_eq!((etf[0].date,etf[0].price,etf[0].quantity),(day(5),4.,3700));
|
||||
assert_eq!(etf[0].execution_timestamp,Some(day(5).and_hms_opt(9,30,0).unwrap()));
|
||||
assert_eq!(etf[0].order_created_date,Some(day(2)));
|
||||
assert!(etf[0].reason.contains("2026-01-02 13:00:00"));
|
||||
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)&&fill.date==day(2)));
|
||||
assert!(result.terminal_audit.is_clean());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() {
|
||||
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap();
|
||||
assert_eq!(result.terminal_audit.deferred_etf_target_count,1);
|
||||
assert_eq!(result.terminal_audit.status,fidc_core::BacktestTerminalStatus::CompletedWithPendingState);
|
||||
assert!(result.order_events.iter().all(|order|order.symbol!=code(2)));
|
||||
assert!(result.fills.iter().all(|fill|fill.symbol!=code(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_etf_fallback_does_not_waive_source_conditions_or_capacity() {
|
||||
let at=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
|
||||
assert!(run_etf_fallback(at,day(6),false,"",false,false).is_err());
|
||||
assert!(run_etf_fallback(at,day(6),true,"last > 1",false,false).unwrap_err().to_string().contains("condition evidence unavailable"));
|
||||
assert!(run_etf_fallback(at,day(6),true,"",true,false).unwrap_err().to_string().contains("fixture_source_unavailable"));
|
||||
assert!(run_etf_fallback(at,day(6),true,"",false,true).unwrap_err().to_string().contains("capacity is missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiled_pool_price_screen_does_not_require_unconfigured_etf_market_cap() {
|
||||
let time=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
|
||||
let intent=contract(day(2),1,true);
|
||||
let program=StockPoolProgram {
|
||||
exit_signals: vec![],
|
||||
schema_version:1,pool_id:"typed-mixed-pool".into(),version_id:"v1".into(),members:intent.members,
|
||||
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
|
||||
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"09:30"}),
|
||||
stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(),
|
||||
};
|
||||
let mut config=platform_expr_config_from_value("etf-no-cap-filter","000300.SH",&serde_json::json!({"stockPool":program,"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="1000000".into();
|
||||
config.stock_filter_expr="close > 0".into();config.selection_limit_expr="2".into();config.selection_candidate_limit_expr="2".into();
|
||||
config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));
|
||||
config.intraday_execution_time=Some(time);config.matching_type=MatchingType::CurrentBarClose;
|
||||
config.risk_config.trading_constraints.volume_limit_enabled=false;
|
||||
let result=BacktestEngine::new(etf_fallback_fixture(time),PlatformExprStrategy::new(config.clone()),
|
||||
broker(false).with_matching_type(MatchingType::CurrentBarClose).with_historical_etf_open_fallback(true),
|
||||
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(5)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
|
||||
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
|
||||
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)),"{:?}",result.equity_curve.iter().map(|row|&row.diagnostics).collect::<Vec<_>>());
|
||||
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)));
|
||||
config.stock_filter_expr="last != 0".into();
|
||||
let rejected=BacktestEngine::new(etf_fallback_fixture(time),PlatformExprStrategy::new(config),
|
||||
broker(false).with_matching_type(MatchingType::CurrentBarClose).with_historical_etf_open_fallback(true),
|
||||
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(5)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
|
||||
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap_err();
|
||||
assert!(rejected.to_string().contains("etf_intraday_condition_evidence_missing"),"{rejected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn etf_signal_budget_does_not_read_the_current_sessions_future_close() {
|
||||
let run=|future_close:f64| {
|
||||
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
|
||||
let mut parts=etf_fallback_fixture(time).snapshot_components();
|
||||
for row in &mut parts.market {
|
||||
if row.symbol==code(2)&&row.date==day(5) {row.close=future_close;row.last_price=future_close;row.high=future_close.max(row.open);}
|
||||
}
|
||||
let data=DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap();
|
||||
let program=StockPoolProgram{exit_signals:vec![],schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
|
||||
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":true}}),
|
||||
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"13:00","window_end":"14:55"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into()};
|
||||
let mut config=platform_expr_config_from_value("etf-budget","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"13:00"}}})).unwrap();
|
||||
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
|
||||
config.stock_filter_expr="true".into();config.selection_limit_expr="2".into();config.selection_candidate_limit_expr="2".into();
|
||||
config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));config.intraday_execution_time=Some(time);config.matching_type=MatchingType::CurrentBarClose;
|
||||
config.risk_config.trading_constraints.volume_limit_enabled=false;
|
||||
BacktestEngine::new(data,PlatformExprStrategy::new(config),broker(false).with_matching_type(MatchingType::CurrentBarClose).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
|
||||
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
|
||||
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap()
|
||||
};
|
||||
let a=run(40.);let b=run(400.);
|
||||
let budget=|result:&fidc_core::BacktestResult|result.equity_curve.iter().find(|row|row.date==day(5)).unwrap().diagnostics.split(" | ").find(|line|line.starts_with("stock_pool_signal_frozen")).unwrap().to_string();
|
||||
assert_eq!(budget(&a),budget(&b));
|
||||
assert_eq!(serde_json::to_value(&a.fills).unwrap(),serde_json::to_value(&b.fills).unwrap());
|
||||
}
|
||||
|
||||
struct EtfReallocationSignal { protection_days: u32 }
|
||||
impl fidc_core::strategy::Strategy for EtfReallocationSignal {
|
||||
fn name(&self)->&str {"deferred ETF sell funding"}
|
||||
fn requires_minute_callbacks(&self)->bool {false}
|
||||
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {vec![chrono::NaiveTime::from_hms_opt(13,0,0).unwrap()]}
|
||||
fn decision_quote_symbols(&mut self,_:&fidc_core::strategy::StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError>{Ok(BTreeSet::from([code(1),code(2)]))}
|
||||
fn on_day(&mut self,ctx:&fidc_core::strategy::StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {
|
||||
if ![day(2),day(6)].contains(&ctx.execution_date) {return Ok(Default::default());}
|
||||
let mut intent=contract(ctx.execution_date,1,false);
|
||||
intent.rule.automatic_trade_protection.buy_protection_days=self.protection_days;
|
||||
if ctx.execution_date==day(2) {intent.selection.final_symbols=vec![code(1),code(2)];intent.constraints.target_holding_count=Some(2);}
|
||||
else {intent.frozen_equity=300000.into();intent.out_of_pool_policy="reduce_to_zero_when_sellable".into();}
|
||||
Ok(decision(intent))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_etf_sell_does_not_finance_same_day_stock_topup() {
|
||||
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
|
||||
let result=BacktestEngine::new(etf_fallback_fixture(time),EtfReallocationSignal{protection_days:0},
|
||||
broker(false).with_matching_type(MatchingType::MinuteLast).with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
|
||||
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::Last})
|
||||
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
|
||||
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)&&fill.date==day(5)));
|
||||
assert!(result.fills.iter().all(|fill|fill.date!=day(6)),"{:?}",result.fills);
|
||||
assert!(!result.order_events.iter().any(|order|order.date==day(6)&&order.symbol==code(1)&&order.side==fidc_core::OrderSide::Buy),"{:?}",result.order_events);
|
||||
assert_eq!(result.terminal_audit.deferred_etf_target_count,1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn etf_post_buy_protection_starts_on_deferred_fill_day_not_signal_day() {
|
||||
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
|
||||
let result=BacktestEngine::new(etf_fallback_fixture(time),EtfReallocationSignal{protection_days:1},
|
||||
broker(false).with_matching_type(MatchingType::MinuteLast).with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
|
||||
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::Last})
|
||||
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
|
||||
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)&&fill.date==day(5)));
|
||||
assert!(result.fills.iter().filter(|fill|fill.symbol==code(2)).all(|fill|fill.side!=fidc_core::OrderSide::Sell));
|
||||
// Jan 2 is the signal; actual Jan 5 fill protects Jan 5 and Jan 6.
|
||||
// Starting the timer on Jan 2 would incorrectly queue an exit on Jan 6.
|
||||
assert_eq!(result.terminal_audit.deferred_etf_target_count,0);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
use chrono::NaiveDate;
|
||||
use fidc_core::stock_pool_execution::*;
|
||||
use fidc_core::stock_pool_state::StockPoolExecutionState;
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn day(value: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, value).unwrap()
|
||||
}
|
||||
fn member() -> StockPoolMemberSpec {
|
||||
StockPoolMemberSpec {
|
||||
symbol: "000001.SZ".into(),
|
||||
requested_order: 0,
|
||||
recommendation_reason: String::new(),
|
||||
target_weight_bps: None,
|
||||
stop_loss: None,
|
||||
take_profit: None,
|
||||
}
|
||||
}
|
||||
fn held(quantity: i64, closable: i64) -> Position {
|
||||
Position {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: quantity.into(),
|
||||
closable_quantity: closable.into(),
|
||||
average_cost: 10.into(),
|
||||
}
|
||||
}
|
||||
fn quote() -> MarketSnapshot {
|
||||
MarketSnapshot {
|
||||
symbol: "000001.SZ".into(),
|
||||
last_price: 10.into(),
|
||||
prev_close: Some(10.into()),
|
||||
volume: Some(1000000.into()),
|
||||
turnover: Some(10000000.into()),
|
||||
bid_price_1: Some(10.into()),
|
||||
ask_price_1: Some(10.into()),
|
||||
is_kcb: Some(false),
|
||||
instrument_rules: None,
|
||||
buy_sizing_price: None,
|
||||
sell_sizing_price: None,
|
||||
}
|
||||
}
|
||||
fn plan(
|
||||
state: &StockPoolExecutionState,
|
||||
at: NaiveDate,
|
||||
members: &[StockPoolMemberSpec],
|
||||
positions: &[Position],
|
||||
cash: i64,
|
||||
outside: &str,
|
||||
) -> StockPoolPlan {
|
||||
plan_at_price(state, at, members, positions, cash, outside, 10)
|
||||
}
|
||||
fn plan_at_price(
|
||||
state: &StockPoolExecutionState,
|
||||
at: NaiveDate,
|
||||
members: &[StockPoolMemberSpec],
|
||||
positions: &[Position],
|
||||
cash: i64,
|
||||
outside: &str,
|
||||
price: i64,
|
||||
) -> StockPoolPlan {
|
||||
let symbols = members
|
||||
.iter()
|
||||
.map(|member| member.symbol.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let selection = StockPoolSelection {
|
||||
trade_date: at,
|
||||
requested_symbols: symbols.clone(),
|
||||
normal_trading_symbols: symbols.clone(),
|
||||
risk_eligible_symbols: symbols.clone(),
|
||||
final_symbols: symbols,
|
||||
exclusion_reasons: BTreeMap::new(),
|
||||
inherited_from_generation: None,
|
||||
explicit_empty: false,
|
||||
generation: Some("same-goal".into()),
|
||||
};
|
||||
let mut constraints = stock_pool_constraints_from_configuration(
|
||||
&json!({"top_n_rebalance_policy":"preserve_existing"}),
|
||||
&json!({}),
|
||||
)
|
||||
.unwrap();
|
||||
constraints.pending_entry_symbols = state.pending_symbols();
|
||||
constraints.next_day_outside_exit_symbols = state.next_day_exit_symbols(at);
|
||||
let mut market = quote();
|
||||
market.last_price = price.into();
|
||||
market.bid_price_1 = Some(price.into());
|
||||
market.ask_price_1 = Some(price.into());
|
||||
build_stock_pool_target_plan_with_constraints(
|
||||
&selection,
|
||||
members,
|
||||
&StockPoolExecutionRule::default(),
|
||||
&AccountSnapshot {
|
||||
total_equity: 10000.into(),
|
||||
cash: cash.into(),
|
||||
frozen_cash: Decimal::ZERO,
|
||||
},
|
||||
positions,
|
||||
&[market],
|
||||
10000,
|
||||
Decimal::ZERO,
|
||||
outside,
|
||||
"preserve_existing",
|
||||
&constraints,
|
||||
"same-goal",
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_filled_entry_is_not_reopened_when_price_falls_before_next_observation() {
|
||||
let members = vec![member()];
|
||||
let calendar = vec![day(11), day(14)];
|
||||
let initial = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
|
||||
let first_plan = plan(&initial, day(11), &members, &[], 10000, "hold");
|
||||
let state = initial.record_plan(day(11), "first-entry", &first_plan).unwrap();
|
||||
assert_eq!(state.entries["000001.SZ"].completion_quantity, Some(1000.into()));
|
||||
let state: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
|
||||
let observed = state.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)]).unwrap();
|
||||
assert!(!observed.pending_symbols().contains("000001.SZ"));
|
||||
let next = plan_at_price(&observed, day(14), &members, &[held(1000, 1000)], 1000, "hold", 8);
|
||||
assert_eq!(next.rows[0].target_quantity, 1000.into());
|
||||
assert_eq!(next.rows[0].delta_quantity, Decimal::ZERO);
|
||||
assert_eq!(next.rows[0].status, "PRESERVED_EXISTING_POSITION");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_clipped_or_rejected_entry_does_not_claim_full_completion() {
|
||||
let members = vec![member()];
|
||||
let calendar = vec![day(11), day(14)];
|
||||
let initial = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
|
||||
let first_plan = plan(&initial, day(11), &members, &[], 5000, "hold");
|
||||
assert_eq!(first_plan.rows[0].status, "REDUCE_TO_ALLOWED_QUANTITY");
|
||||
let state = initial.record_plan(day(11), "limited-entry", &first_plan).unwrap();
|
||||
assert_eq!(state.entries["000001.SZ"].completion_quantity, None);
|
||||
for quantity in [0, 500] {
|
||||
let positions = if quantity == 0 { vec![] } else { vec![held(quantity, quantity)] };
|
||||
let observed = state.observe(day(14), day(14), &calendar, &members, &positions).unwrap();
|
||||
assert!(observed.pending_symbols().contains("000001.SZ"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_state_without_quantity_keeps_its_serialized_identity() {
|
||||
let original = json!({
|
||||
"schema_version":1,"last_execution_date":"2026-09-11",
|
||||
"entries":{"000001.SZ":{"pending":true,"observed_holding":false,
|
||||
"first_decision_date":"2026-09-11","latest_generation":"legacy",
|
||||
"latest_target_value":"10000"}},"last_target_weights":{},"removed_since":{}
|
||||
});
|
||||
let state: StockPoolExecutionState = serde_json::from_value(original.clone()).unwrap();
|
||||
state.validate().unwrap();
|
||||
assert_eq!(serde_json::to_value(state).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_exit_basis_is_immutable_restart_safe_and_scoped_to_the_signal() {
|
||||
let original = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &[day(11), day(14)], &[member()], &[held(1000, 1000)]).unwrap();
|
||||
let basis = BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))]);
|
||||
let saved = original.record_position_action_bases("sell-signal", &basis).unwrap();
|
||||
assert!(original.position_action_bases.is_empty(), "a preview must not mutate its input");
|
||||
let restored: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&saved).unwrap()).unwrap();
|
||||
let next_day = restored.observe(day(11), day(14), &[day(11), day(14)], &[member()], &[held(500, 500)]).unwrap();
|
||||
assert_eq!(next_day.position_action_bases_for("sell-signal"), basis);
|
||||
assert!(next_day.position_action_bases_for("new-signal").is_empty());
|
||||
assert!(next_day.record_position_action_bases("sell-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap_err().contains("basis_changed"));
|
||||
let new_signal = next_day.record_position_action_bases("new-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap();
|
||||
assert!(new_signal.position_action_bases_for("sell-signal").is_empty());
|
||||
assert_eq!(new_signal.position_action_bases_for("new-signal")["000001.SZ"], Decimal::from(500));
|
||||
for invalid in [Decimal::ZERO, Decimal::NEGATIVE_ONE] {
|
||||
assert!(original.record_position_action_bases("signal", &BTreeMap::from([("000001.SZ".into(), invalid)])).is_err());
|
||||
}
|
||||
assert!(original.record_position_action_bases(" ", &basis).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_split_adjusts_exit_basis_and_entry_completion_not_generation() {
|
||||
let initial = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &[day(11)], &[member()], &[]).unwrap();
|
||||
let entry_plan = plan(&initial, day(11), &[member()], &[], 10000, "hold");
|
||||
let entered = initial.record_plan(day(11), "entry", &entry_plan).unwrap();
|
||||
let saved = entered.record_position_action_bases("sell", &BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))])).unwrap();
|
||||
let adjusted = saved.adjust_for_split("000001.SZ", Decimal::new(15,1)).unwrap();
|
||||
assert_eq!(adjusted.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1500));
|
||||
assert_eq!(adjusted.entries["000001.SZ"].completion_quantity, Some(Decimal::from(1500)));
|
||||
assert_eq!(adjusted.position_action_bases["000001.SZ"].first_execution_date, day(11));
|
||||
assert_eq!(saved.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1000));
|
||||
assert!(saved.adjust_for_split("000001.SZ", Decimal::ZERO).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
|
||||
let members = vec![member()];
|
||||
let calendar = vec![day(11), day(14)];
|
||||
let first = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &calendar, &members, &[])
|
||||
.unwrap();
|
||||
let initial = plan(&first, day(11), &members, &[], 10000, "hold");
|
||||
assert_eq!(initial.rows[0].target_quantity, 1000.into());
|
||||
let pending = first.record_plan(day(11), "same-goal", &initial).unwrap();
|
||||
assert!(pending.entries["000001.SZ"].pending);
|
||||
assert!(!pending.entries["000001.SZ"].observed_holding);
|
||||
let persisted = serde_json::to_vec(&pending).unwrap();
|
||||
let restored: StockPoolExecutionState = serde_json::from_slice(&persisted).unwrap();
|
||||
let partial = restored
|
||||
.observe(day(11), day(11), &calendar, &members, &[held(500, 0)])
|
||||
.unwrap();
|
||||
let retry = plan(&partial, day(11), &members, &[held(500, 0)], 5000, "hold");
|
||||
assert_eq!(retry.rows[0].delta_quantity, 500.into(), "{retry:?}");
|
||||
let pending = partial.record_plan(day(11), "same-goal", &retry).unwrap();
|
||||
assert!(pending.entries["000001.SZ"].pending);
|
||||
let filled = pending
|
||||
.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)])
|
||||
.unwrap();
|
||||
let satisfied = plan(&filled, day(14), &members, &[held(1000, 1000)], 0, "hold");
|
||||
assert_eq!(satisfied.rows[0].status, "PRESERVED_EXISTING_POSITION");
|
||||
let completed = filled.record_plan(day(14), "new-day", &satisfied).unwrap();
|
||||
assert!(!completed.entries["000001.SZ"].pending);
|
||||
assert_eq!(
|
||||
plan(
|
||||
&completed,
|
||||
day(14),
|
||||
&members,
|
||||
&[held(1000, 1000)],
|
||||
0,
|
||||
"hold"
|
||||
)
|
||||
.rows[0]
|
||||
.status,
|
||||
"PRESERVED_EXISTING_POSITION"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_anchor_is_not_reset_by_rechecks_weekends_or_t_plus_one() {
|
||||
let calendar = vec![day(11), day(14), day(15)];
|
||||
let positions = vec![held(1000, 1000)];
|
||||
let removed = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &calendar, &[], &positions)
|
||||
.unwrap();
|
||||
assert_eq!(removed.removed_since["000001.SZ"], day(11));
|
||||
assert_eq!(
|
||||
plan(
|
||||
&removed,
|
||||
day(11),
|
||||
&[],
|
||||
&positions,
|
||||
0,
|
||||
"reduce_next_trading_day"
|
||||
)
|
||||
.rows[0]
|
||||
.status,
|
||||
"DEFERRED_T_PLUS_ONE"
|
||||
);
|
||||
assert!(
|
||||
removed
|
||||
.observe(day(12), day(12), &calendar, &[], &positions)
|
||||
.is_err()
|
||||
);
|
||||
let restored: StockPoolExecutionState =
|
||||
serde_json::from_str(&serde_json::to_string(&removed).unwrap()).unwrap();
|
||||
let monday = restored
|
||||
.observe(day(14), day(14), &calendar, &[], &[held(1000, 0)])
|
||||
.unwrap();
|
||||
assert_eq!(monday.removed_since["000001.SZ"], day(11));
|
||||
assert_eq!(
|
||||
plan(
|
||||
&monday,
|
||||
day(14),
|
||||
&[],
|
||||
&[held(1000, 0)],
|
||||
0,
|
||||
"reduce_next_trading_day"
|
||||
)
|
||||
.rows[0]
|
||||
.delta_quantity,
|
||||
Decimal::ZERO
|
||||
);
|
||||
let next = monday
|
||||
.observe(day(15), day(15), &calendar, &[], &positions)
|
||||
.unwrap();
|
||||
let exit = plan(
|
||||
&next,
|
||||
day(15),
|
||||
&[],
|
||||
&positions,
|
||||
0,
|
||||
"reduce_next_trading_day",
|
||||
);
|
||||
assert_eq!(exit.rows[0].target_quantity, Decimal::ZERO);
|
||||
assert_eq!(exit.rows[0].side, Some(OrderSide::Sell));
|
||||
let returned = next
|
||||
.observe(day(15), day(15), &calendar, &[member()], &positions)
|
||||
.unwrap();
|
||||
assert!(returned.removed_since.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloned_preview_does_not_start_a_timer_and_next_open_uses_signal_removal_date() {
|
||||
let state = StockPoolExecutionState::default();
|
||||
let calendar = vec![day(11), day(14)];
|
||||
let preview = state
|
||||
.observe(day(11), day(14), &calendar, &[], &[held(1000, 1000)])
|
||||
.unwrap();
|
||||
assert!(state.removed_since.is_empty());
|
||||
assert!(preview.next_day_exit_symbols(day(14)).contains("000001.SZ"));
|
||||
let mut invalid = preview;
|
||||
invalid.schema_version = 0;
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
@@ -35,7 +35,7 @@ fn market(
|
||||
) -> DailyMarketSnapshot {
|
||||
DailyMarketSnapshot {
|
||||
date: d(date),
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
timestamp: None,
|
||||
day_open: open,
|
||||
open,
|
||||
@@ -66,7 +66,7 @@ fn factor(
|
||||
) -> DailyFactorSnapshot {
|
||||
DailyFactorSnapshot {
|
||||
date: d(date),
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
pe_ttm: 18.0,
|
||||
@@ -87,7 +87,7 @@ fn candidate(
|
||||
) -> CandidateEligibility {
|
||||
CandidateEligibility {
|
||||
date: d(date),
|
||||
symbol: symbol.to_string(),
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing,
|
||||
|
||||
@@ -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/`。服务均未由本任务重启,没有提交回测任务或证券订单。
|
||||
Reference in New Issue
Block a user