From 6ffa0346aab3d959f2327e1134583c5bc6460534 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 12 Sep 2026 03:55:00 +0800 Subject: [PATCH] feat(stock-pool): unify target execution, durable intent state and ETF rules --- Cargo.lock | 322 ++- Cargo.toml | 1 + crates/fidc-core/Cargo.toml | 1 + crates/fidc-core/src/broker.rs | 83 +- crates/fidc-core/src/broker_stock_pool.rs | 480 ++++ crates/fidc-core/src/cost.rs | 52 + crates/fidc-core/src/engine.rs | 4 + crates/fidc-core/src/instrument.rs | 6 + crates/fidc-core/src/lib.rs | 5 + .../fidc-core/src/platform_expr_strategy.rs | 27 +- crates/fidc-core/src/platform_stock_pool.rs | 236 ++ .../fidc-core/src/platform_strategy_spec.rs | 35 + crates/fidc-core/src/portfolio.rs | 11 + crates/fidc-core/src/risk_control.rs | 18 +- crates/fidc-core/src/stock_pool_candidates.rs | 229 ++ crates/fidc-core/src/stock_pool_execution.rs | 2463 +++++++++++++++++ .../src/stock_pool_execution_tests.rs | 1155 ++++++++ crates/fidc-core/src/stock_pool_frozen.rs | 150 + crates/fidc-core/src/stock_pool_index_cap.rs | 93 + .../fidc-core/src/stock_pool_index_policy.rs | 344 +++ crates/fidc-core/src/stock_pool_indicators.rs | 179 ++ crates/fidc-core/src/stock_pool_state.rs | 238 ++ crates/fidc-core/src/strategy.rs | 5 + .../stock_pool_disabled_stops_compiled.json | 275 ++ .../tests/stock_pool_execution_contract.rs | 632 +++++ .../tests/stock_pool_execution_state.rs | 217 ++ 26 files changed, 7223 insertions(+), 38 deletions(-) create mode 100644 crates/fidc-core/src/broker_stock_pool.rs create mode 100644 crates/fidc-core/src/platform_stock_pool.rs create mode 100644 crates/fidc-core/src/stock_pool_candidates.rs create mode 100644 crates/fidc-core/src/stock_pool_execution.rs create mode 100644 crates/fidc-core/src/stock_pool_execution_tests.rs create mode 100644 crates/fidc-core/src/stock_pool_frozen.rs create mode 100644 crates/fidc-core/src/stock_pool_index_cap.rs create mode 100644 crates/fidc-core/src/stock_pool_index_policy.rs create mode 100644 crates/fidc-core/src/stock_pool_indicators.rs create mode 100644 crates/fidc-core/src/stock_pool_state.rs create mode 100644 crates/fidc-core/tests/fixtures/stock_pool_disabled_stops_compiled.json create mode 100644 crates/fidc-core/tests/stock_pool_execution_contract.rs create mode 100644 crates/fidc-core/tests/stock_pool_execution_state.rs diff --git a/Cargo.lock b/Cargo.lock index 2aedbbf..f29ee3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -25,6 +36,12 @@ dependencies = [ "libc", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -49,6 +66,18 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -58,12 +87,58 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytes" version = "1.12.1" @@ -100,7 +175,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.1", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -239,11 +314,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" name = "fidc-core" version = "0.1.0" dependencies = [ - "ahash", + "ahash 0.8.12", "chrono", "indexmap", "rayon", "rhai", + "rust_decimal", "serde", "serde_json", "sha2", @@ -275,6 +351,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.34" @@ -353,10 +435,19 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -596,7 +687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -729,6 +820,24 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -738,6 +847,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quinn" version = "0.11.11" @@ -767,7 +896,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -815,6 +944,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.2" @@ -823,7 +969,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -838,7 +1003,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -861,6 +1026,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.24" @@ -905,7 +1079,7 @@ version = "1.23.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4e35aaaa439a5bda2f8d15251bc375e4edfac75f9865734644782c9701b5709" dependencies = [ - "ahash", + "ahash 0.8.12", "bitflags", "instant", "no-std-compat", @@ -942,6 +1116,51 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust_decimal" +version = "1.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rkyv", + "serde", + "serde_json", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -995,6 +1214,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "serde" version = "1.0.228" @@ -1067,6 +1292,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1124,6 +1355,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -1179,6 +1421,12 @@ name = "ta-lib-dispatch" version = "0.1.2" source = "git+https://github.com/TA-Lib/ta-lib.git?rev=dd5a90259a3f9e04e2da9f38bf0719a841b40108#dd5a90259a3f9e04e2da9f38bf0719a841b40108" +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "thin-vec" version = "0.2.16" @@ -1263,6 +1511,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0db3bae107c9522f86d361697dee1d7386a2ddcf659d5aea5159819a21a3c4a7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.5.3" @@ -1369,6 +1647,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1624,6 +1912,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -1636,6 +1933,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 3985559..5a8e1e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/fidc-core/Cargo.toml b/crates/fidc-core/Cargo.toml index b382da2..c685408 100644 --- a/crates/fidc-core/Cargo.toml +++ b/crates/fidc-core/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true authors.workspace = true [dependencies] +rust_decimal.workspace = true ahash.workspace = true chrono.workspace = true indexmap.workspace = true diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index bff3490..9cfa338 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -23,6 +23,9 @@ use crate::strategy::{ TargetPortfolioOrderPricing, }; +#[path="broker_stock_pool.rs"] +mod stock_pool; + #[derive(Debug, Default)] pub struct BrokerExecutionReport { pub order_events: Vec, @@ -1757,6 +1760,9 @@ where return result; } match intent { + OrderIntent::StockPool { contract } => self.process_stock_pool_contract( + date,portfolio,data,contract,intraday_turnover,execution_cursors,global_execution_cursor,commission_state,report, + ), OrderIntent::WithTimeInForce { .. } => unreachable!("wrapper handled above"), OrderIntent::Shares { symbol, @@ -3527,6 +3533,7 @@ where date, sell_execution_price, current_qty.saturating_sub(provisional_target_qty), + data.instruments().get(&symbol), ); } constraints.push(TargetConstraint { @@ -3582,6 +3589,7 @@ where date, constraint.buy_execution_price, target_qty - constraint.current_qty, + data.instruments().get(&constraint.symbol), ); } if target_qty > 0 { @@ -4165,14 +4173,14 @@ where u32::MAX } - fn estimated_sell_net_cash(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 { + fn estimated_sell_net_cash(&self, date: NaiveDate, price: f64, quantity: u32, instrument: Option<&Instrument>) -> f64 { if quantity == 0 { return 0.0; } let gross = Self::fixed_gross_amount(price, quantity); let cost = self .cost_model - .calculate(date, OrderSide::Sell, gross.to_f64()); + .calculate_for_instrument(date, OrderSide::Sell, gross.to_f64(), instrument); gross .checked_sub(cost.fixed_total()) .expect("fixed-point sell proceeds underflow") @@ -4282,14 +4290,14 @@ where } } - fn estimated_buy_cash_out(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 { + fn estimated_buy_cash_out(&self, date: NaiveDate, price: f64, quantity: u32, instrument: Option<&Instrument>) -> f64 { if quantity == 0 { return 0.0; } let gross = Self::fixed_gross_amount(price, quantity); let cost = self .cost_model - .calculate(date, OrderSide::Buy, gross.to_f64()); + .calculate_for_instrument(date, OrderSide::Buy, gross.to_f64(), instrument); gross .checked_add(cost.fixed_total()) .expect("fixed-point buy cash overflow") @@ -4328,7 +4336,7 @@ where let minimum_execution_price = self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(minimum_buy_quantity))?; Ok(Self::fixed_cash_fits( - self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity), + self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity, data.instruments().get(symbol)), portfolio.cash(), )) } @@ -4754,7 +4762,7 @@ where let execution_price = self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(fillable_qty))?; if let Some(reason) = - self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price) + self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price, data.instruments().get(symbol)) { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); (0, Vec::new(), None, Vec::new()) @@ -4775,6 +4783,7 @@ where OrderSide::Sell, execution_price, limit_price, + data.instruments().get(symbol), ) { Ok(execution_price) => ( fillable_qty, @@ -4900,12 +4909,13 @@ where let leg_cash_before = portfolio.cash(); let gross_money = Self::fixed_gross_amount(leg.price, leg.quantity); let gross_amount = gross_money.to_f64(); - let cost = self.cost_model.calculate_with_order_state( + let cost = self.cost_model.calculate_with_order_state_for_instrument( date, OrderSide::Sell, gross_amount, Some(order_id), commission_state, + data.instruments().get(symbol), ); let net_cash = gross_money .checked_sub(cost.fixed_total()) @@ -5603,6 +5613,7 @@ where price, minimum_order_quantity, order_step_size, + data.instruments().get(symbol), ); let requested_qty = self.maybe_expand_periodic_value_buy_quantity( date, @@ -5719,6 +5730,7 @@ where price, minimum_order_quantity, order_step_size, + data.instruments().get(symbol), ); let requested_qty = self.maybe_expand_periodic_value_buy_quantity( date, @@ -5887,6 +5899,7 @@ where price, minimum_order_quantity, order_step_size, + data.instruments().get(symbol), ); let requested_qty = self.maybe_expand_periodic_value_buy_quantity( date, @@ -6482,7 +6495,7 @@ where let execution_price = self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(constrained_qty))?; if let Some(reason) = - self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price) + self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price, data.instruments().get(symbol)) { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); (0, Vec::new(), None, Vec::new()) @@ -6503,6 +6516,7 @@ where OrderSide::Buy, execution_price, limit_price, + data.instruments().get(symbol), ) { Err(reason) => { partial_fill_reason = @@ -6518,6 +6532,7 @@ where constrained_qty, self.minimum_order_quantity(data, symbol), self.order_step_size(data, symbol), + data.instruments().get(symbol), ); let mut blocked_by_final_price = false; if filled_qty > 0 { @@ -6532,6 +6547,7 @@ where OrderSide::Buy, execution_price, limit_price, + data.instruments().get(symbol), ) { Ok(price) => execution_price = price, Err(reason) => { @@ -6681,12 +6697,13 @@ where let leg_cash_before = portfolio.cash(); let gross_money = Self::fixed_gross_amount(leg.price, leg.quantity); let gross_amount = gross_money.to_f64(); - let cost = self.cost_model.calculate_with_order_state( + let cost = self.cost_model.calculate_with_order_state_for_instrument( date, OrderSide::Buy, gross_amount, Some(order_id), commission_state, + data.instruments().get(symbol), ); let cash_out = gross_money .checked_add(cost.fixed_total()) @@ -7083,6 +7100,7 @@ where price: f64, minimum_order_quantity: u32, order_step_size: u32, + instrument: Option<&Instrument>, ) -> u32 { if !value_budget.is_finite() || value_budget <= 0.0 || !price.is_finite() || price <= 0.0 { return 0; @@ -7093,7 +7111,7 @@ where self.round_buy_quantity(raw_quantity, minimum_order_quantity, order_step_size); while quantity >= minimum { if Self::fixed_cash_fits( - self.estimated_buy_cash_out(date, price, quantity), + self.estimated_buy_cash_out(date, price, quantity, instrument), value_budget, ) { return quantity; @@ -7122,6 +7140,7 @@ where fallback_price, minimum_order_quantity, order_step_size, + data.instruments().get(symbol), ); for _ in 0..8 { let execution_price = snapshot @@ -7137,6 +7156,7 @@ where execution_price, minimum_order_quantity, order_step_size, + data.instruments().get(symbol), ); if resolved == quantity { return Ok(quantity); @@ -7152,7 +7172,7 @@ where .filter(|price| price.is_finite() && *price > 0.0) .unwrap_or(fallback_price); if Self::fixed_cash_fits( - self.estimated_buy_cash_out(date, execution_price, quantity), + self.estimated_buy_cash_out(date, execution_price, quantity, data.instruments().get(symbol)), value_budget, ) { return Ok(quantity); @@ -7186,6 +7206,7 @@ where requested_qty: u32, minimum_order_quantity: u32, order_step_size: u32, + instrument: Option<&Instrument>, ) -> u32 { let mut quantity = self.round_buy_quantity(requested_qty, minimum_order_quantity, order_step_size); @@ -7199,7 +7220,7 @@ where ); continue; } - let cost = self.cost_model.calculate(date, OrderSide::Buy, gross); + let cost = self.cost_model.calculate_for_instrument(date, OrderSide::Buy, gross, instrument); let cash_out = FixedMoney::checked_sum_f64([gross, cost.total()]) .expect("buy cash must be finite fixed-point money") .to_f64(); @@ -7326,6 +7347,7 @@ where snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, execution_price: f64, + instrument: Option<&Instrument>, ) -> Option<&'static str> { if !execution_price.is_finite() || execution_price <= 0.0 { return Some("invalid execution price"); @@ -7333,6 +7355,7 @@ where match side { OrderSide::Buy if self.risk_config.static_rules.reject_one_yuan_buy + && !instrument.is_some_and(Instrument::is_exchange_traded_fund) && execution_price <= 1.0 => { Some("one_yuan") @@ -7370,9 +7393,10 @@ where side: OrderSide, execution_price: f64, limit_price: Option, + instrument: Option<&Instrument>, ) -> Result { let adjusted = self.execution_price_with_limit_slippage(execution_price, limit_price); - if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, adjusted) { + if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, adjusted, instrument) { Err(reason) } else { Ok(adjusted) @@ -7460,6 +7484,7 @@ where limit_price, execution_ledger, calibration.as_ref(), + data.instruments().get(symbol), )? { return Ok(Some(fill)); } @@ -7555,6 +7580,7 @@ where limit_price, &IntradayExecutionLedger::default(), None, + None, ) .expect("test quote selection without historical calibration") } @@ -7579,6 +7605,7 @@ where limit_price: Option, execution_ledger: &IntradayExecutionLedger, calibration: Option<&HistoricalSlippageCalibration>, + instrument: Option<&Instrument>, ) -> Result, BacktestError> { if requested_qty == 0 { return Ok(None); @@ -7649,7 +7676,7 @@ where else { continue; }; - if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price) { + if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price, instrument) { execution_block_reason.get_or_insert(reason); execution_block_timestamp = Some(execution_at); continue; @@ -7743,7 +7770,7 @@ where let mut quote_price = self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?; - if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) + if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price, instrument) { execution_block_reason.get_or_insert(reason); execution_block_timestamp = Some(execution_at); @@ -7771,7 +7798,7 @@ where quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); if let Some(reason) = - self.execution_limit_rejection_reason(snapshot, side, quote_price) + self.execution_limit_rejection_reason(snapshot, side, quote_price, instrument) { execution_block_reason.get_or_insert(reason); execution_block_timestamp = Some(execution_at); @@ -7792,7 +7819,7 @@ where } let candidate_cost = self .cost_model - .calculate(snapshot.date, OrderSide::Buy, candidate_gross) + .calculate_for_instrument(snapshot.date, OrderSide::Buy, candidate_gross, instrument) .total(); let candidate_cash = FixedMoney::checked_sum_f64([candidate_gross, candidate_cost]) @@ -7816,7 +7843,7 @@ where quote_price = self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?; quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); - if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) + if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price, instrument) { execution_block_reason.get_or_insert(reason); execution_block_timestamp = Some(execution_at); @@ -8755,19 +8782,19 @@ mod tests { ).unwrap(); assert_eq!(blocked.quantity, 0); assert_eq!(blocked.unfilled_reason, Some("one_yuan")); - assert_eq!(slipped.execution_price_with_limit_slippage_or_rejection(&snapshot, OrderSide::Buy, 1.0, None), Err("one_yuan")); + assert_eq!(slipped.execution_price_with_limit_slippage_or_rejection(&snapshot, OrderSide::Buy, 1.0, None, None), Err("one_yuan")); let limit_broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_slippage_model(SlippageModel::LimitPrice); assert_eq!(limit_broker.execution_price_with_limit_slippage_or_rejection( - &snapshot, OrderSide::Buy, 1.2, Some(0.9)), Err("one_yuan")); + &snapshot, OrderSide::Buy, 1.2, Some(0.9), None), Err("one_yuan")); let mut risk = FidcRiskControlConfig::default(); risk.static_rules.reject_one_yuan_buy = false; let allowed = limit_broker.with_risk_config(risk); assert_eq!(allowed.execution_price_with_limit_slippage_or_rejection( - &snapshot, OrderSide::Buy, 1.2, Some(0.9)), Ok(0.9)); - assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Buy, f64::NAN), Some("invalid execution price")); - assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Sell, 0.9), None); + &snapshot, OrderSide::Buy, 1.2, Some(0.9), None), Ok(0.9)); + assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Buy, f64::NAN, None), Some("invalid execution price")); + assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Sell, 0.9, None), None); } #[test] @@ -9998,9 +10025,9 @@ mod tests { let allocated_amount = 50_000.0; assert_eq!(quantity, 4_900); - assert!(broker.estimated_buy_cash_out(date, execution_price, quantity) <= allocated_amount); + assert!(broker.estimated_buy_cash_out(date, execution_price, quantity, None) <= allocated_amount); assert!( - broker.estimated_buy_cash_out(date, execution_price, quantity + 100) > allocated_amount + broker.estimated_buy_cash_out(date, execution_price, quantity + 100, None) > allocated_amount ); } @@ -11585,7 +11612,7 @@ mod tests { let clock = date.and_hms_opt(9,33,0).unwrap(); let first = broker.select_execution_fill_with_ledger( &snapshot.symbol,&snapshot,"es,OrderSide::Buy,MatchingType::MinuteLast, - Some(clock),Some(clock),200,100,100,100,false,None,None,None,&ledger,None, + Some(clock),Some(clock),200,100,100,100,false,None,None,None,&ledger,None,None, ).unwrap().unwrap(); assert_eq!(first.quantity,200); assert_eq!(first.legs[0].execution_timestamp,Some(clock)); @@ -11594,7 +11621,7 @@ mod tests { let later = clock + chrono::Duration::seconds(1); let second = broker.select_execution_fill_with_ledger( &snapshot.symbol,&snapshot,"es,OrderSide::Sell,MatchingType::MinuteLast, - Some(later),Some(later),50,100,100,100,true,None,None,None,&ledger,None, + Some(later),Some(later),50,100,100,100,true,None,None,None,&ledger,None,None, ).unwrap().unwrap(); assert_eq!(second.quantity,50); assert_eq!(second.legs[0].execution_timestamp,Some(later)); @@ -11602,7 +11629,7 @@ mod tests { assert_eq!(ledger.volume_consumed(&snapshot.symbol,quotes[0].timestamp),250); let third = broker.select_execution_fill_with_ledger( &snapshot.symbol,&snapshot,"es,OrderSide::Buy,MatchingType::MinuteLast, - Some(later),Some(later),100,100,100,100,false,None,None,None,&ledger,None, + Some(later),Some(later),100,100,100,100,false,None,None,None,&ledger,None,None, ).unwrap(); assert!(third.is_none()); } diff --git a/crates/fidc-core/src/broker_stock_pool.rs b/crates/fidc-core/src/broker_stock_pool.rs new file mode 100644 index 0000000..c17bf3c --- /dev/null +++ b/crates/fidc-core/src/broker_stock_pool.rs @@ -0,0 +1,480 @@ +//! Executes one frozen pool intent against real broker-simulator state. +use super::*; +use crate::holding_policy::HoldingLifecycleEvidence; +use crate::stock_pool_execution as pool; +use rust_decimal::{Decimal, prelude::ToPrimitive}; + +fn decimal(value: f64, label: &str) -> Result { + if !value.is_finite() { + return Err(BacktestError::Execution(format!( + "stock_pool_nonfinite_{label}" + ))); + } + value + .to_string() + .parse() + .map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}"))) +} + +fn pool_positions( + portfolio: &PortfolioState, + date: NaiveDate, +) -> Result, 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 BrokerSimulator { + fn pool_quote_inputs( + &self, + date: NaiveDate, + data: &DataSet, + symbols: &BTreeSet, + execution_clock: Option, + ) -> Result, BacktestError> { + symbols + .iter() + .map(|symbol| { + let snapshot = data.market(date, symbol).ok_or_else(|| { + BacktestError::Execution(format!( + "stock_pool_execution_snapshot_missing:{symbol}:{date}" + )) + })?; + let instrument = data.instruments().get(symbol).ok_or_else(|| { + BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}")) + })?; + let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if self + .matching_type_uses_intraday_quotes() + { + let time = self + .runtime_intraday_start_time + .get() + .or(self.intraday_execution_start_time) + .ok_or_else(|| { + BacktestError::Execution( + "stock_pool_intraday_execution_clock_required".into(), + ) + })?; + let clock = execution_clock + .unwrap_or(date.and_time(time)) + .max(date.and_time(time)); + let quote = data + .execution_quotes_on(date, symbol) + .iter() + .rev() + .find(|quote| quote.timestamp <= clock) + .ok_or_else(|| { + BacktestError::Execution(format!( + "stock_pool_execution_quote_missing:{symbol}:{clock}" + )) + })?; + if !quote.last_price.is_finite() || quote.last_price <= 0.0 { + return Err(BacktestError::Execution(format!( + "stock_pool_execution_quote_invalid:{symbol}:{clock}" + ))); + } + let raw_buy = self + .select_quote_reference_price( + snapshot, + quote, + OrderSide::Buy, + self.matching_type, + ) + .ok_or_else(|| { + BacktestError::Execution(format!( + "stock_pool_buy_reference_missing:{symbol}:{clock}" + )) + })?; + let raw_sell = self + .select_quote_reference_price( + snapshot, + quote, + OrderSide::Sell, + self.matching_type, + ) + .ok_or_else(|| { + BacktestError::Execution(format!( + "stock_pool_sell_reference_missing:{symbol}:{clock}" + )) + })?; + let calibration = self.slippage_calibration(data, snapshot)?; + let buy = self.quote_execution_price( + snapshot, + OrderSide::Buy, + raw_buy, + None, + calibration.as_ref(), + )?; + let sell = self.quote_execution_price( + snapshot, + OrderSide::Sell, + raw_sell, + None, + calibration.as_ref(), + )?; + ( + quote.last_price, + snapshot.prev_close, + Some(quote.volume_delta as f64), + Some(quote.amount_delta), + Some(quote.bid1), + Some(quote.ask1), + buy, + sell, + ) + } else { + let price = snapshot.price(self.effective_execution_price_field(date)); + if !price.is_finite() || price <= 0.0 { + return Err(BacktestError::Execution(format!( + "stock_pool_execution_price_missing:{symbol}:{date}" + ))); + } + // A daily open does not reveal the session's volume/turnover. + let completed = self.effective_execution_price_field(date) == PriceField::Close; + ( + price, + snapshot.prev_close, + completed.then_some(snapshot.volume as f64), + None, + Some(price), + Some(price), + self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?, + self.snapshot_execution_price(data, snapshot, OrderSide::Sell, None)?, + ) + }; + Ok(pool::MarketSnapshot { + symbol: symbol.clone(), + last_price: decimal(price, "price")?, + prev_close: Some(decimal(prev, "prev_close")?), + volume: volume.map(|v| decimal(v, "volume")).transpose()?, + turnover: amount.map(|v| decimal(v, "amount")).transpose()?, + bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?, + ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?, + is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")), + instrument_rules: Some(pool::StockPoolInstrumentRules { + price_tick: decimal(snapshot.price_tick, "price_tick")?, + quantity_step: instrument.order_step_size().into(), + minimum_buy_quantity: instrument.minimum_order_quantity().into(), + }), + buy_sizing_price: Some(decimal(buy_price, "buy_price")?), + sell_sizing_price: Some(decimal(sell_price, "sell_price")?), + }) + }) + .collect() + } + + pub(super) fn process_stock_pool_contract( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + contract: &pool::FrozenStockPoolIntent, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, + global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, + 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::>(); + scope.extend(portfolio.positions().keys().cloned()); + let official_dates = data.calendar().iter().collect::>(); + let initial_positions = pool_positions(portfolio, date)?; + let state = portfolio + .stock_pool_execution_state(&contract.pool_id) + .observe( + contract.signal_date, + date, + &official_dates, + &members, + &initial_positions, + ) + .map_err(BacktestError::Execution)?; + portfolio + .set_stock_pool_execution_state(&contract.pool_id, state) + .map_err(BacktestError::Execution)?; + if self.has_open_orders() { + report + .diagnostics + .push("stock_pool_waiting_for_active_orders no_new_intent=true".into()); + return Ok(()); + } + let mut constraints = contract.constraints.clone(); + constraints.execution_date = Some(date); + constraints.frozen_positions.clear(); + let mut quote_scope = scope.clone(); + for symbol in &scope { + let paused = data.market(date, symbol).is_some_and(|row| row.paused) + || data + .candidate(date, symbol) + .is_some_and(|row| row.is_paused); + if !paused { + continue; + } + quote_scope.remove(symbol); + if let Some(position) = portfolio + .position(symbol) + .filter(|position| position.quantity > 0) + { + constraints.frozen_positions.insert( + symbol.clone(), + pool::FrozenStockPoolPosition { + trade_date: date, + reason: "paused".into(), + valuation_price: decimal(position.last_price, "paused_holding_valuation")?, + }, + ); + } else { + selection + .normal_trading_symbols + .retain(|item| item != symbol); + selection + .risk_eligible_symbols + .retain(|item| item != symbol); + selection.final_symbols.retain(|item| item != symbol); + selection + .exclusion_reasons + .entry(symbol.clone()) + .or_default() + .push("paused".into()); + } + } + let before_positions = portfolio + .positions() + .keys() + .cloned() + .collect::>(); + for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] { + 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.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date); + let account = pool::AccountSnapshot { + total_equity: contract.frozen_equity, + cash: decimal(portfolio.cash(), "cash")?, + frozen_cash: Decimal::ZERO, + }; + constraints.automatic_permissions.clear(); + if contract.rule.automatic_trade_protection.enabled() { + for symbol in &scope { + let position = portfolio.position(symbol).filter(|p| p.quantity > 0); + let sold = self + .same_day_sold_symbols + .borrow() + .iter() + .rev() + .find(|(day, symbols)| **day <= date && symbols.contains(symbol)) + .map(|(day, _)| *day); + let evidence = HoldingLifecycleEvidence { + has_position: position.is_some(), + opened_date: position.and_then(|p| p.opened_date()), + last_buy_date: position.and_then(|p| p.last_buy_date()), + last_sell_date: sold, + }; + let permission = contract + .rule + .automatic_trade_protection + .evaluate(symbol, date, &evidence, data.calendar()) + .map_err(BacktestError::Execution)?; + constraints + .automatic_permissions + .insert(symbol.clone(), permission); + } + } + if self + .risk_config + .static_rules + .forbid_same_day_rebuy_after_sell + { + constraints.same_day_sold_symbols.extend( + self.same_day_sold_symbols + .borrow() + .get(&date) + .into_iter() + .flatten() + .cloned(), + ); + } + constraints.same_day_sold_symbols.extend( + before_positions + .iter() + .filter(|symbol| portfolio.position(symbol).is_none_or(|p| p.quantity == 0)) + .cloned(), + ); + let fee = + |symbol: &str, side: pool::OrderSide, gross: Decimal| -> Result { + 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 updated = execution_state + .record_plan(contract.signal_date, &contract.generation, &plan) + .map_err(BacktestError::Execution)?; + portfolio + .set_stock_pool_execution_state(&contract.pool_id, updated) + .map_err(BacktestError::Execution)?; + report.diagnostics.push(format!("stock_pool_plan phase={side:?} generation={} requested_bps={} effective_bps={} budget={}",contract.generation,plan.requested_invest_ratio_bps,plan.effective_invest_ratio_bps,plan.budget)); + let max_positions = constraints + .target_holding_count + .unwrap_or(selection.final_symbols.len()); + for row in plan.rows { + if side == pool::OrderSide::Buy && row.side.is_none() { + report.diagnostics.push(format!( + "stock_pool_decision symbol={} status={} current={} target={} reason={}", + row.symbol, + row.status, + row.current_quantity, + row.target_quantity, + row.reason + )); + } + if row.side != Some(side) { + continue; + } + if side == pool::OrderSide::Buy + && portfolio + .position(&row.symbol) + .is_none_or(|p| p.quantity == 0) + && Self::positive_position_count(portfolio) >= max_positions + { + report.diagnostics.push(format!( + "stock_pool_buy_deferred symbol={} reason=occupied_position_slots", + row.symbol + )); + continue; + } + let target = row.target_quantity.to_i32().ok_or_else(|| { + BacktestError::Execution("stock_pool_target_quantity_out_of_range".into()) + })?; + let reason = row.source_intent.as_deref().unwrap_or("stock_pool_target"); + if let Some(price) = row.limit_price { + self.process_limit_target_shares( + date, + portfolio, + data, + &row.symbol, + target, + price.to_f64().ok_or_else(|| { + BacktestError::Execution("stock_pool_limit_price_out_of_range".into()) + })?, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + )?; + } else { + self.process_target_shares( + date, + portfolio, + data, + &row.symbol, + target, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + )?; + } + } + } + Ok(()) + } +} diff --git a/crates/fidc-core/src/cost.rs b/crates/fidc-core/src/cost.rs index e95e135..96c3f10 100644 --- a/crates/fidc-core/src/cost.rs +++ b/crates/fidc-core/src/cost.rs @@ -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, commission_state: &mut BTreeMap, _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, commission_state: &mut BTreeMap, 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(); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index e65d0c5..01574bf 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -4097,6 +4097,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, .. } diff --git a/crates/fidc-core/src/instrument.rs b/crates/fidc-core/src/instrument.rs index feede4f..909e3ce 100644 --- a/crates/fidc-core/src/instrument.rs +++ b/crates/fidc-core/src/instrument.rs @@ -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) } diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 4cad045..d0471ca 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -29,6 +29,11 @@ pub mod rules; pub mod scheduler; pub mod strategy; pub mod holding_policy; +pub mod stock_pool_candidates; +pub mod stock_pool_indicators; +pub mod stock_pool_execution; +pub mod stock_pool_index_policy; +pub mod stock_pool_state; pub mod signal_contract; pub mod strategy_ai; pub mod universe; diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 7ced0b7..c2ed853 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -29,6 +29,9 @@ use crate::numeric_expr_vm::{ }; use crate::portfolio::PortfolioState; use crate::holding_policy::{AutomaticTradeProtection, AutomaticTradePermission, HoldingLifecycleEvidence}; + +#[path="platform_stock_pool.rs"] +mod stock_pool; use crate::portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossState}; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit}; use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler}; @@ -248,7 +251,7 @@ fn ordered_weight_bps_from_scales( Ok(weights) } -fn replenish_target_weight_bps( +pub(crate) fn replenish_target_weight_bps( original_weights: &[(String, u32)], candidate_symbols: &[String], excluded_symbols: &BTreeSet, @@ -625,6 +628,7 @@ pub struct PlatformPositionTargetRule { #[derive(Debug, Clone)] pub struct PlatformExprStrategyConfig { + pub stock_pool:Option, pub signal_book: Option>, pub strategy_name: String, pub market: String, @@ -713,6 +717,7 @@ impl PlatformExprStrategyConfig { pub fn generic() -> Self { Self { signal_book: None, + stock_pool:None, strategy_name: "platform-expression".to_string(), market: "CN_A".to_string(), benchmark_symbol: String::new(), @@ -2246,6 +2251,7 @@ impl PlatformExprStrategy { | "benchmark_open" | "has_dynamic_universe" | "dynamic_universe_count" + | "pool_candidate_count" | "has_subscriptions" | "subscription_count" | "subscription_guard_required" @@ -5092,6 +5098,7 @@ impl PlatformExprStrategy { ctx.dynamic_universe_count() as i64, ); scope.push("has_subscriptions", ctx.has_subscriptions()); + scope.push("pool_candidate_count", self.frozen_candidate_count(ctx) as i64); scope.push("subscription_count", ctx.subscription_count() as i64); scope.push( "subscription_guard_required", @@ -5264,6 +5271,7 @@ impl PlatformExprStrategy { "has_dynamic_universe".into(), Dynamic::from(ctx.has_dynamic_universe()), ); + day_factors.insert("pool_candidate_count".into(), Dynamic::from(self.frozen_candidate_count(ctx) as i64)); day_factors.insert( "dynamic_universe_count".into(), Dynamic::from(ctx.dynamic_universe_count() as i64), @@ -6022,6 +6030,7 @@ impl PlatformExprStrategy { } "has_dynamic_universe" => boolean(ctx.has_dynamic_universe()), "dynamic_universe_count" => integer(ctx.dynamic_universe_count() as i64), + "pool_candidate_count" => integer(self.frozen_candidate_count(ctx) as i64), "has_subscriptions" => boolean(ctx.has_subscriptions()), "subscription_count" => integer(ctx.subscription_count() as i64), "subscription_guard_required" => boolean(self.config.subscription_guard_required), @@ -8624,12 +8633,27 @@ impl PlatformExprStrategy { Ok((low.min(high), low.max(high))) } + fn frozen_candidate_count(&self, ctx: &StrategyContext<'_>) -> usize { + if !self.config.candidate_symbols_by_date.is_empty() { + self.config.candidate_symbols_by_date.get(&ctx.decision_date).map_or(0, BTreeSet::len) + } else if ctx.has_dynamic_universe() { + ctx.dynamic_universe_count() + } else if let Some(symbols) = &self.config.universe_include { + symbols.len() + } else { + ctx.data.daily_snapshot_view(ctx.decision_date).factor_symbol_ids().len() + } + } + fn selection_limit( &self, ctx: &StrategyContext<'_>, day: &DayExpressionState, ) -> Result { let value = self.eval_float(ctx, &self.config.selection_limit_expr, day, None, None)?; + if !value.is_finite() || value < 0.0 { + return Err(BacktestError::Execution("selection limit must be finite and non-negative".into())); + } Ok(value.round().max(1.0) as usize) } @@ -12714,6 +12738,7 @@ impl PlatformExprStrategy { { return Ok(StrategyDecision::default()); } + if self.config.stock_pool.is_some(){return self.stock_pool_decision(ctx)} let execution_date = ctx.execution_date; let decision_date = ctx.decision_date; let defer_execution_risk = ctx.is_lagged_execution(); diff --git a/crates/fidc-core/src/platform_stock_pool.rs b/crates/fidc-core/src/platform_stock_pool.rs new file mode 100644 index 0000000..052994a --- /dev/null +++ b/crates/fidc-core/src/platform_stock_pool.rs @@ -0,0 +1,236 @@ +//! Stock pools emit one frozen framework intent, not a lossy code-strategy translation. +use super::*; +use crate::stock_pool_execution as pool; +use rust_decimal::Decimal; + +impl PlatformExprStrategy { + pub(super) fn stock_pool_decision( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + let program = self + .config + .stock_pool + .as_ref() + .ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))? + .clone(); + let mut constraints = pool::stock_pool_constraints_from_configuration( + &program.allocation_policy, + &program.stop_take_policy, + ) + .map_err(BacktestError::Execution)?; + if let Some(policy) = constraints + .market_timing_policy + .as_ref() + .filter(|policy| policy.enabled) + { + let before_close = !ctx.is_lagged_execution() + && ctx + .active_datetime + .is_some_and(|at| at.time() < NaiveTime::from_hms_opt(15, 0, 0).unwrap()); + let as_of = if before_close { + ctx.data + .previous_trading_date(ctx.decision_date, 1) + .ok_or_else(|| { + BacktestError::Execution( + "market_timing_previous_completed_session_missing".into(), + ) + })? + } else { + ctx.decision_date + }; + let required = policy + .required_history() + .map_err(BacktestError::Execution)?; + let mut dates = ctx + .data + .calendar() + .iter() + .filter(|date| *date <= as_of) + .collect::>(); + 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::, BacktestError>>()?; + constraints.market_timing_input = + Some(crate::stock_pool_index_policy::MarketTimingInput { + index_code: index.clone(), + as_of_date: as_of, + official_dates: dates, + closes, + }); + } + let rule = pool::normalize_stock_pool_execution_rule( + Some(&program.timing_policy), + !self.config.buy_filter_expr.trim().is_empty(), + !self.config.stop_loss_expr.trim().is_empty() + || !self.config.take_profit_expr.trim().is_empty() + || !self.config.position_target_rules.is_empty(), + ) + .map_err(BacktestError::Execution)?; + if self.config.in_skip_window(ctx.decision_date) { + return Ok(StrategyDecision::default()); + } + let day = self.day_state(ctx, ctx.decision_date)?; + let (market_date, universe_date, factor_date) = self.selection_dates(ctx); + let (low, high) = self.market_cap_band(ctx, &day)?; + let (ranked, mut diagnostics, risk_decisions) = self.select_symbols( + ctx, + market_date, + universe_date, + factor_date, + &day, + low, + high, + usize::MAX, + )?; + let held = ctx + .portfolio + .positions() + .values() + .filter(|p| p.quantity > 0) + .map(|p| p.symbol.clone()) + .collect::>(); + if !self.config.buy_filter_expr.trim().is_empty() { + for symbol in &ranked { + let stock = + self.stock_state_with_factor_date(ctx, market_date, factor_date, symbol)?; + if !self.eval_bool(ctx, &self.config.buy_filter_expr, &day, Some(&stock), None)? { + constraints + .buy_denials + .insert(symbol.clone(), vec!["frozen_buy_condition_not_met".into()]); + } + } + } + let native_exits = self.current_stop_take_exit_symbols(ctx, ctx.decision_date, &day)?; + for symbol in native_exits { + constraints.position_target_bps.insert(symbol, 0); + } + for (symbol, (bps, _)) in + self.current_position_target_rules(ctx, ctx.decision_date, factor_date, &day)? + { + constraints + .position_target_bps + .entry(symbol) + .and_modify(|old| *old = (*old).min(bps)) + .or_insert(bps); + } + let limit = constraints.target_holding_count.unwrap_or(ranked.len()); + let final_symbols = ranked + .iter() + .filter(|symbol| !constraints.position_target_bps.contains_key(*symbol)) + .take(limit) + .cloned() + .collect(); + let generation = format!( + "stock-pool:{}:{}:{}", + program.pool_id, + program.version_id, + ctx.active_datetime + .map(|date| date.to_string()) + .unwrap_or_else(|| ctx.decision_date.to_string()) + ); + let selection = pool::StockPoolSelection { + trade_date: ctx.decision_date, + requested_symbols: ranked.clone(), + normal_trading_symbols: ranked.clone(), + risk_eligible_symbols: ranked.clone(), + final_symbols, + exclusion_reasons: BTreeMap::new(), + inherited_from_generation: None, + explicit_empty: program.members.is_empty() + && self.config.candidate_symbols_by_date.is_empty(), + generation: Some(generation.clone()), + }; + let by_symbol = program + .members + .iter() + .map(|member| (member.symbol.as_str(), member)) + .collect::>(); + 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::() + .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() + }) + } +} diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index cd7284b..733bb4a 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -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, #[serde(default)] pub signal_book: Option, #[serde(default)] @@ -914,6 +916,8 @@ pub struct StrategyExpressionSelectionConfig { pub candidate_symbols_by_date: BTreeMap>, #[serde(default, alias = "preserve_candidate_order")] pub preserve_candidate_order: bool, + #[serde(default, alias = "candidate_source_book")] + pub candidate_source_book: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -1652,6 +1656,7 @@ fn apply_execution_behavior_overrides( return Err("strictValueBudget=false is not supported".to_string()); } cfg.strict_value_budget = true; + if let Some(rate) = sell_then_buy_delay_slippage_rate { if !rate.is_finite() || !(0.0..1.0).contains(&rate) { return Err( @@ -2138,6 +2143,19 @@ pub fn platform_expr_config_from_spec( if selection.preserve_candidate_order && selection.candidate_symbols_by_date.is_empty() { return Err("preserveCandidateOrder requires a dated candidate book".to_string()); } + if let Some(book) = &selection.candidate_source_book { + if !selection.preserve_candidate_order { + return Err("candidateSourceBook requires preserveCandidateOrder=true".into()); + } + let expected = book.resolved_symbols()?.into_iter() + .map(|(date, symbols)| (date.to_string(), symbols)).collect::>(); + if expected != selection.candidate_symbols_by_date { + return Err("candidateSourceBook differs from resolved candidateSymbolsByDate".into()); + } + if cfg.selection_limit_expr.trim() == "pool_candidate_count" { + cfg.max_positions = expected.values().map(Vec::len).max().unwrap_or(0).max(1); + } + } for (raw_date, raw_symbols) in &selection.candidate_symbols_by_date { let trade_date = NaiveDate::parse_from_str(raw_date, "%Y-%m-%d").map_err(|_| { format!("candidateSymbolsByDate contains invalid date: {raw_date}") @@ -2636,6 +2654,12 @@ pub fn platform_expr_config_from_spec( } cfg.strict_value_budget = true; + if spec.runtime_expressions.as_ref().and_then(|runtime| runtime.selection.as_ref()) + .is_some_and(|selection| selection.candidate_source_book.is_some()) + && (cfg.matching_type != MatchingType::NextBarOpen || !cfg.current_day_precomputed_factors) { + return Err("daily candidate source book requires completed signal-day factors and next_bar_open".into()); + } + let signal_book = match (&spec.signal_book,&spec.signal_book_ref) { (Some(_),Some(_)) => return Err("inline_and_registered_signal_book_are_mutually_exclusive".into()), (Some(raw),None) => Some(std::sync::Arc::new(raw.clone().validate()?)), @@ -2670,6 +2694,17 @@ pub fn platform_expr_config_from_spec( } cfg.max_holding_days = (limit > 0).then_some(limit); } + if let Some(pool)=&spec.stock_pool { + if cfg.signal_book.is_some() || spec.signal_book_ref.is_some() || !cfg.explicit_actions.is_empty(){return Err("stock_pool_program_cannot_mix_other_order_programs".into())} + let secondary_buy=!cfg.buy_filter_expr.trim().is_empty(); + let secondary_sell=spec.runtime_expressions.as_ref().and_then(|runtime|runtime.risk.as_ref()).is_some_and(|risk|risk.stop_loss_expr.is_some()||risk.take_profit_expr.is_some()) || !cfg.position_target_rules.is_empty(); + pool.validate(secondary_buy,secondary_sell)?; + cfg.stock_pool=Some(pool.clone()); + cfg.hold_until_exit_enabled=false; + cfg.daily_top_up_enabled=false; + cfg.daily_position_target_adjust_enabled=false; + cfg.target_portfolio_daily_enabled=false; + } Ok(cfg) } diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index e21befd..5e552ce 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -676,6 +676,7 @@ pub struct PortfolioState { cash_receivables: Vec, pending_cash_flows: Vec, day_sold_symbols: BTreeSet, + stock_pool_states: std::collections::BTreeMap, } #[derive(Debug, Clone)] @@ -712,6 +713,7 @@ impl PortfolioState { cash_receivables: Vec::new(), pending_cash_flows: Vec::new(), day_sold_symbols: BTreeSet::new(), + stock_pool_states: std::collections::BTreeMap::new(), } } @@ -721,6 +723,15 @@ impl PortfolioState { self.initial_cash.to_f64() } + pub(crate) fn stock_pool_execution_state(&self,pool_id:&str)->crate::stock_pool_state::StockPoolExecutionState{ + self.stock_pool_states.get(pool_id).cloned().unwrap_or_default() + } + + pub(crate) fn set_stock_pool_execution_state(&mut self,pool_id:&str,state:crate::stock_pool_state::StockPoolExecutionState)->Result<(),String>{ + if pool_id.trim().is_empty(){return Err("stock_pool_state_identity_missing".into())} + state.validate()?;self.stock_pool_states.insert(pool_id.into(),state);Ok(()) + } + pub fn initial_cash(&self) -> f64 { self.initial_cash.to_f64() } diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 0a058f4..0cbf474 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -418,6 +418,7 @@ impl ChinaAShareRiskControl { RiskCheckScope::Sell => false, }; if reject_one_yuan + && !instrument.is_some_and(Instrument::is_exchange_traded_fund) && (candidate.is_one_yuan || (market.day_open.is_finite() && market.day_open > 0.0 && market.day_open <= 1.0)) { @@ -492,7 +493,8 @@ impl ChinaAShareRiskControl { } // Daily candidate flags can describe the later close. Execution // price constraints must use this order's actual pricing clock. - if config.static_rules.reject_one_yuan_buy && check_price <= 1.0 { + if config.static_rules.reject_one_yuan_buy && check_price <= 1.0 + && !instrument.is_some_and(Instrument::is_exchange_traded_fund) { return Some("one_yuan"); } if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy { @@ -936,6 +938,20 @@ mod tests { day, &candidate, &snapshot, None, 0.9, &relaxed), None); } + #[test] + fn fund_identity_excludes_stock_one_yuan_rule_but_not_actual_price_and_pause_checks() { + let day=d(2025,2,6); + let mut candidate=candidate(day); + let mut snapshot=market(day,1.2,0.5); + snapshot.lower_limit=0.01;snapshot.upper_limit=10.; + let instrument=Instrument{symbol:candidate.symbol.clone(),name:"fixture fund".into(),board:"ETF".into(),round_lot:100,listed_at:Some(d(2024,1,2)),delisted_at:None,status:"active".into()}; + let config=FidcRiskControlConfig::default(); + assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),None); + assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.,&config),Some("invalid execution price")); + candidate.is_paused=true;snapshot.paused=true; + assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),Some("paused")); + } + #[test] fn execution_quote_covers_missing_one_yuan_flag_but_not_other_risk_facts() { let day = d(2025, 2, 6); diff --git a/crates/fidc-core/src/stock_pool_candidates.rs b/crates/fidc-core/src/stock_pool_candidates.rs new file mode 100644 index 0000000..ba6d090 --- /dev/null +++ b/crates/fidc-core/src/stock_pool_candidates.rs @@ -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, +} + +#[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, 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, 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::>(); + let auto_set = automatic.iter().cloned().collect::>(); + 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::>(); + union.extend(second.iter().filter(|symbol| seen.insert((*symbol).clone())).cloned()); + union + } + }; + if policy.priority == CandidateSourcePriority::ListOrder { + let present = ordered.iter().cloned().collect::>(); + let prefix = symbols(&policy.merged_order, "candidate_sources.merged_order")? + .into_iter().filter(|symbol| present.contains(symbol)).collect::>(); + let selected = prefix.iter().cloned().collect::>(); + 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, + pub automatic_symbols_by_date: BTreeMap>, + pub source_snapshot_sha256: String, + pub source_coverage_sha256: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_symbols: Option>, +} + +impl CandidateSourceBook { + pub fn resolve(&self) -> Result>, 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::>())).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>, 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 { 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::>(), 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::>(), 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()); + } +} diff --git a/crates/fidc-core/src/stock_pool_execution.rs b/crates/fidc-core/src/stock_pool_execution.rs new file mode 100644 index 0000000..9a2f329 --- /dev/null +++ b/crates/fidc-core/src/stock_pool_execution.rs @@ -0,0 +1,2463 @@ +//! Deterministic stock-pool target planning shared by historical and online execution. +//! Account, quote and fill facts are supplied by the caller; this module has no I/O. +use chrono::NaiveDate; +use rust_decimal::{Decimal, RoundingStrategy}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +pub const STOCK_POOL_SCHEMA_VERSION: u32 = 1; + +pub const POOL_TRIGGER_FIRST_TICK: &str = "first_tick_after_open"; +pub const POOL_TRIGGER_TIME_WINDOW: &str = "time_window"; +pub const POOL_TRIGGER_CONDITION: &str = "condition"; +pub const POOL_TRIGGER_SCHEDULED_BAR: &str = "scheduled_bar"; + +pub const POOL_PRICE_FIRST_TICK: &str = "first_tick"; +pub const POOL_PRICE_OPENING_AUCTION: &str = "opening_auction"; +pub const POOL_PRICE_FIXED_LIMIT: &str = "fixed_limit"; +pub const POOL_PRICE_FORMULA_LIMIT: &str = "formula_limit"; +pub const POOL_PRICE_CONDITION_THEN_LIMIT: &str = "condition_then_limit"; +pub const POOL_PRICE_CONDITION_THEN_MARKET: &str = "condition_then_market"; + +pub const POOL_SELL_TARGET_DELTA: &str = "target_delta"; +pub const POOL_SELL_CONDITION: &str = "condition"; +pub const STOCK_POOL_CURRENT_SNAPSHOT_TOLERANCE_SECONDS: u64 = 120; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OrderSide { + Buy, + Sell, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum QuoteConditionScope { + #[default] + PerSymbol, + AllTargets, + AnyTarget, +} + +pub fn stock_pool_target_holding_count(policy: &Value) -> Result, String> { + let object = policy + .as_object() + .ok_or("allocation_policy must be an object")?; + let mut observed: Option> = None; + for key in ["target_holding_count", "targetHoldingCount"] { + let Some(raw) = object.get(key) else { continue }; + let count = if raw.is_null() || raw.as_str().is_some_and(|value| value.trim().is_empty()) { + None + } else { + let text = match raw { + Value::String(value) => value.trim().to_owned(), + Value::Number(value) => value.to_string(), + _ => return Err("allocation_policy.target_holding_count must be an integer".into()), + }; + let value = text + .parse::() + .map_err(|_| "allocation_policy.target_holding_count must be an integer")?; + if value.fract() != Decimal::ZERO + || value < Decimal::ONE + || value > Decimal::from(10000) + { + return Err( + "allocation_policy.target_holding_count must be between 1 and 10000".into(), + ); + } + Some( + value + .normalize() + .to_string() + .parse::() + .map_err(|_| "allocation_policy.target_holding_count must be an integer")?, + ) + }; + if observed.is_some_and(|previous| previous != count) { + return Err("allocation_policy.target_holding_count aliases conflict".into()); + } + observed = Some(count); + } + Ok(observed.flatten()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum MembershipPolicy { + #[default] + FollowCandidates, + RetainHoldings, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StockPoolPortfolioPolicy { + pub schema_version: u32, + pub membership: MembershipPolicy, + pub rebalance_weights: bool, +} + +impl StockPoolPortfolioPolicy { + pub fn from_legacy(value: &str) -> Result { + let (membership, rebalance_weights) = match value { + "full_rebalance" => (MembershipPolicy::FollowCandidates, true), + "preserve_existing" => (MembershipPolicy::FollowCandidates, false), + "preserve_members" => (MembershipPolicy::RetainHoldings, true), + _ => return Err(format!("unsupported top_n_rebalance_policy={value}")), + }; + Ok(Self { + schema_version: 1, + membership, + rebalance_weights, + }) + } + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != 1 { + return Err("stock pool portfolio policy schema_version must be 1".into()); + } + Ok(()) + } +} + +pub fn stock_pool_funding_from_configuration(allocation: &Value) -> Result<(i32, Decimal), String> { + let empty = serde_json::Map::new(); + let object = if allocation.is_null() { + &empty + } else { + allocation + .as_object() + .ok_or("allocation_policy must be an object")? + }; + let number = |keys: &[&str]| -> Result, String> { + let mut result: Option> = None; + for key in keys { + if let Some(value) = object.get(*key) { + let next = if value.is_null() || value.as_str().is_some_and(|v| v.trim().is_empty()) + { + None + } else { + let raw = match value { + Value::Number(n) => n.to_string(), + Value::String(v) => v.trim().to_owned(), + _ => return Err(format!("invalid numeric allocation field:{key}")), + }; + Some( + raw.parse::() + .map_err(|_| format!("invalid numeric allocation field:{key}"))?, + ) + }; + if result.is_some_and(|old| old != next) { + return Err(format!("allocation aliases conflict:{}", keys[0])); + } + result = Some(next); + } + } + Ok(result.flatten()) + }; + let basis = number(&["invest_ratio_bps", "investRatioBps"])?; + let fraction = number(&["invest_ratio", "investRatio"])?; + if fraction.is_some_and(|v| v < Decimal::ZERO || v > Decimal::ONE) { + return Err("invest_ratio must be in [0,1]; use invest_ratio_bps for basis points".into()); + } + let normalized_fraction = fraction.map(|value| value * Decimal::from(10000)); + if let (Some(left), Some(right)) = (basis, normalized_fraction) { + if left != right { + return Err("investment ratio aliases conflict".into()); + } + } + let ratio = basis + .or(normalized_fraction) + .unwrap_or(Decimal::from(10000)); + if ratio < Decimal::ZERO || ratio > Decimal::from(10000) || ratio.fract() != Decimal::ZERO { + return Err("investment ratio must be an integer between 0 and 10000 basis points".into()); + } + let cash = number(&["reserve_cash", "reserveCash"])?.unwrap_or(Decimal::ZERO); + if cash < Decimal::ZERO { + return Err("reserve_cash must be nonnegative".into()); + } + Ok(( + ratio + .normalize() + .to_string() + .parse::() + .map_err(|_| "investment ratio out of range")?, + cash, + )) +} + +pub fn stock_pool_constraints_from_configuration( + allocation: &Value, + stops: &Value, +) -> Result { + stock_pool_funding_from_configuration(allocation)?; + let empty = serde_json::Map::new(); + let object = if allocation.is_null() { + &empty + } else { + allocation + .as_object() + .ok_or("allocation_policy must be an object")? + }; + let alias = |keys: &[&str]| -> Result, String> { + let mut value = None; + for key in keys { + if let Some(next) = object.get(*key) { + if value.is_some_and(|old| old != next) { + return Err(format!("allocation aliases conflict:{}", keys[0])); + } + value = Some(next) + } + } + Ok(value) + }; + let legacy = alias(&["top_n_rebalance_policy", "topNRebalancePolicy"])?; + if legacy.is_some_and(|value| !value.is_null() && !value.is_string()) { + return Err("legacy rebalance policy must be a string".into()); + } + if !stops.is_null() && !stops.is_object() { + return Err("stop_take_policy must be an object".into()); + } + let legacy_policy = StockPoolPortfolioPolicy::from_legacy( + legacy.and_then(Value::as_str).unwrap_or("full_rebalance"), + )?; + let policy = if let Some(raw) = + alias(&["portfolio_policy", "portfolioPolicy"])?.filter(|raw| !raw.is_null()) + { + let value: StockPoolPortfolioPolicy = serde_json::from_value(raw.clone()) + .map_err(|error| format!("invalid stock pool portfolio policy:{error}"))?; + value.validate()?; + if legacy.is_some_and(|value| !value.is_null()) && value != legacy_policy { + return Err("portfolio policy conflicts with legacy rebalance policy".into()); + } + value + } else { + legacy_policy + }; + let target_holding_count = if allocation.is_null() { + None + } else { + stock_pool_target_holding_count(allocation)? + }; + let reserve_cash_slots = match alias(&["reserve_cash_slots", "reserveCashSlots"])? + .filter(|value| !value.is_null()) + { + Some(value) => value + .as_u64() + .filter(|value| *value <= 10000) + .ok_or("reserve_cash_slots must be an integer between 0 and 10000")? + as usize, + None => 0, + }; + if reserve_cash_slots > 0 && target_holding_count.is_none() { + return Err("cash reserve slots require target_holding_count".into()); + } + let stop = |keys: &[&str]| -> Result, String> { + let mut found = None; + for key in keys { + if let Some(value) = stops.get(*key) { + let next = if value.is_null() || value.as_str() == Some("") { + None + } else { + let text = value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()); + let number = text + .parse::() + .map_err(|_| "invalid stop/take ratio")?; + if number < Decimal::ZERO || number >= Decimal::ONE { + return Err("stop/take ratio must be in [0,1)".into()); + } + if number == Decimal::ZERO { + None + } else { + Some(number) + } + }; + if found.is_some_and(|old| old != next) { + return Err("stop/take aliases conflict".into()); + } + found = Some(next); + } + } + Ok(found.flatten()) + }; + Ok(StockPoolDecisionConstraints { + market_timing_policy: Some( + crate::stock_pool_index_policy::MarketTimingPolicy::from_allocation(allocation)?, + ), + portfolio_policy: Some(policy), + target_holding_count, + reserve_cash_slots, + default_stop_loss: stop(&["stop_loss", "stopLoss"])?, + default_take_profit: stop(&["take_profit", "takeProfit"])?, + ..Default::default() + }) +} + +#[derive(Debug, Clone)] +pub struct AccountSnapshot { + pub total_equity: Decimal, + pub cash: Decimal, + pub frozen_cash: Decimal, +} +#[derive(Debug, Clone)] +pub struct Position { + pub symbol: String, + pub quantity: Decimal, + pub closable_quantity: Decimal, + pub average_cost: Decimal, +} +#[derive(Debug, Clone)] +pub struct MarketSnapshot { + pub symbol: String, + pub last_price: Decimal, + pub prev_close: Option, + pub volume: Option, + pub turnover: Option, + pub bid_price_1: Option, + pub ask_price_1: Option, + pub is_kcb: Option, + pub instrument_rules: Option, + /// Explicit execution-adapter estimates; omission means the declared last + /// price model, never replacement of an invalid supplied value. + pub buy_sizing_price: Option, + pub sell_sizing_price: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StockPoolInstrumentRules { + pub price_tick: Decimal, + pub quantity_step: Decimal, + pub minimum_buy_quantity: Decimal, +} + +impl StockPoolInstrumentRules { + pub fn validate(&self) -> Result<(), String> { + if self.price_tick <= Decimal::ZERO + || self.price_tick > Decimal::ONE + || self.quantity_step <= Decimal::ZERO + || self.quantity_step.fract() != Decimal::ZERO + || self.minimum_buy_quantity < self.quantity_step + || self.minimum_buy_quantity.fract() != Decimal::ZERO + { + return Err("stock_pool_instrument_rules_invalid".into()); + } + Ok(()) + } +} + +pub fn stock_pool_instrument_rules( + snapshot: &MarketSnapshot, +) -> Result { + if let Some(rules) = &snapshot.instrument_rules { + rules.validate()?; + return Ok(rules.clone()); + } + let is_kcb = snapshot.is_kcb.ok_or_else(|| { + format!( + "listed_sector_classification_missing: symbol={}", + snapshot.symbol + ) + })?; + let (quantity_step, minimum_buy_quantity) = if is_kcb { + (Decimal::ONE, Decimal::from(200)) + } else if snapshot.symbol.ends_with(".BJ") { + (Decimal::ONE, Decimal::from(100)) + } else { + (Decimal::from(100), Decimal::from(100)) + }; + Ok(StockPoolInstrumentRules { + price_tick: Decimal::new(1, 2), + quantity_step, + minimum_buy_quantity, + }) +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StockPoolMemberSpec { + pub symbol: String, + #[serde(default)] + pub recommendation_reason: String, + #[serde(default)] + pub requested_order: i32, + #[serde(default)] + pub target_weight_bps: Option, + #[serde(default)] + pub stop_loss: Option, + #[serde(default)] + pub take_profit: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct StockPoolExecutionRule { + #[serde(default, alias = "buyConditionScope")] + pub buy_condition_scope: Option, + #[serde(default, alias = "sellConditionScope")] + pub sell_condition_scope: Option, + #[serde(skip)] + pub secondary_sell_condition: bool, + #[serde( + default, + deserialize_with = "crate::holding_policy::deserialize_optional_policy" + )] + pub automatic_trade_protection: crate::holding_policy::AutomaticTradeProtection, + #[serde(alias = "schemaVersion")] + pub schema_version: u32, + #[serde( + alias = "autoExecute", + alias = "daily_auto_execute", + alias = "autoTrade" + )] + pub auto_execute: bool, + #[serde(alias = "freezeCutoff")] + pub freeze_time: String, + #[serde(alias = "triggerMode")] + pub trigger_mode: String, + #[serde(alias = "windowStart")] + pub window_start: String, + #[serde(alias = "windowEnd")] + pub window_end: String, + #[serde(alias = "condition")] + pub buy_condition: String, + #[serde(alias = "sellTriggerMode")] + pub sell_trigger_mode: String, + #[serde(alias = "sellCondition")] + pub sell_condition: String, + #[serde(alias = "pricingMode")] + pub pricing_mode: String, + #[serde(alias = "fixedPrice")] + pub fixed_price: Option, + #[serde(alias = "fixedPrices")] + pub fixed_prices: BTreeMap, + #[serde(alias = "buyOffsetBps")] + pub buy_offset_bps: i32, + #[serde(alias = "sellOffsetBps")] + pub sell_offset_bps: i32, + #[serde(alias = "timeInForce")] + pub time_in_force: String, + #[serde(alias = "missedWindowPolicy")] + pub missed_window_policy: String, + #[serde(alias = "maxChildOrders")] + pub max_child_orders: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StockPoolSelection { + pub trade_date: NaiveDate, + pub requested_symbols: Vec, + pub normal_trading_symbols: Vec, + pub risk_eligible_symbols: Vec, + pub final_symbols: Vec, + #[serde(default)] + pub exclusion_reasons: BTreeMap>, + #[serde(default)] + pub inherited_from_generation: Option, + #[serde(default)] + pub explicit_empty: bool, + #[serde(default)] + pub generation: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct StockPoolDecisionConstraints { + pub execution_date: Option, + pub frozen_positions: BTreeMap, + pub prior_target_weights: BTreeMap, + pub pending_entry_symbols: BTreeSet, + pub next_day_outside_exit_symbols: BTreeSet, + pub market_timing_policy: Option, + pub market_timing_input: Option, + pub portfolio_policy: Option, + pub target_holding_count: Option, + pub reserve_cash_slots: usize, + pub default_stop_loss: Option, + pub default_take_profit: Option, + pub position_target_bps: BTreeMap, + pub buy_denials: BTreeMap>, + pub same_day_sold_symbols: BTreeSet, + pub automatic_permissions: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FrozenStockPoolPosition { + pub trade_date: NaiveDate, + pub reason: String, + pub valuation_price: Decimal, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StockPoolPlanRow { + pub symbol: String, + pub target_weight_bps: i32, + pub target_value: Decimal, + pub current_quantity: Decimal, + pub target_quantity: Decimal, + pub delta_quantity: Decimal, + pub side: Option, + pub status: String, + pub reason: String, + pub reference_price: Option, + pub order_type: Option, + pub limit_price: Option, + pub source_intent: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StockPoolPlan { + pub market_timing: Option, + pub rows: Vec, + pub budget: Decimal, + pub estimated_buy_amount: Decimal, + pub estimated_sell_amount: Decimal, + pub estimated_cash_after: Decimal, + pub requested_invest_ratio_bps: i32, + pub effective_invest_ratio_bps: Decimal, + pub retained_slots: usize, +} + +/// A signal-time contract. Only the broker/execution adapter supplies later +/// prices, actual cash and holdings; strategy code never sees those inputs. +#[derive(Debug, Clone)] +pub struct FrozenStockPoolIntent { + pub pool_id: String, + pub signal_date: NaiveDate, + pub frozen_equity: Decimal, + pub selection: StockPoolSelection, + pub members: Vec, + pub rule: StockPoolExecutionRule, + pub constraints: StockPoolDecisionConstraints, + pub invest_ratio_bps: i32, + pub reserve_cash: Decimal, + pub out_of_pool_policy: String, + pub generation: String, +} + +/// Complete immutable pool execution configuration, distinct from a generated +/// code strategy. Source screening/event evidence remains separately bound. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StockPoolProgram { + pub schema_version: u32, + pub pool_id: String, + pub version_id: String, + pub members: Vec, + pub allocation_policy: Value, + pub timing_policy: Value, + pub stop_take_policy: Value, + pub out_of_pool_policy: String, +} + +impl StockPoolProgram { + pub fn validate(&self, secondary_buy: bool, secondary_sell: bool) -> Result<(), String> { + if self.schema_version != 1 + || self.pool_id.trim().is_empty() + || self.version_id.trim().is_empty() + { + return Err("stock_pool_program_identity_invalid".into()); + } + normalize_stock_pool_members(&self.members)?; + stock_pool_funding_from_configuration(&self.allocation_policy)?; + stock_pool_constraints_from_configuration(&self.allocation_policy, &self.stop_take_policy)?; + normalize_stock_pool_execution_rule( + Some(&self.timing_policy), + secondary_buy, + secondary_sell, + )?; + if !matches!( + self.out_of_pool_policy.as_str(), + "hold" | "reduce_to_zero_when_sellable" | "reduce_next_trading_day" + ) { + return Err("stock_pool_program_outside_policy_invalid".into()); + } + Ok(()) + } +} + +impl Default for StockPoolExecutionRule { + fn default() -> Self { + Self { + buy_condition_scope: None, + sell_condition_scope: None, + secondary_sell_condition: false, + automatic_trade_protection: Default::default(), + schema_version: STOCK_POOL_SCHEMA_VERSION, + auto_execute: true, + freeze_time: "09:20".to_string(), + trigger_mode: POOL_TRIGGER_FIRST_TICK.to_string(), + window_start: "09:30".to_string(), + window_end: "09:35".to_string(), + buy_condition: String::new(), + sell_trigger_mode: POOL_SELL_TARGET_DELTA.to_string(), + sell_condition: String::new(), + pricing_mode: POOL_PRICE_FORMULA_LIMIT.to_string(), + fixed_price: None, + fixed_prices: BTreeMap::new(), + buy_offset_bps: 0, + sell_offset_bps: 0, + time_in_force: "DAY".to_string(), + missed_window_policy: "intraday_catchup".to_string(), + max_child_orders: 1, + } + } +} + +pub fn build_stock_pool_target_plan_with_constraints( + selection: &StockPoolSelection, + members: &[StockPoolMemberSpec], + rule: &StockPoolExecutionRule, + account: &AccountSnapshot, + positions: &[Position], + quotes: &[MarketSnapshot], + invest_ratio_bps: i32, + reserve_cash: Decimal, + out_of_pool_policy: &str, + top_n_rebalance_policy: &str, + constraints: &StockPoolDecisionConstraints, + generation: &str, + commission_rate: Decimal, + minimum_commission: Decimal, + stamp_tax_rate: Decimal, +) -> Result { + build_stock_pool_target_plan_with_fee_model( + selection, + members, + rule, + account, + positions, + quotes, + invest_ratio_bps, + reserve_cash, + out_of_pool_policy, + top_n_rebalance_policy, + constraints, + generation, + commission_rate, + minimum_commission, + stamp_tax_rate, + None, + ) +} + +pub fn build_stock_pool_target_plan_with_fee_model( + selection: &StockPoolSelection, + members: &[StockPoolMemberSpec], + rule: &StockPoolExecutionRule, + account: &AccountSnapshot, + positions: &[Position], + quotes: &[MarketSnapshot], + invest_ratio_bps: i32, + reserve_cash: Decimal, + out_of_pool_policy: &str, + top_n_rebalance_policy: &str, + constraints: &StockPoolDecisionConstraints, + generation: &str, + commission_rate: Decimal, + minimum_commission: Decimal, + stamp_tax_rate: Decimal, + fee_model: Option<&dyn Fn(&str, OrderSide, Decimal) -> Result>, +) -> Result { + if rule.automatic_trade_protection.enabled() { + for symbol in stock_pool_execution_quote_symbols(&selection.requested_symbols, positions) { + if !constraints.automatic_permissions.contains_key(&symbol) { + return Err(format!( + "automatic_trade_protection_permission_missing:{symbol}" + )); + } + } + } + let mut effective_position_targets = constraints.position_target_bps.clone(); + for (symbol, permission) in &constraints.automatic_permissions { + if permission.max_holding_exit { + effective_position_targets.insert(symbol.clone(), 0); + } + } + let same_day_sold_symbols = &constraints.same_day_sold_symbols; + if !(0..=10_000).contains(&invest_ratio_bps) { + return Err("invest_ratio_bps must be between 0 and 10000".to_string()); + } + if reserve_cash < Decimal::ZERO { + return Err("reserve_cash must be non-negative".to_string()); + } + if commission_rate < Decimal::ZERO + || minimum_commission < Decimal::ZERO + || stamp_tax_rate < Decimal::ZERO + { + return Err("stock pool fee settings must be non-negative".to_string()); + } + let fee_for = |symbol: &str, side: OrderSide, gross: Decimal| -> Result { + let fee = if let Some(model) = fee_model { + model(symbol, side, gross)? + } else { + commission_for_notional(gross, commission_rate, minimum_commission) + + if side == OrderSide::Sell { + gross * stamp_tax_rate + } else { + Decimal::ZERO + } + }; + if fee < Decimal::ZERO { + return Err("stock pool fee model returned a negative cost".into()); + } + Ok(fee) + }; + if !matches!( + out_of_pool_policy, + "hold" | "reduce_to_zero_when_sellable" | "reduce_next_trading_day" + ) { + return Err(format!( + "unsupported out_of_pool_policy={out_of_pool_policy}" + )); + } + let portfolio_policy = constraints + .portfolio_policy + .clone() + .map(Ok) + .unwrap_or_else(|| StockPoolPortfolioPolicy::from_legacy(top_n_rebalance_policy))?; + portfolio_policy.validate()?; + if constraints.target_holding_count == Some(0) + || (constraints.reserve_cash_slots > 0 && constraints.target_holding_count.is_none()) + { + return Err("cash reserve slots require a positive target holding count".into()); + } + let mut normalized_members = normalize_stock_pool_members(members)?; + for member in &mut normalized_members { + if member.stop_loss.is_none() { + member.stop_loss = constraints.default_stop_loss; + } + if member.take_profit.is_none() { + member.take_profit = constraints.default_take_profit; + } + } + let quote_map = quotes + .iter() + .filter_map(|quote| normalize_stock_symbol("e.symbol).map(|symbol| (symbol, quote))) + .collect::>(); + let mut current = BTreeMap::::new(); + for position in positions { + let Some(symbol) = normalize_stock_symbol(&position.symbol) else { + continue; + }; + if position.quantity < Decimal::ZERO + || position.closable_quantity < Decimal::ZERO + || position.closable_quantity > position.quantity + { + return Err(format!("invalid managed stock position quantity:{symbol}")); + } + if current + .insert( + symbol.clone(), + ( + position.quantity, + position.closable_quantity, + position.average_cost, + ), + ) + .is_some() + { + return Err(format!("duplicate managed stock position:{symbol}")); + } + } + if quote_map.len() != quotes.len() { + return Err("duplicate or invalid stock pool execution quotes".into()); + } + frozen::validate(selection.trade_date, constraints, ¤t)?; + for symbol in constraints.frozen_positions.keys() { + effective_position_targets.remove(symbol); + } + if portfolio_policy.membership == MembershipPolicy::RetainHoldings + && out_of_pool_policy == "hold" + { + let known = normalized_members + .iter() + .map(|member| member.symbol.clone()) + .collect::>(); + for (symbol, (quantity, _, _)) in ¤t { + if *quantity > Decimal::ZERO && !known.contains(symbol) { + normalized_members.push(StockPoolMemberSpec { + symbol: symbol.clone(), + requested_order: normalized_members.len() as i32, + recommendation_reason: String::new(), + target_weight_bps: None, + stop_loss: constraints.default_stop_loss, + take_profit: constraints.default_take_profit, + }); + } + } + } + let member_map = normalized_members + .iter() + .map(|member| (member.symbol.clone(), member)) + .collect::>(); + let explicit_weights = normalized_members + .iter() + .filter_map(|member| { + member + .target_weight_bps + .map(|weight| (member.symbol.clone(), weight)) + }) + .collect::>(); + let mut original_final_symbols = normalize_symbol_list(&selection.final_symbols)?; + let capacity = constraints.target_holding_count.unwrap_or_else(|| { + original_final_symbols.len() + + constraints + .frozen_positions + .keys() + .filter(|symbol| { + member_map.contains_key(*symbol) && !original_final_symbols.contains(symbol) + }) + .count() + }); + if portfolio_policy.membership == MembershipPolicy::RetainHoldings { + let retained = normalized_members + .iter() + .filter(|member| { + current + .get(&member.symbol) + .is_some_and(|row| row.0 > Decimal::ZERO) + && !effective_position_targets.contains_key(&member.symbol) + }) + .map(|member| member.symbol.clone()) + .collect::>(); + let mut chosen = retained.into_iter().take(capacity).collect::>(); + for symbol in &original_final_symbols { + if chosen.len() >= capacity { + break; + } + if !chosen.contains(symbol) { + chosen.push(symbol.clone()) + } + } + original_final_symbols = chosen; + } else { + original_final_symbols.truncate(capacity); + } + let mut protected_positions = constraints + .automatic_permissions + .iter() + .filter(|(symbol, permission)| { + permission.sell_denial.is_some() + && current + .get(*symbol) + .is_some_and(|position| position.0 > Decimal::ZERO) + }) + .map(|(symbol, _)| symbol.clone()) + .collect::>(); + protected_positions.extend(constraints.frozen_positions.keys().cloned()); + let global_stop_hits = current + .iter() + .filter_map(|(symbol, (quantity, _, cost))| { + let member = member_map.get(symbol)?; + let quote = quote_map.get(symbol)?; + (*quantity > Decimal::ZERO + && *cost > Decimal::ZERO + && !protected_positions.contains(symbol) + && (member.stop_loss.is_some_and(|stop| { + stop > Decimal::ZERO && quote.last_price <= *cost * (Decimal::ONE - stop) + }) || member.take_profit.is_some_and(|take| { + take > Decimal::ZERO && quote.last_price >= *cost * (Decimal::ONE + take) + }))) + .then(|| symbol.clone()) + }) + .collect::>(); + let mut quote_sell_exits = BTreeSet::new(); + let mut sell_condition_denials = BTreeSet::new(); + if rule.sell_trigger_mode == POOL_SELL_CONDITION { + let held = current + .iter() + .filter(|(symbol, row)| { + row.0 > Decimal::ZERO && !constraints.frozen_positions.contains_key(*symbol) + }) + .map(|(symbol, _)| symbol.clone()) + .collect::>(); + let qualified = quote_condition_results( + &rule.sell_condition, + rule.sell_condition_scope, + &held, + "e_map, + )?; + for symbol in held { + if global_stop_hits.contains(&symbol) + || constraints + .automatic_permissions + .get(&symbol) + .is_some_and(|permission| permission.max_holding_exit) + { + continue; + } + let permitted = qualified.get(&symbol) == Some(&true) + && (!rule.secondary_sell_condition + || constraints.position_target_bps.contains_key(&symbol)); + if !permitted { + sell_condition_denials.insert(symbol.clone()); + effective_position_targets.remove(&symbol); + } else if !rule.secondary_sell_condition { + quote_sell_exits.insert(symbol.clone()); + effective_position_targets.insert(symbol, 0); + } + } + protected_positions.extend(sell_condition_denials.iter().cloned()); + } + let factor_position_target_bps = &effective_position_targets; + let reserved_protected_slots = protected_positions + .iter() + .filter(|symbol| !original_final_symbols.contains(symbol)) + .count(); + let maximum_holding_exits = constraints + .automatic_permissions + .iter() + .filter(|(symbol, permission)| { + permission.max_holding_exit && !constraints.frozen_positions.contains_key(*symbol) + }) + .map(|(symbol, _)| symbol.clone()) + .collect::>(); + let mut stop_take_exits = global_stop_hits; + stop_take_exits.extend(quote_sell_exits.iter().cloned()); + for symbol in &original_final_symbols { + if protected_positions.contains(symbol) { + continue; + } + let Some(member) = member_map.get(symbol) else { + continue; + }; + let Some((quantity, _closable, cost)) = current.get(symbol) else { + continue; + }; + let Some(quote) = quote_map.get(symbol) else { + continue; + }; + if *quantity > Decimal::ZERO + && *cost > Decimal::ZERO + && (member.stop_loss.is_some_and(|stop| { + stop > Decimal::ZERO && quote.last_price <= *cost * (Decimal::ONE - stop) + }) || member.take_profit.is_some_and(|take| { + take > Decimal::ZERO && quote.last_price >= *cost * (Decimal::ONE + take) + })) + { + stop_take_exits.insert(symbol.clone()); + } + } + stop_take_exits.extend(maximum_holding_exits.iter().cloned()); + let normalized_same_day_sold = + normalize_symbol_set(&same_day_sold_symbols.iter().cloned().collect::>())?; + let mut rebuy_exclusions = stop_take_exits.clone(); + rebuy_exclusions.extend( + normalized_same_day_sold + .iter() + .filter(|symbol| { + current + .get(*symbol) + .is_none_or(|row| row.0 == Decimal::ZERO) + }) + .cloned(), + ); + let target_count = capacity.saturating_sub(reserved_protected_slots); + let free_slots = capacity.saturating_sub(protected_positions.len()); + let mut unprotected_count = 0; + let mut active_symbols = original_final_symbols + .iter() + .filter(|symbol| !rebuy_exclusions.contains(*symbol)) + .filter(|symbol| { + if protected_positions.contains(*symbol) { + return true; + } + if unprotected_count >= free_slots { + return false; + } + unprotected_count += 1; + true + }) + .cloned() + .collect::>(); + let mut promoted_symbols = Vec::new(); + let risk_eligible_symbols = normalize_symbol_set(&selection.risk_eligible_symbols)?; + let normal_symbols = normalize_symbol_set(&selection.normal_trading_symbols)?; + for raw_symbol in &selection.requested_symbols { + if active_symbols.len() >= target_count { + break; + } + let Some(symbol) = normalize_stock_symbol(raw_symbol) else { + continue; + }; + if rebuy_exclusions.contains(&symbol) + || !risk_eligible_symbols.contains(&symbol) + || !normal_symbols.contains(&symbol) + || selection.exclusion_reasons.contains_key(&symbol) + || factor_position_target_bps.contains_key(&symbol) + || active_symbols.contains(&symbol) + || !member_map.contains_key(&symbol) + || !quote_map.contains_key(&symbol) + { + continue; + } + active_symbols.push(symbol.clone()); + promoted_symbols.push(symbol); + } + let mut weights = if explicit_weights.is_empty() && constraints.frozen_positions.is_empty() { + let count = (active_symbols.len() + reserved_protected_slots) as i32; + let (share, remainder) = if count == 0 { + (0, 0) + } else { + (10_000 / count, 10_000 % count) + }; + active_symbols + .iter() + .enumerate() + .map(|(index, symbol)| { + ( + symbol.clone(), + share + i32::from((index as i32) < remainder), + ) + }) + .collect::>() + } else { + frozen::weights( + &original_final_symbols, + &active_symbols, + &normalized_members, + &explicit_weights, + constraints, + reserved_protected_slots, + target_count, + )? + }; + for symbol in &rebuy_exclusions { + if original_final_symbols.contains(symbol) || current.contains_key(symbol) { + weights.insert(symbol.clone(), 0); + } + } + let mut planning_symbols = active_symbols; + for symbol in &original_final_symbols { + if rebuy_exclusions.contains(symbol) && !planning_symbols.contains(symbol) { + planning_symbols.push(symbol.clone()); + } + } + for symbol in &maximum_holding_exits { + if member_map.contains_key(symbol) + && !factor_position_target_bps.contains_key(symbol) + && !planning_symbols.contains(symbol) + { + weights.insert(symbol.clone(), 0); + planning_symbols.push(symbol.clone()); + } + } + for symbol in &stop_take_exits { + if current + .get(symbol) + .is_some_and(|position| position.0 > Decimal::ZERO) + && member_map.contains_key(symbol) + && !factor_position_target_bps.contains_key(symbol) + && !planning_symbols.contains(symbol) + { + weights.insert(symbol.clone(), 0); + planning_symbols.push(symbol.clone()); + } + } + let active_target_symbols = weights + .iter() + .filter_map(|(symbol, weight)| (*weight > 0).then_some(symbol.clone())) + .collect::>(); + let buy_condition_allowed = if !rule.buy_condition.trim().is_empty() { + quote_condition_results( + &rule.buy_condition, + rule.buy_condition_scope, + &active_target_symbols + .iter() + .filter(|symbol| !constraints.frozen_positions.contains_key(*symbol)) + .cloned() + .collect::>(), + "e_map, + )? + } else { + BTreeMap::new() + }; + let top_n_demoted_symbols = + if selection.final_symbols.len() < selection.risk_eligible_symbols.len() { + current + .iter() + .filter_map(|(symbol, (quantity, _, _))| { + (*quantity > Decimal::ZERO + && risk_eligible_symbols.contains(symbol) + && !active_target_symbols.contains(symbol) + && !factor_position_target_bps.contains_key(symbol)) + .then_some(symbol.clone()) + }) + .collect::>() + } else { + BTreeSet::new() + }; + for symbol in &top_n_demoted_symbols { + weights.insert(symbol.clone(), 0); + if !planning_symbols.contains(symbol) { + planning_symbols.push(symbol.clone()); + } + } + let occupied_slots = planning_symbols + .iter() + .filter(|symbol| weights.get(*symbol).is_some_and(|weight| *weight > 0)) + .count() + + reserved_protected_slots; + let seat_scale = if constraints.reserve_cash_slots > 0 { + Decimal::from(occupied_slots.min(capacity) as u64) + / Decimal::from( + capacity + .checked_add(constraints.reserve_cash_slots) + .ok_or("stock pool seat count overflow")? as u64, + ) + } else { + Decimal::ONE + }; + let market_timing = constraints + .market_timing_policy + .as_ref() + .filter(|policy| policy.enabled) + .map(|policy| { + let input = constraints + .market_timing_input + .as_ref() + .ok_or("market_timing_verified_completed_index_input_required")?; + crate::stock_pool_index_policy::evaluate(policy, input, selection.trade_date) + }) + .transpose()?; + let market_exposure = market_timing + .as_ref() + .map(|result| { + result + .exposure + .to_string() + .parse::() + .map_err(|_| "market_timing_exposure_out_of_range") + }) + .transpose()? + .unwrap_or(Decimal::ONE); + let effective_invest_ratio_bps = Decimal::from(invest_ratio_bps) * market_exposure * seat_scale; + let base_budget = account.total_equity * Decimal::from(invest_ratio_bps) + / Decimal::from(10_000) + * market_exposure; + let investment_budget = if constraints.reserve_cash_slots > 0 { + base_budget * Decimal::from(occupied_slots.min(capacity) as u64) + / Decimal::from((capacity + constraints.reserve_cash_slots) as u64) + } else { + base_budget + }; + let budget = (investment_budget - reserve_cash).max(Decimal::ZERO); + let requested_weight_total = if explicit_weights.is_empty() { + 10_000 + } else { + original_final_symbols + .iter() + .map(|symbol| *explicit_weights.get(symbol).unwrap_or(&0)) + .sum::() + .max(weights.values().copied().sum()) + }; + let mut protected_values = protected_positions + .iter() + .map(|symbol| { + let current_value = current[symbol].0 + * frozen::valuation(symbol, "e_map, &constraints.frozen_positions)?; + let desired = + budget * Decimal::from(*weights.get(symbol).unwrap_or(&0)) / Decimal::from(10_000); + if constraints.frozen_positions.contains_key(symbol) { + return Ok((symbol.clone(), desired)); + } + let fixed = constraints + .automatic_permissions + .get(symbol) + .is_some_and(|permission| permission.buy_denial.is_some()); + Ok(( + symbol.clone(), + if fixed { + current_value + } else { + current_value.max(desired) + }, + )) + }) + .collect::, String>>()?; + // Retaining completed holdings consumes their actual value. New slots + // share only the remaining budget; their smaller allocation is not a + // fictitious cash failure caused by an equal-weight target for old names. + if !portfolio_policy.rebalance_weights && budget > Decimal::ZERO { + for symbol in &planning_symbols { + if weights.get(symbol).is_some_and(|weight| *weight > 0) + && !constraints.frozen_positions.contains_key(symbol) + && !constraints.pending_entry_symbols.contains(symbol) + && !stop_take_exits.contains(symbol) + && let Some((quantity, _, _)) = + current.get(symbol).filter(|row| row.0 > Decimal::ZERO) + { + let price = quote_map + .get(symbol) + .ok_or_else(|| format!("{symbol} retained holding quote missing"))? + .last_price; + protected_values.insert(symbol.clone(), *quantity * price); + } + } + } + let free_desired = weights + .iter() + .filter(|(symbol, _)| !protected_values.contains_key(*symbol)) + .map(|(_, weight)| budget * Decimal::from(*weight) / Decimal::from(10_000)) + .sum::(); + let free_budget = (budget * Decimal::from(requested_weight_total) / Decimal::from(10_000) + - protected_values.values().copied().sum::()) + .max(Decimal::ZERO); + let free_scale = if protected_values.is_empty() || free_desired <= Decimal::ZERO { + Decimal::ONE + } else { + (free_budget / free_desired).min(Decimal::ONE) + }; + let mut rows = Vec::new(); + let mut estimated_buy_amount = Decimal::ZERO; + let mut estimated_sell_amount = Decimal::ZERO; + for (symbol, fact) in &constraints.frozen_positions { + let quantity = current[symbol].0; + let weight = *weights.get(symbol).unwrap_or(&0); + rows.push(StockPoolPlanRow { + symbol: symbol.clone(), + target_weight_bps: weight, + target_value: budget * Decimal::from(weight) / Decimal::from(10_000), + current_quantity: quantity, + target_quantity: quantity, + delta_quantity: Decimal::ZERO, + side: None, + status: "MARKET_SUSPENDED".into(), + reason: "当日正式停牌,保留原预算与席位;估值不作为成交报价".into(), + reference_price: Some(fact.valuation_price), + order_type: None, + limit_price: None, + source_intent: None, + }); + } + + // Out-of-pool positions are intentionally explicit. The default is hold; + // this prevents an edited pool from silently liquidating unrelated work. + for (symbol, (quantity, closable, _cost)) in current.iter().filter(|(symbol, _)| { + !member_map.contains_key(*symbol) + && !factor_position_target_bps.contains_key(*symbol) + && !constraints.frozen_positions.contains_key(*symbol) + }) { + let outside_policy = if maximum_holding_exits.contains(symbol) + || (out_of_pool_policy == "reduce_next_trading_day" + && constraints.next_day_outside_exit_symbols.contains(symbol)) + { + "reduce_to_zero_when_sellable" + } else { + out_of_pool_policy + }; + let (status, delta, target, side, reason) = match outside_policy { + "hold" => ( + "OUT_OF_SCOPE", + Decimal::ZERO, + *quantity, + None, + "持仓不在本股票池管理范围,按配置继续持有", + ), + "reduce_next_trading_day" => ( + "DEFERRED_T_PLUS_ONE", + Decimal::ZERO, + *quantity, + None, + "移出股票池,按配置顺延到下一交易日处理", + ), + _ => { + let sellable = (*closable).min(*quantity); + if sellable > Decimal::ZERO { + estimated_sell_amount += sellable + * quote_map + .get(symbol) + .map(|quote| quote.last_price) + .unwrap_or(Decimal::ZERO); + ( + "READY", + -sellable, + *quantity - sellable, + Some(OrderSide::Sell), + "移出股票池,按配置清仓可卖数量", + ) + } else { + ( + "DEFERRED_T_PLUS_ONE", + Decimal::ZERO, + *quantity, + None, + "移出股票池,当前没有可卖数量", + ) + } + } + }; + let (outside_order_type, outside_limit_price) = if delta != Decimal::ZERO { + let quote = quote_map + .get(symbol) + .ok_or_else(|| format!("{symbol} missing current execution quote"))?; + let (kind, price) = resolve_stock_pool_order_price( + rule, + symbol, + quote.last_price, + OrderSide::Sell, + stock_pool_instrument_rules(quote)?.price_tick, + )?; + (Some(kind), price) + } else { + (None, None) + }; + rows.push(StockPoolPlanRow { + symbol: symbol.clone(), + target_weight_bps: 0, + target_value: Decimal::ZERO, + current_quantity: *quantity, + target_quantity: target, + delta_quantity: delta, + side, + status: status.to_string(), + reason: reason.to_string(), + reference_price: quote_map.get(symbol).map(|quote| quote.last_price), + order_type: outside_order_type, + limit_price: outside_limit_price, + source_intent: (delta != Decimal::ZERO) + .then(|| format!("stock_pool:{generation}:{symbol}:sell")), + }); + } + + for (symbol, target_bps) in factor_position_target_bps { + if *target_bps >= 10_000 { + return Err(format!( + "factor position target for {symbol} must be below 10000 bps" + )); + } + if !member_map.contains_key(symbol) && !current.contains_key(symbol) { + return Err(format!( + "factor position-action symbol {symbol} is outside candidates and managed holdings" + )); + } + if selection.final_symbols.contains(symbol) + && !maximum_holding_exits.contains(symbol) + && !quote_sell_exits.contains(symbol) + { + return Err(format!( + "factor position-action symbol {symbol} cannot remain in final selection" + )); + } + let current_quantity = current + .get(symbol) + .map(|value| value.0) + .unwrap_or(Decimal::ZERO); + let closable_quantity = current + .get(symbol) + .map(|value| value.1) + .unwrap_or(current_quantity); + let quote = quote_map.get(symbol); + let step = if *target_bps == 0 || current_quantity == Decimal::ZERO { + Decimal::ONE + } else { + order_quantity_rules(quote.ok_or_else(|| { + format!("{symbol} missing current execution quote for factor reduction") + })?)? + .0 + }; + let requested_target = if *target_bps == 0 { + Decimal::ZERO + } else { + floor_step( + current_quantity * Decimal::from(*target_bps) / Decimal::from(10_000), + step, + ) + }; + let desired_reduction = (current_quantity - requested_target).max(Decimal::ZERO); + let executable = if *target_bps == 0 { + closable_quantity.min(current_quantity).max(Decimal::ZERO) + } else { + floor_step( + desired_reduction.min(closable_quantity).max(Decimal::ZERO), + step, + ) + }; + let (status, reason, delta, target, side, order_type, limit_price) = + if current_quantity == Decimal::ZERO { + ( + "FACTOR_EXIT_ALREADY_SATISFIED", + "生产因子持仓动作命中,当前无持仓", + Decimal::ZERO, + Decimal::ZERO, + None, + None, + None, + ) + } else if executable == Decimal::ZERO { + ( + "DEFERRED_T_PLUS_ONE", + "生产因子持仓动作命中,当前没有可卖数量", + Decimal::ZERO, + current_quantity, + None, + None, + None, + ) + } else { + let quote = quote.ok_or_else(|| { + format!("{symbol} missing current execution quote for factor exit") + })?; + let (kind, price) = resolve_stock_pool_order_price( + rule, + symbol, + quote.last_price, + OrderSide::Sell, + stock_pool_instrument_rules(quote)?.price_tick, + )?; + estimated_sell_amount += executable * quote.last_price; + ( + "READY", + if maximum_holding_exits.contains(symbol) { + "达到最长持有期,按配置退出" + } else if quote_sell_exits.contains(symbol) { + "卖出行情条件命中" + } else if *target_bps == 0 { + "生产因子退出条件命中" + } else { + "生产因子减仓条件命中" + }, + -executable, + current_quantity - executable, + Some(OrderSide::Sell), + Some(kind), + price, + ) + }; + rows.push(StockPoolPlanRow { + symbol: symbol.clone(), + target_weight_bps: *target_bps as i32, + target_value: target * quote.map(|value| value.last_price).unwrap_or(Decimal::ZERO), + current_quantity, + target_quantity: target, + delta_quantity: delta, + side, + status: status.to_string(), + reason: reason.to_string(), + reference_price: quote.map(|value| value.last_price), + order_type, + limit_price, + source_intent: (delta != Decimal::ZERO).then(|| { + format!( + "stock_pool:{generation}:{symbol}:{}", + if *target_bps == 0 { + "factor_sell" + } else { + "factor_reduce" + } + ) + }), + }); + } + + for symbol in &planning_symbols { + if constraints.frozen_positions.contains_key(symbol) { + continue; + } + if !member_map.contains_key(symbol) { + return Err(format!("selection symbol {symbol} is missing from members")); + } + let current_quantity = current + .get(symbol) + .map(|value| value.0) + .unwrap_or(Decimal::ZERO); + let closable_quantity = current + .get(symbol) + .map(|value| value.1) + .unwrap_or(current_quantity); + let quote = quote_map + .get(symbol) + .ok_or_else(|| format!("{symbol} missing current execution quote"))?; + if quote.last_price <= Decimal::ZERO { + return Err(format!("{symbol} execution quote is invalid")); + } + let mut weight = *weights.get(symbol).unwrap_or(&0); + let forced_exit = stop_take_exits.contains(symbol); + if forced_exit { + weight = 0; + } + let target_value = protected_values + .get(symbol) + .copied() + .unwrap_or(budget * Decimal::from(weight) / Decimal::from(10_000) * free_scale) + .round_dp_with_strategy(2, RoundingStrategy::MidpointNearestEven); + let sizing_price = if target_value >= current_quantity * quote.last_price { + quote.buy_sizing_price.unwrap_or(quote.last_price) + } else { + quote.sell_sizing_price.unwrap_or(quote.last_price) + }; + if sizing_price <= Decimal::ZERO { + return Err(format!("{symbol} execution sizing price is invalid")); + } + let raw_target = (target_value / sizing_price).floor(); + let (step, minimum_buy) = order_quantity_rules(quote)?; + let mut target_quantity = current_quantity; + let mut delta = Decimal::ZERO; + let mut status = "ALREADY_SATISFIED".to_string(); + let mut reason = "目标仓位已满足".to_string(); + let preserve_existing = !portfolio_policy.rebalance_weights + && weight > 0 + && target_value > Decimal::ZERO + && current_quantity > Decimal::ZERO + && !forced_exit + && (!constraints.pending_entry_symbols.contains(symbol) + || raw_target <= current_quantity); + if preserve_existing { + if constraints.pending_entry_symbols.contains(symbol) { + status = "ENTRY_TARGET_ALREADY_SATISFIED".into(); + reason = "本轮建仓目标已满足,后续按配置保留股数".into(); + } else { + status = "PRESERVED_EXISTING_POSITION".to_string(); + reason = "按配置保留当前持仓,仅调整 Top N 进出名单".to_string(); + } + } else if raw_target > current_quantity { + let desired = raw_target - current_quantity; + let executable = floor_step(desired, step); + if buy_condition_allowed.get(symbol) == Some(&false) { + status = "BUY_CONDITION_PENDING".into(); + reason = "买入行情条件尚未满足".into(); + } else if let Some(reasons) = constraints.buy_denials.get(symbol) { + status = "BUY_FACTOR_BLOCKED".to_string(); + reason = format!("买入因子条件未满足,保留当前持仓: {}", reasons.join("; ")); + } else if normalized_same_day_sold.contains(symbol) { + status = "BUY_FACTOR_BLOCKED".into(); + reason = "当日已卖出,禁止增加仓位;不因此清仓剩余持仓".into(); + } else if executable < minimum_buy { + status = "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED".to_string(); + reason = if current_quantity == Decimal::ZERO { + "目标股数不足该证券首笔最小交易单位,无需生成委托" + } else { + "目标差额不足一个最小交易单位,无需生成委托" + } + .to_string(); + } else { + target_quantity += executable; + delta = executable; + status = "READY".to_string(); + reason.clear(); + } + } else if raw_target < current_quantity { + let desired = current_quantity - raw_target; + let executable = if raw_target == Decimal::ZERO { + closable_quantity + } else { + floor_step(desired, step).min(floor_step(closable_quantity, step)) + }; + if executable > Decimal::ZERO { + target_quantity -= executable; + delta = -executable; + status = "READY".to_string(); + reason = if maximum_holding_exits.contains(symbol) { + "达到最长持有期,按配置退出".to_string() + } else if forced_exit { + "止损/止盈触发".to_string() + } else if top_n_demoted_symbols.contains(symbol) { + "Top N 优先级调整,移出当前持仓槽位".to_string() + } else { + String::new() + }; + estimated_sell_amount += executable * quote.last_price; + } else if closable_quantity < desired { + status = "DEFERRED_T_PLUS_ONE".to_string(); + reason = "可卖数量不足,剩余减仓顺延到下一交易日".to_string(); + } else { + status = "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED".to_string(); + reason = "目标差额不足一个最小交易单位".to_string(); + } + } + let (order_type, limit_price) = if delta == Decimal::ZERO { + (None, None) + } else { + let side = if delta > Decimal::ZERO { + OrderSide::Buy + } else { + OrderSide::Sell + }; + let (kind, price) = resolve_stock_pool_order_price( + rule, + symbol, + quote.last_price, + side, + stock_pool_instrument_rules(quote)?.price_tick, + )?; + (Some(kind), price) + }; + if delta > Decimal::ZERO { + estimated_buy_amount += delta * limit_price.unwrap_or(quote.last_price); + } + rows.push(StockPoolPlanRow { + symbol: symbol.clone(), + target_weight_bps: weight, + target_value, + current_quantity, + target_quantity, + delta_quantity: delta, + side: (delta != Decimal::ZERO).then(|| { + if delta > Decimal::ZERO { + OrderSide::Buy + } else { + OrderSide::Sell + } + }), + status, + reason, + reference_price: Some(sizing_price), + order_type, + limit_price, + source_intent: (delta != Decimal::ZERO).then(|| { + format!( + "stock_pool:{generation}:{symbol}:{}", + if delta > Decimal::ZERO { "buy" } else { "sell" } + ) + }), + }); + } + + for symbol in &protected_positions { + if current + .get(symbol) + .is_some_and(|position| position.0 > Decimal::ZERO) + && !rows.iter().any(|row| &row.symbol == symbol) + { + let quantity = current[symbol].0; + let price = quote_map + .get(symbol) + .ok_or_else(|| format!("retained position quote missing:{symbol}"))? + .last_price; + rows.push(StockPoolPlanRow { + symbol: symbol.clone(), + target_weight_bps: 0, + target_value: quantity * price, + current_quantity: quantity, + target_quantity: quantity, + delta_quantity: Decimal::ZERO, + side: None, + status: if sell_condition_denials.contains(symbol) { + "SELL_CONDITION_PENDING" + } else { + "AUTOMATIC_TRADE_PROTECTED" + } + .into(), + reason: "持仓保留,继续占用资金与席位".into(), + reference_price: Some(price), + order_type: None, + limit_price: None, + source_intent: None, + }); + } + } + for row in &mut rows { + if sell_condition_denials.contains(&row.symbol) && row.delta_quantity <= Decimal::ZERO { + row.status = "SELL_CONDITION_PENDING".into(); + row.reason = "卖出条件尚未全部满足,保留持仓与席位".into(); + row.target_quantity = row.current_quantity; + row.delta_quantity = Decimal::ZERO; + row.side = None; + row.order_type = None; + row.limit_price = None; + row.source_intent = None; + } + if let Some(permission) = constraints.automatic_permissions.get(&row.symbol) { + let denial = if row.delta_quantity < Decimal::ZERO { + permission.sell_denial + } else if row.delta_quantity > Decimal::ZERO { + permission.buy_denial + } else { + permission.sell_denial + }; + if let Some(reason) = denial { + row.status = "AUTOMATIC_TRADE_PROTECTED".into(); + row.reason = match reason { + "automatic_trade_locked" => "锁定区间内保留持仓,不自动买卖", + "buy_fill_protection" => "买入成交后保护期内,不自动卖出", + "sell_fill_cooldown" => "卖出成交后禁买期内,不自动增加仓位", + "maximum_holding_exit" => "达到最长持有期,不再自动加仓", + _ => reason, + } + .into(); + row.delta_quantity = Decimal::ZERO; + row.target_quantity = row.current_quantity; + row.target_value = + row.current_quantity * row.reference_price.unwrap_or(Decimal::ZERO); + row.side = None; + row.order_type = None; + row.limit_price = None; + row.source_intent = None; + } + } + } + if market_timing.is_some() { + let caps = index_cap::remaining_index_targets( + ¤t, + &member_map, + &constraints.automatic_permissions, + &rows, + "e_map, + &constraints.frozen_positions, + budget * Decimal::from(requested_weight_total) / Decimal::from(10000), + )?; + let mut indices = rows + .iter() + .enumerate() + .map(|(index, row)| (row.symbol.clone(), index)) + .collect::>(); + for (symbol, cap) in caps { + let (quantity, closable, _) = current[&symbol]; + let quote = quote_map[&symbol]; + let (step, _) = order_quantity_rules(quote)?; + let desired = (quantity - cap.quantity).max(Decimal::ZERO); + let executable = if cap.quantity == Decimal::ZERO { + closable.min(quantity) + } else { + floor_step(desired.min(closable), step) + }; + let index = if let Some(index) = indices.get(&symbol) { + *index + } else { + let index = rows.len(); + rows.push(StockPoolPlanRow { + symbol: symbol.clone(), + target_weight_bps: *weights.get(&symbol).unwrap_or(&0), + target_value: quantity * quote.last_price, + current_quantity: quantity, + target_quantity: quantity, + delta_quantity: Decimal::ZERO, + side: None, + status: "ALREADY_SATISFIED".into(), + reason: String::new(), + reference_price: Some(quote.last_price), + order_type: None, + limit_price: None, + source_intent: None, + }); + indices.insert(symbol.clone(), index); + index + }; + let row = &mut rows[index]; + if executable > Decimal::ZERO { + row.target_quantity = quantity - executable; + row.target_value = cap.quantity * quote.last_price; + row.delta_quantity = -executable; + row.side = Some(OrderSide::Sell); + row.status = "READY".into(); + row.reason = "指数总仓位收缩,按剩余持仓比例减仓".into(); + let (kind, price) = resolve_stock_pool_order_price( + rule, + &symbol, + quote.last_price, + OrderSide::Sell, + stock_pool_instrument_rules(quote)?.price_tick, + )?; + row.order_type = Some(kind); + row.limit_price = price; + row.source_intent + .get_or_insert_with(|| format!("stock_pool:{generation}:{symbol}:sell")); + } else if cap.blocked_by_t1 { + row.target_quantity = quantity; + row.delta_quantity = Decimal::ZERO; + row.side = None; + row.order_type = None; + row.limit_price = None; + row.source_intent = None; + row.status = "DEFERRED_T_PLUS_ONE".into(); + row.reason = "指数仓位受可卖数量限制,保留剩余持仓至可交易时处理".into(); + } else if desired > Decimal::ZERO { + row.target_quantity = quantity; + row.delta_quantity = Decimal::ZERO; + row.side = None; + row.order_type = None; + row.limit_price = None; + row.source_intent = None; + row.status = "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED".into(); + row.reason = "指数目标差额不足最小交易单位,无需生成委托".into(); + } + } + } + // Do not authorise buys against proceeds that are not settled yet. The + // service may re-preview after the sell batch and then submit the buy leg. + let available_cash = (account.cash - account.frozen_cash).max(Decimal::ZERO); + let mut remaining_cash = available_cash; + let confirmed_position_value = current + .iter() + .filter(|(_, row)| row.0 > Decimal::ZERO) + .map(|(symbol, row)| { + let price = frozen::valuation(symbol, "e_map, &constraints.frozen_positions)?; + Ok(row.0 * price) + }) + .collect::, String>>()? + .into_iter() + .sum::(); + let mut position_budget = (budget * Decimal::from(requested_weight_total) + / Decimal::from(10000) + - confirmed_position_value) + .max(Decimal::ZERO); + let mut occupied_position_slots = current.values().filter(|row| row.0 > Decimal::ZERO).count(); + for row in rows + .iter_mut() + .filter(|row| row.side == Some(OrderSide::Buy)) + { + let price = stock_pool_plan_price(row, "e_map, OrderSide::Buy)?; + let quote = quote_map + .get(&row.symbol) + .ok_or_else(|| format!("{} missing current execution quote", row.symbol))?; + let (step, minimum_buy) = order_quantity_rules(quote)?; + let cost = |quantity: Decimal| { + Ok(quantity * price + fee_for(&row.symbol, OrderSide::Buy, quantity * price)?) + }; + let own_budget = (row.target_value - row.current_quantity * price).max(Decimal::ZERO); + let allocation_quantity = max_affordable_buy_quantity_with_cost( + own_budget, + row.delta_quantity, + step, + minimum_buy, + &cost, + )?; + if allocation_quantity < minimum_buy { + row.status = "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED".into(); + row.reason = "含费用目标预算不足最小交易单位,无需生成委托".into(); + row.side = None; + row.delta_quantity = Decimal::ZERO; + row.target_quantity = row.current_quantity; + continue; + } + if row.current_quantity == Decimal::ZERO && occupied_position_slots >= capacity { + row.status = "DEFERRED_POSITION_SLOTS".into(); + row.reason = "实际持仓席位尚未释放,卖出成交后重新计算买入".into(); + row.side = None; + row.delta_quantity = Decimal::ZERO; + row.target_quantity = row.current_quantity; + continue; + } + if cost(minimum_buy)? > position_budget { + row.status = "DEFERRED_POSITION_BUDGET".into(); + row.reason = "实际持仓仍占用目标资金预算,卖出成交后重新计算买入".into(); + row.side = None; + row.delta_quantity = Decimal::ZERO; + row.target_quantity = row.current_quantity; + continue; + } + let max_quantity = max_affordable_buy_quantity_with_cost( + remaining_cash.min(own_budget).min(position_budget), + row.delta_quantity, + step, + minimum_buy, + &cost, + )?; + if max_quantity < minimum_buy { + row.status = "BLOCKED_ACCOUNT".to_string(); + row.reason = "可用资金不足以满足该证券首笔最小交易单位".to_string(); + row.side = None; + row.delta_quantity = Decimal::ZERO; + row.target_quantity = row.current_quantity; + continue; + } + if max_quantity < row.delta_quantity { + row.delta_quantity = max_quantity; + row.target_quantity = row.current_quantity + max_quantity; + row.status = "REDUCE_TO_ALLOWED_QUANTITY".to_string(); + row.reason = "按当前可用资金缩量;卖出资金确认后需重新预览".to_string(); + } + remaining_cash -= cost(row.delta_quantity)?; + position_budget -= cost(row.delta_quantity)?; + if row.current_quantity == Decimal::ZERO { + occupied_position_slots += 1; + } + } + estimated_buy_amount = rows + .iter() + .filter(|row| row.side == Some(OrderSide::Buy)) + .map(|row| { + let price = stock_pool_plan_price(row, "e_map, OrderSide::Buy)?; + Ok(row.delta_quantity * price + + fee_for(&row.symbol, OrderSide::Buy, row.delta_quantity * price)?) + }) + .collect::, String>>()? + .into_iter() + .sum(); + estimated_sell_amount = rows + .iter() + .filter(|row| row.side == Some(OrderSide::Sell)) + .map(|row| { + let price = stock_pool_plan_price(row, "e_map, OrderSide::Sell)?; + let gross = row.delta_quantity.abs() * price; + Ok((gross - fee_for(&row.symbol, OrderSide::Sell, gross)?).max(Decimal::ZERO)) + }) + .collect::, String>>()? + .into_iter() + .sum(); + let estimated_cash_after = available_cash - estimated_buy_amount + estimated_sell_amount; + Ok(StockPoolPlan { + market_timing, + rows, + budget, + estimated_buy_amount, + estimated_sell_amount, + estimated_cash_after, + requested_invest_ratio_bps: invest_ratio_bps, + effective_invest_ratio_bps, + retained_slots: reserved_protected_slots, + }) +} + +fn stock_pool_plan_price( + row: &StockPoolPlanRow, + quotes: &HashMap, + side: OrderSide, +) -> Result { + let quote = quotes + .get(&row.symbol) + .ok_or_else(|| format!("{} execution estimate missing", row.symbol))?; + let price = row + .limit_price + .or(if side == OrderSide::Buy { + quote.buy_sizing_price + } else { + quote.sell_sizing_price + }) + .unwrap_or(quote.last_price); + if price <= Decimal::ZERO { + return Err(format!("{} execution estimate invalid", row.symbol)); + } + Ok(price) +} + +pub fn normalize_stock_pool_members( + members: &[StockPoolMemberSpec], +) -> Result, String> { + let mut seen = BTreeSet::new(); + let mut result = Vec::with_capacity(members.len()); + for (index, member) in members.iter().enumerate() { + let symbol = normalize_stock_symbol(&member.symbol) + .ok_or_else(|| format!("invalid stock pool symbol: {}", member.symbol))?; + if !seen.insert(symbol.clone()) { + return Err(format!("stock pool contains duplicate symbol: {symbol}")); + } + if let Some(weight) = member.target_weight_bps + && !(0..=10_000).contains(&weight) + { + return Err(format!("{symbol}.target_weight_bps is out of range")); + } + for (name, ratio) in [ + ("stop_loss", member.stop_loss), + ("take_profit", member.take_profit), + ] { + if let Some(value) = ratio + && (value < Decimal::ZERO || value >= Decimal::ONE) + { + return Err(format!("{symbol}.{name} is out of range")); + } + } + let recommendation_reason = member.recommendation_reason.trim().to_string(); + if recommendation_reason.chars().count() > 1000 { + return Err(format!( + "{symbol}.recommendation_reason exceeds 1000 characters" + )); + } + let mut normalized = member.clone(); + normalized.symbol = symbol; + normalized.requested_order = index as i32; + normalized.recommendation_reason = recommendation_reason; + result.push(normalized); + } + let explicit = result + .iter() + .filter_map(|item| item.target_weight_bps) + .collect::>(); + if !explicit.is_empty() && explicit.len() != result.len() { + return Err("explicit stock pool weights must be set for every member".to_string()); + } + if explicit.iter().sum::() > 10_000 { + return Err("stock pool weights exceed 10000 bps".to_string()); + } + Ok(result) +} + +pub fn normalize_stock_symbol(raw: &str) -> Option { + let mut value = raw.trim().to_ascii_uppercase(); + for (from, to) in [ + (".XSHG", ".SH"), + (".XSHE", ".SZ"), + (".SHSE", ".SH"), + (".SZSE", ".SZ"), + (".BJSE", ".BJ"), + (".XBE", ".BJ"), + ] { + value = value.replace(from, to); + } + if !value.contains('.') && value.len() == 6 && value.chars().all(|ch| ch.is_ascii_digit()) { + let suffix = if ["600", "601", "603", "605", "688", "689"] + .iter() + .any(|prefix| value.starts_with(prefix)) + { + ".SH" + } else if ["000", "001", "002", "003", "300", "301"] + .iter() + .any(|prefix| value.starts_with(prefix)) + { + ".SZ" + } else if ["43", "83", "87", "88", "92"] + .iter() + .any(|prefix| value.starts_with(prefix)) + { + ".BJ" + } else { + "" + }; + value.push_str(suffix); + } + let (code, exchange) = value.split_once('.')?; + if code.len() != 6 + || !code.chars().all(|ch| ch.is_ascii_digit()) + || !matches!(exchange, "SH" | "SZ" | "BJ") + { + return None; + } + Some(format!("{code}.{exchange}")) +} + +fn normalize_symbol_list(values: &[String]) -> Result, String> { + let mut result = Vec::new(); + let mut seen = BTreeSet::new(); + for value in values { + if let Some(symbol) = normalize_stock_symbol(value) { + if seen.insert(symbol.clone()) { + result.push(symbol); + } + } else if !value.trim().is_empty() { + return Err(format!("invalid stock pool symbol: {value}")); + } + } + Ok(result) +} + +fn normalize_symbol_set(values: &[String]) -> Result, String> { + Ok(normalize_symbol_list(values)?.into_iter().collect()) +} + +pub fn stock_pool_execution_quote_symbols( + requested_symbols: &[String], + positions: &[Position], +) -> Vec { + let mut symbols = requested_symbols + .iter() + .filter_map(|symbol| normalize_stock_symbol(symbol)) + .collect::>(); + symbols.extend( + positions + .iter() + .filter(|position| position.quantity > Decimal::ZERO) + .filter_map(|position| normalize_stock_symbol(&position.symbol)), + ); + symbols.into_iter().collect() +} + +pub fn resolve_stock_pool_order_price( + rule: &StockPoolExecutionRule, + symbol: &str, + reference_price: Decimal, + side: OrderSide, + price_tick: Decimal, +) -> Result<(String, Option), String> { + if reference_price <= Decimal::ZERO || price_tick <= Decimal::ZERO || price_tick > Decimal::ONE + { + return Err(format!("{symbol} reference price is invalid")); + } + let symbol = + normalize_stock_symbol(symbol).ok_or_else(|| format!("invalid symbol {symbol}"))?; + match rule.pricing_mode.as_str() { + POOL_PRICE_FIRST_TICK | POOL_PRICE_CONDITION_THEN_MARKET => { + Ok(("market".to_string(), None)) + } + POOL_PRICE_FIXED_LIMIT => { + let price = rule + .fixed_prices + .get(&symbol) + .copied() + .or(rule.fixed_price) + .ok_or_else(|| format!("fixed_limit pricing has no price for {symbol}"))?; + if price <= Decimal::ZERO || (price / price_tick).fract() != Decimal::ZERO { + return Err(format!( + "fixed_limit price for {symbol} is not aligned with price_tick={price_tick}" + )); + } + Ok(("limit".to_string(), Some(price))) + } + POOL_PRICE_OPENING_AUCTION | POOL_PRICE_FORMULA_LIMIT | POOL_PRICE_CONDITION_THEN_LIMIT => { + let offset = if side == OrderSide::Buy { + rule.buy_offset_bps + } else { + rule.sell_offset_bps + }; + let raw_price = + reference_price * (Decimal::ONE + Decimal::from(offset) / Decimal::from(10_000)); + let price = if side == OrderSide::Buy { + (raw_price / price_tick).ceil() * price_tick + } else { + (raw_price / price_tick).floor() * price_tick + }; + if price <= Decimal::ZERO { + return Err(format!("{symbol} protection price is invalid")); + } + Ok(("limit".to_string(), Some(price))) + } + mode => Err(format!("stock pool pricing_mode={mode} is not supported")), + } +} + +fn floor_step(value: Decimal, step: Decimal) -> Decimal { + if value <= Decimal::ZERO || step <= Decimal::ZERO { + return Decimal::ZERO; + } + (value / step).floor() * step +} + +fn commission_for_notional( + notional: Decimal, + commission_rate: Decimal, + minimum_commission: Decimal, +) -> Decimal { + if notional <= Decimal::ZERO { + return Decimal::ZERO; + } + (notional * commission_rate).max(minimum_commission) +} + +fn max_affordable_buy_quantity_with_cost( + cash: Decimal, + requested: Decimal, + step: Decimal, + minimum: Decimal, + cost: &dyn Fn(Decimal) -> Result, +) -> Result { + if cash <= Decimal::ZERO || requested <= Decimal::ZERO || step <= Decimal::ZERO { + return Ok(Decimal::ZERO); + } + let mut low = Decimal::ZERO; + let mut high = floor_step(requested, step); + while high - low > step { + let mut mid = floor_step((low + high) / Decimal::from(2), step); + if mid <= low { + mid = low + step; + } + if mid >= minimum && cost(mid)? <= cash { + low = mid; + } else { + high = mid - step; + } + } + Ok(if high >= minimum && cost(high)? <= cash { + high + } else if low >= minimum && cost(low)? <= cash { + low + } else { + Decimal::ZERO + }) +} + +fn order_quantity_rules(snapshot: &MarketSnapshot) -> Result<(Decimal, Decimal), String> { + let rules = stock_pool_instrument_rules(snapshot)?; + Ok((rules.quantity_step, rules.minimum_buy_quantity)) +} + +pub fn normalize_stock_pool_execution_rule( + raw: Option<&Value>, + secondary_buy_condition: bool, + secondary_sell_condition: bool, +) -> Result { + let mut rule = match raw { + None | Some(Value::Null) => StockPoolExecutionRule::default(), + Some(value) => serde_json::from_value::(value.clone()) + .map_err(|err| format!("stock pool execution_rule is invalid: {err}"))?, + }; + rule.secondary_sell_condition = secondary_sell_condition; + rule.automatic_trade_protection.validate()?; + if rule.schema_version != STOCK_POOL_SCHEMA_VERSION { + return Err(format!( + "stock pool execution_rule.schema_version must be {}", + STOCK_POOL_SCHEMA_VERSION + )); + } + if !matches!( + rule.trigger_mode.as_str(), + POOL_TRIGGER_FIRST_TICK + | POOL_TRIGGER_TIME_WINDOW + | POOL_TRIGGER_CONDITION + | POOL_TRIGGER_SCHEDULED_BAR + ) { + return Err(format!( + "stock pool trigger_mode={} is not supported", + rule.trigger_mode + )); + } + if !matches!( + rule.pricing_mode.as_str(), + POOL_PRICE_FIRST_TICK + | POOL_PRICE_OPENING_AUCTION + | POOL_PRICE_FIXED_LIMIT + | POOL_PRICE_FORMULA_LIMIT + | POOL_PRICE_CONDITION_THEN_LIMIT + | POOL_PRICE_CONDITION_THEN_MARKET + ) { + return Err(format!( + "stock pool pricing_mode={} is not supported", + rule.pricing_mode + )); + } + if rule.pricing_mode == POOL_PRICE_OPENING_AUCTION { + return Err( + "stock pool opening_auction pricing requires a verified auction quote and is not available" + .to_string(), + ); + } + if !matches!( + rule.sell_trigger_mode.as_str(), + POOL_SELL_TARGET_DELTA | POOL_SELL_CONDITION + ) { + return Err(format!( + "stock pool sell_trigger_mode={} is not supported", + rule.sell_trigger_mode + )); + } + for (name, value) in [ + ("freeze_time", rule.freeze_time.as_str()), + ("window_start", rule.window_start.as_str()), + ("window_end", rule.window_end.as_str()), + ] { + if !is_hhmm(value) { + return Err(format!("stock pool {name} must use HH:MM")); + } + } + if rule.freeze_time > rule.window_start || rule.window_start >= rule.window_end { + return Err("stock pool execution window is not ordered".to_string()); + } + if rule.trigger_mode != POOL_TRIGGER_SCHEDULED_BAR { + let start = parse_hhmm_minutes(&rule.window_start).expect("validated HH:MM"); + let end = parse_hhmm_minutes(&rule.window_end).expect("validated HH:MM"); + if !(start..end).any(stock_pool_is_trading_minute) { + return Err( + "stock pool execution window has no continuous trading minutes".to_string(), + ); + } + } + if rule.trigger_mode == POOL_TRIGGER_CONDITION + || rule.pricing_mode == POOL_PRICE_CONDITION_THEN_LIMIT + || rule.pricing_mode == POOL_PRICE_CONDITION_THEN_MARKET + { + if rule.buy_condition.trim().is_empty() && !secondary_buy_condition { + return Err( + "stock pool condition trigger requires a buy quote, factor or event condition" + .to_string(), + ); + } + } + if !rule.buy_condition.trim().is_empty() + && parse_stock_pool_condition(&rule.buy_condition).is_none() + { + return Err("stock pool buy_condition is not supported".to_string()); + } + if rule.sell_trigger_mode == POOL_SELL_CONDITION { + if (rule.sell_condition.trim().is_empty() && !secondary_sell_condition) + || (!rule.sell_condition.trim().is_empty() + && parse_stock_pool_condition(&rule.sell_condition).is_none()) + { + return Err( + "stock pool condition sell mode requires a supported sell_condition".to_string(), + ); + } + } else if !rule.sell_condition.trim().is_empty() { + return Err("sell_condition requires sell_trigger_mode=condition".to_string()); + } + if rule.pricing_mode == POOL_PRICE_FIXED_LIMIT + && rule.fixed_price.is_none() + && rule.fixed_prices.is_empty() + { + return Err("fixed_limit pricing requires fixed_price or fixed_prices".to_string()); + } + let mut normalized_fixed_prices = BTreeMap::new(); + for (symbol, price) in &rule.fixed_prices { + let Some(normalized_symbol) = normalize_stock_symbol(symbol) else { + return Err(format!("fixed_prices contains invalid value for {symbol}")); + }; + if *price <= Decimal::ZERO { + return Err(format!("fixed_prices contains invalid value for {symbol}")); + } + normalized_fixed_prices.insert(normalized_symbol, *price); + } + rule.fixed_prices = normalized_fixed_prices; + if let Some(price) = rule.fixed_price { + if price <= Decimal::ZERO { + return Err("fixed_price must be positive".to_string()); + } + } + if !matches!(rule.time_in_force.to_ascii_uppercase().as_str(), "DAY") { + return Err("stock pool time_in_force currently only supports DAY".to_string()); + } + if !matches!( + rule.missed_window_policy.as_str(), + "reject" | "intraday_catchup" + ) { + return Err("stock pool missed_window_policy is not supported".to_string()); + } + if !(1..=1).contains(&rule.max_child_orders) { + return Err("stock pool max_child_orders must be 1".to_string()); + } + if !(-9999..=9999).contains(&rule.buy_offset_bps) + || !(-9999..=9999).contains(&rule.sell_offset_bps) + { + return Err("stock pool price offset is out of range".to_string()); + } + rule.time_in_force = rule.time_in_force.to_ascii_uppercase(); + rule.buy_condition = rule.buy_condition.trim().to_string(); + rule.sell_condition = rule.sell_condition.trim().to_string(); + Ok(rule) +} + +fn is_hhmm(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 5 + && bytes[2] == b':' + && bytes[0..2].iter().all(u8::is_ascii_digit) + && bytes[3..5].iter().all(u8::is_ascii_digit) + && value[0..2].parse::().is_ok_and(|hour| hour < 24) + && value[3..5].parse::().is_ok_and(|minute| minute < 60) +} + +pub fn parse_stock_pool_condition(value: &str) -> Option<(String, String, String, Decimal)> { + let text = value.trim(); + let (scope, expression) = text + .split_once(':') + .map_or(("all", text), |(scope, expression)| (scope, expression)); + // Legacy `all:` meant per-symbol evaluation. Preserve it; actual + // aggregation is the explicit buy/sell_condition_scope contract. + if !scope.eq_ignore_ascii_case("all") { + return None; + } + let operators = [">=", "<=", "==", "!=", ">", "<"]; + let (field, operator, threshold) = operators.iter().find_map(|operator| { + let index = expression.find(operator)?; + Some(( + expression[..index].trim(), + *operator, + expression[index + operator.len()..].trim(), + )) + })?; + if !matches!( + field.to_ascii_lowercase().as_str(), + "price" | "last" | "change_pct" | "volume" | "amount" | "bid1" | "ask1" + ) { + return None; + } + let value = threshold.parse::().ok()?; + if (!field.eq_ignore_ascii_case("change_pct") && value < Decimal::ZERO) + || (field.eq_ignore_ascii_case("volume") && value.fract() != Decimal::ZERO) + { + return None; + } + Some(( + scope.to_ascii_lowercase(), + field.to_ascii_lowercase(), + operator.to_string(), + value, + )) +} + +fn parse_hhmm_minutes(value: &str) -> Option { + Some(value[0..2].parse::().ok()? * 60 + value[3..5].parse::().ok()?) +} + +pub fn stock_pool_is_trading_minute(minute: u32) -> bool { + (9 * 60 + 30..11 * 60 + 30).contains(&minute) || (13 * 60..15 * 60 + 31).contains(&minute) +} + +pub fn stock_pool_condition_matches( + condition: &str, + quote: &MarketSnapshot, +) -> Result { + let Some((_scope, field, operator, threshold)) = parse_stock_pool_condition(condition) else { + return Err("stock pool condition is not supported".to_string()); + }; + let observed = match field.as_str() { + "price" | "last" => quote.last_price, + "change_pct" => quote + .prev_close + .filter(|value| *value > Decimal::ZERO) + .map(|prev| (quote.last_price / prev - Decimal::ONE) * Decimal::from(100)) + .ok_or_else(|| "condition requires prev_close".to_string())?, + "volume" => quote + .volume + .ok_or_else(|| "condition requires volume".to_string())?, + "amount" => quote + .turnover + .ok_or_else(|| "condition requires amount".to_string())?, + "bid1" => quote + .bid_price_1 + .ok_or_else(|| "condition requires bid1".to_string())?, + "ask1" => quote + .ask_price_1 + .ok_or_else(|| "condition requires ask1".to_string())?, + _ => return Err("stock pool condition field is not supported".to_string()), + }; + if (matches!(field.as_str(), "price" | "last" | "bid1" | "ask1") && observed <= Decimal::ZERO) + || (matches!(field.as_str(), "volume" | "amount") && observed < Decimal::ZERO) + || (field == "change_pct" && quote.last_price <= Decimal::ZERO) + { + return Err(format!("condition requires valid {field}")); + } + Ok(match operator.as_str() { + ">=" => observed >= threshold, + "<=" => observed <= threshold, + "==" => observed == threshold, + "!=" => observed != threshold, + ">" => observed > threshold, + "<" => observed < threshold, + _ => false, + }) +} + +fn quote_condition_results( + condition: &str, + scope: Option, + symbols: &[String], + quotes: &HashMap, +) -> Result, String> { + if condition.trim().is_empty() { + return Ok(symbols + .iter() + .map(|symbol| (symbol.clone(), true)) + .collect()); + } + let values = symbols + .iter() + .map(|symbol| { + let quote = quotes + .get(symbol) + .ok_or_else(|| format!("quote condition facts missing:{symbol}"))?; + Ok(( + symbol.clone(), + stock_pool_condition_matches(condition, quote)?, + )) + }) + .collect::, String>>()?; + let aggregate = match scope.unwrap_or_default() { + QuoteConditionScope::PerSymbol => return Ok(values), + QuoteConditionScope::AllTargets => { + !values.is_empty() && values.values().all(|value| *value) + } + QuoteConditionScope::AnyTarget => values.values().any(|value| *value), + }; + Ok(symbols + .iter() + .map(|symbol| (symbol.clone(), aggregate)) + .collect()) +} + +#[cfg(test)] +#[path = "stock_pool_execution_tests.rs"] +mod tests; + +#[path = "stock_pool_frozen.rs"] +mod frozen; +#[path = "stock_pool_index_cap.rs"] +mod index_cap; diff --git a/crates/fidc-core/src/stock_pool_execution_tests.rs b/crates/fidc-core/src/stock_pool_execution_tests.rs new file mode 100644 index 0000000..973e1f7 --- /dev/null +++ b/crates/fidc-core/src/stock_pool_execution_tests.rs @@ -0,0 +1,1155 @@ +use super::*; +use serde_json::json; + +fn symbol(index: usize) -> String { + format!("{index:06}.SZ") +} +fn members(count: usize) -> Vec { + (1..=count) + .map(|i| StockPoolMemberSpec { + symbol: symbol(i), + requested_order: i as i32, + recommendation_reason: String::new(), + target_weight_bps: None, + stop_loss: None, + take_profit: None, + }) + .collect() +} +fn selection(count: usize, target: usize) -> StockPoolSelection { + let symbols = (1..=count).map(symbol).collect::>(); + StockPoolSelection { + trade_date: NaiveDate::from_ymd_opt(2026, 9, 11).unwrap(), + requested_symbols: symbols.clone(), + normal_trading_symbols: symbols.clone(), + risk_eligible_symbols: symbols.clone(), + final_symbols: symbols.into_iter().take(target).collect(), + exclusion_reasons: BTreeMap::new(), + inherited_from_generation: None, + explicit_empty: false, + generation: Some("test".into()), + } +} +fn quotes(count: usize) -> Vec { + (1..=count) + .map(|i| MarketSnapshot { + symbol: symbol(i), + last_price: Decimal::from(10), + prev_close: Some(Decimal::from(10)), + volume: Some(Decimal::from(1_000_000)), + turnover: Some(Decimal::from(10_000_000)), + bid_price_1: Some(Decimal::from(10)), + ask_price_1: Some(Decimal::from(10)), + is_kcb: Some(false), + instrument_rules: None, + buy_sizing_price: None, + sell_sizing_price: None, + }) + .collect() +} +fn position(index: usize) -> Position { + Position { + symbol: symbol(index), + quantity: Decimal::from(1000), + closable_quantity: Decimal::from(1000), + average_cost: Decimal::from(10), + } +} + +fn suspended_plan(count: usize, explicit: bool, outside: bool) -> Result { + suspended_plan_with_selection(count, explicit, outside, false) +} +fn suspended_plan_with_selection( + count: usize, + explicit: bool, + outside: bool, + exclude_paused: bool, +) -> Result { + let mut chosen = selection(count, count); + let mut pool = members(count); + if explicit { + for (index, member) in pool.iter_mut().enumerate() { + member.target_weight_bps = + Some(10_000 / count as i32 + i32::from(index < 10_000 % count)); + } + } + let paused = if outside { + count + 1 + } else if count == 24 { + 15 + } else { + count + }; + let mut market = quotes(count); + market.retain(|quote| quote.symbol != symbol(paused)); + if count == 24 { + pool[1].take_profit = Some(Decimal::new(16, 2)); + market[1].last_price = Decimal::from(12); + } + let mut constraints = StockPoolDecisionConstraints { + execution_date: Some(chosen.trade_date), + frozen_positions: [( + symbol(paused), + FrozenStockPoolPosition { + trade_date: chosen.trade_date, + reason: "paused".into(), + valuation_price: Decimal::from(10), + }, + )] + .into(), + ..Default::default() + }; + if outside { + constraints + .prior_target_weights + .insert(symbol(paused), 5_000); + chosen.final_symbols.truncate(1); + } + if exclude_paused { + chosen.final_symbols.retain(|item| item != &symbol(paused)); + chosen + .normal_trading_symbols + .retain(|item| item != &symbol(paused)); + chosen + .risk_eligible_symbols + .retain(|item| item != &symbol(paused)); + constraints.prior_target_weights.insert(symbol(paused), 417); + } + build_stock_pool_target_plan_with_constraints( + &chosen, + &pool, + &StockPoolExecutionRule::default(), + &AccountSnapshot { + total_equity: Decimal::from(1_000_000), + cash: Decimal::from(700_000), + frozen_cash: Decimal::ZERO, + }, + &(1..=count.max(paused)).map(position).collect::>(), + &market, + 10_000, + Decimal::ZERO, + "reduce_to_zero_when_sellable", + "full_rebalance", + &constraints, + "paused", + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + ) +} + +#[test] +fn paused_held_weight_is_not_replenished_after_another_stock_exits() { + for explicit in [false, true] { + let plan = suspended_plan(24, explicit, false).unwrap(); + let paused = plan + .rows + .iter() + .find(|row| row.symbol == symbol(15)) + .unwrap(); + assert_eq!(paused.target_weight_bps, 417); + assert_eq!(paused.status, "MARKET_SUSPENDED"); + assert_eq!(paused.current_quantity, paused.target_quantity); + assert!( + paused.side.is_none() && paused.order_type.is_none() && paused.source_intent.is_none() + ); + assert_eq!( + plan.rows + .iter() + .filter(|row| row.symbol != symbol(15)) + .map(|row| row.target_weight_bps) + .sum::(), + 9_583 + ); + let exited = plan + .rows + .iter() + .find(|row| row.symbol == symbol(2)) + .unwrap(); + assert_eq!(exited.target_weight_bps, 0); + assert_eq!(exited.side, Some(OrderSide::Sell)); + } +} + +#[test] +fn selection_excluding_paused_stock_keeps_the_same_24_stock_budget_as_broker_execution() { + for explicit in [false, true] { + let broker = suspended_plan_with_selection(24, explicit, false, false).unwrap(); + let online = suspended_plan_with_selection(24, explicit, false, true).unwrap(); + let weights = |plan: StockPoolPlan| { + plan.rows + .into_iter() + .map(|row| (row.symbol, row.target_weight_bps)) + .collect::>() + }; + assert_eq!(weights(broker), weights(online)); + } +} + +#[test] +fn index_cap_cannot_sell_a_paused_holding_or_use_its_budget_as_free_cash() { + let date = selection(1, 1).trade_date; + let current = [ + (symbol(1), (1000.into(), 1000.into(), 10.into())), + (symbol(2), (1000.into(), 1000.into(), 10.into())), + ] + .into(); + let pool = members(2); + let members = pool + .iter() + .map(|member| (member.symbol.clone(), member)) + .collect(); + let market = quotes(1); + let quotes = market + .iter() + .map(|quote| (quote.symbol.clone(), quote)) + .collect(); + let frozen = [( + symbol(2), + FrozenStockPoolPosition { + trade_date: date, + reason: "paused".into(), + valuation_price: 10.into(), + }, + )] + .into(); + let targets = index_cap::remaining_index_targets( + ¤t, + &members, + &BTreeMap::new(), + &[], + "es, + &frozen, + 10000.into(), + ) + .unwrap(); + assert!(!targets.contains_key(&symbol(2))); + assert_eq!(targets[&symbol(1)].quantity, Decimal::ZERO); +} + +#[test] +fn paused_outside_holding_retains_prior_budget_without_an_execution_quote() { + let plan = suspended_plan(1, false, true).unwrap(); + let paused = plan + .rows + .iter() + .find(|row| row.symbol == symbol(2)) + .unwrap(); + assert_eq!(paused.target_weight_bps, 5_000); + assert_eq!(paused.status, "MARKET_SUSPENDED"); + assert_eq!(paused.delta_quantity, Decimal::ZERO); + assert!(plan.rows.iter().all(|row| row.side.is_none())); +} + +#[test] +fn frozen_valuation_is_dated_and_cannot_turn_an_unknown_gap_into_a_pause() { + let chosen = selection(1, 1); + let current = [( + symbol(1), + (Decimal::from(100), Decimal::from(100), Decimal::from(10)), + )] + .into(); + let mut constraints = StockPoolDecisionConstraints { + execution_date: Some(chosen.trade_date), + frozen_positions: [( + symbol(1), + FrozenStockPoolPosition { + trade_date: chosen.trade_date, + reason: "paused".into(), + valuation_price: Decimal::from(10), + }, + )] + .into(), + ..Default::default() + }; + frozen::validate(chosen.trade_date, &constraints, ¤t).unwrap(); + constraints + .frozen_positions + .get_mut(&symbol(1)) + .unwrap() + .trade_date = chosen.trade_date.pred_opt().unwrap(); + assert!(frozen::validate(chosen.trade_date, &constraints, ¤t).is_err()); + constraints + .frozen_positions + .get_mut(&symbol(1)) + .unwrap() + .trade_date = chosen.trade_date; + constraints + .frozen_positions + .get_mut(&symbol(1)) + .unwrap() + .reason = "missing".into(); + assert!(frozen::validate(chosen.trade_date, &constraints, ¤t).is_err()); + assert!(frozen::valuation(&symbol(1), &HashMap::new(), &BTreeMap::new()).is_err()); +} + +#[test] +fn quote_condition_number_contract_rejects_invalid_facts_and_fractional_shares() { + for value in ["volume>=1.2", "amount>-1", "priceinf"] { + assert!(parse_stock_pool_condition(value).is_none(), "{value}"); + } + for value in [ + "price>=1.234", + "volume>=100.0", + "change_pct<=-2.75", + "amount>=0", + ] { + assert!(parse_stock_pool_condition(value).is_some(), "{value}"); + } + let mut quote = quotes(1).remove(0); + quote.bid_price_1 = Some(Decimal::ZERO); + assert!(stock_pool_condition_matches("bid1<=10", "e).is_err()); + quote.ask_price_1 = None; + assert!(stock_pool_condition_matches("ask1<=10", "e).is_err()); + quote.volume = Some(-Decimal::ONE); + assert!(stock_pool_condition_matches("volume<100", "e).is_err()); +} + +#[test] +fn instrument_price_tick_and_quantity_units_are_not_a_two_decimal_stock_assumption() { + let rule = StockPoolExecutionRule { + buy_offset_bps: 1, + sell_offset_bps: -1, + ..Default::default() + }; + for (tick, buy, sell) in [ + (Decimal::new(1, 2), "1.24", "1.23"), + (Decimal::new(1, 3), "1.235", "1.233"), + (Decimal::new(5, 3), "1.235", "1.230"), + ] { + let price = |side| { + resolve_stock_pool_order_price(&rule, "510300.SH", Decimal::new(1234, 3), side, tick) + .unwrap() + .1 + .unwrap() + }; + assert_eq!(price(OrderSide::Buy), buy.parse::().unwrap()); + assert_eq!(price(OrderSide::Sell), sell.parse::().unwrap()); + } + let fixed = StockPoolExecutionRule { + pricing_mode: POOL_PRICE_FIXED_LIMIT.into(), + fixed_price: Some(Decimal::new(1234, 3)), + ..Default::default() + }; + assert!( + resolve_stock_pool_order_price( + &fixed, + "510300.SH", + Decimal::ONE, + OrderSide::Buy, + Decimal::new(1, 3) + ) + .is_ok() + ); + assert!( + resolve_stock_pool_order_price( + &fixed, + "510300.SH", + Decimal::ONE, + OrderSide::Buy, + Decimal::new(1, 2) + ) + .unwrap_err() + .contains("not aligned") + ); + let mut market = quotes(1).remove(0); + market.is_kcb = None; + market.instrument_rules = Some(StockPoolInstrumentRules { + price_tick: Decimal::new(1, 3), + quantity_step: 10.into(), + minimum_buy_quantity: 50.into(), + }); + assert_eq!( + order_quantity_rules(&market).unwrap(), + (10.into(), 50.into()) + ); + market.instrument_rules.as_mut().unwrap().quantity_step = Decimal::new(5, 1); + assert!(order_quantity_rules(&market).is_err()); +} +fn run(mode: &str, positions: &[Position], settings: &Value) -> StockPoolPlan { + let constraints = stock_pool_constraints_from_configuration(settings, &json!({})).unwrap(); + build_stock_pool_target_plan_with_constraints( + &selection(3, 2), + &members(3), + &StockPoolExecutionRule::default(), + &AccountSnapshot { + total_equity: Decimal::from(30000), + cash: Decimal::from(20000), + frozen_cash: Decimal::ZERO, + }, + positions, + "es(3), + 10000, + Decimal::ZERO, + "hold", + mode, + &constraints, + "test", + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + ) + .unwrap() +} + +#[test] +fn all_membership_and_weight_combinations_use_the_same_target_planner() { + for (membership, weights, expected_hold, expected_delta) in [ + ("follow_candidates", true, 1, 500_i64), + ("follow_candidates", false, 1, 0), + ("retain_holdings", true, 3, 500), + ("retain_holdings", false, 3, 0), + ] { + let policy = json!({"target_holding_count":2,"portfolio_policy":{"schema_version":1,"membership":membership,"rebalance_weights":weights}}); + let plan = run("full_rebalance", &[position(expected_hold)], &policy); + let held = plan + .rows + .iter() + .find(|row| row.symbol == symbol(expected_hold)) + .unwrap(); + assert_eq!( + held.delta_quantity, + Decimal::from(expected_delta), + "{membership}/{weights}: {plan:?}" + ); + assert!( + plan.rows + .iter() + .filter(|row| row.target_weight_bps > 0) + .count() + <= 2 + ); + } + let latest = run( + "full_rebalance", + &[position(3)], + &json!({"target_holding_count":2,"top_n_rebalance_policy":"full_rebalance"}), + ); + assert_eq!( + latest + .rows + .iter() + .find(|row| row.symbol == symbol(3)) + .unwrap() + .target_quantity, + Decimal::ZERO + ); +} + +#[test] +fn reserve_slots_keep_requested_and_effective_exposure_distinct() { + let settings = json!({"target_holding_count":30,"reserve_cash_slots":1}); + let constraints = stock_pool_constraints_from_configuration(&settings, &json!({})).unwrap(); + let plan = build_stock_pool_target_plan_with_constraints( + &selection(20, 20), + &members(20), + &StockPoolExecutionRule::default(), + &AccountSnapshot { + total_equity: Decimal::from(310000), + cash: Decimal::from(310000), + frozen_cash: Decimal::ZERO, + }, + &[], + "es(20), + 10000, + Decimal::ZERO, + "hold", + "full_rebalance", + &constraints, + "seats", + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + ) + .unwrap(); + assert_eq!(plan.requested_invest_ratio_bps, 10000); + assert_eq!(plan.budget, Decimal::from(200000)); + assert!( + plan.rows + .iter() + .all(|row| row.target_quantity == Decimal::from(1000)) + ); + assert_eq!( + plan.effective_invest_ratio_bps.round_dp(2), + Decimal::new(645161, 2) + ); +} + +#[test] +fn optional_configuration_and_conflicting_aliases_are_explicit() { + assert!(stock_pool_constraints_from_configuration(&Value::Null, &Value::Null).is_ok()); + assert!( + stock_pool_constraints_from_configuration(&json!({"reserve_cash_slots":1}), &json!({})) + .is_err() + ); + assert!( + stock_pool_constraints_from_configuration(&json!({"target_holding_count":2.5}), &json!({})) + .is_err() + ); + assert!( + stock_pool_constraints_from_configuration( + &json!({"top_n_rebalance_policy":false}), + &json!({}) + ) + .is_err() + ); + let conflicting = json!({"top_n_rebalance_policy":"full_rebalance","portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":true}}); + assert!(stock_pool_constraints_from_configuration(&conflicting, &json!({})).is_err()); + for stop in [json!({}), json!({"stop_loss":null,"take_profit":0})] { + let config = stock_pool_constraints_from_configuration(&json!({}), &stop).unwrap(); + assert_eq!(config.default_stop_loss, None); + assert_eq!(config.default_take_profit, None); + } +} + +fn condition_plan( + selection: &StockPoolSelection, + rule: &StockPoolExecutionRule, + positions: &[Position], + quotes: &[MarketSnapshot], + constraints: &StockPoolDecisionConstraints, +) -> StockPoolPlan { + let held_value = positions + .iter() + .map(|position| { + position.quantity + * quotes + .iter() + .find(|quote| quote.symbol == position.symbol) + .unwrap() + .last_price + }) + .sum::(); + build_stock_pool_target_plan_with_constraints( + selection, + &members(3), + rule, + &AccountSnapshot { + total_equity: 40000.into(), + cash: Decimal::from(40000) - held_value, + frozen_cash: Decimal::ZERO, + }, + positions, + quotes, + 10000, + Decimal::ZERO, + "hold", + "full_rebalance", + constraints, + "conditions", + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + ) + .unwrap() +} + +#[test] +fn quote_condition_scopes_are_evaluated_before_target_orders() { + let mut market = quotes(3); + market[1].last_price = 20.into(); + for (scope, expected) in [ + (QuoteConditionScope::PerSymbol, vec![symbol(2)]), + (QuoteConditionScope::AnyTarget, vec![symbol(1), symbol(2)]), + (QuoteConditionScope::AllTargets, vec![]), + ] { + let rule = normalize_stock_pool_execution_rule( + Some(&json!({ + "trigger_mode":"condition", "buy_condition":"price>=15", "buy_condition_scope":scope + })), + false, + false, + ) + .unwrap(); + let plan = condition_plan( + &selection(2, 2), + &rule, + &[], + &market, + &StockPoolDecisionConstraints::default(), + ); + let bought = plan + .rows + .iter() + .filter(|row| row.side == Some(OrderSide::Buy)) + .map(|row| row.symbol.clone()) + .collect::>(); + assert_eq!(bought, expected, "{scope:?}: {plan:?}"); + assert!( + plan.rows + .iter() + .filter(|row| row.status == "BUY_CONDITION_PENDING") + .all(|row| row.delta_quantity == Decimal::ZERO) + ); + } +} + +#[test] +fn sell_quote_condition_creates_exit_and_unmet_holding_keeps_its_slot() { + let mut selected = selection(3, 2); + selected.final_symbols = vec![symbol(3)]; + let mut market = quotes(3); + market[0].last_price = 9.into(); + market[1].last_price = 11.into(); + let rule = normalize_stock_pool_execution_rule( + Some(&json!({ + "sell_trigger_mode":"condition", "sell_condition":"price<10" + })), + false, + false, + ) + .unwrap(); + let constraints = + stock_pool_constraints_from_configuration(&json!({"target_holding_count":2}), &Value::Null) + .unwrap(); + let plan = condition_plan( + &selected, + &rule, + &[position(1), position(2)], + &market, + &constraints, + ); + let row = |index| { + plan.rows + .iter() + .find(|row| row.symbol == symbol(index)) + .unwrap() + }; + assert_eq!(row(1).target_quantity, Decimal::ZERO, "{plan:?}"); + assert_eq!(row(1).side, Some(OrderSide::Sell), "{plan:?}"); + assert_eq!(row(2).delta_quantity, Decimal::ZERO, "{plan:?}"); + assert_eq!(row(2).status, "SELL_CONDITION_PENDING", "{plan:?}"); + assert_eq!(row(3).status, "DEFERRED_POSITION_SLOTS", "{plan:?}"); + let settled = condition_plan(&selected, &rule, &[position(2)], &market, &constraints); + let replacement = settled + .rows + .iter() + .find(|row| row.symbol == symbol(3)) + .unwrap(); + assert_eq!(replacement.side, Some(OrderSide::Buy), "{settled:?}"); + assert!( + replacement.target_value <= settled.budget - Decimal::from(11000), + "{settled:?}" + ); +} + +#[test] +fn native_sell_and_quote_conditions_are_and_but_stop_and_protection_remain_independent() { + let market = quotes(3); + let rule = normalize_stock_pool_execution_rule( + Some(&json!({ + "sell_trigger_mode":"condition","sell_condition":"price<11" + })), + false, + true, + ) + .unwrap(); + let mut constraints = + stock_pool_constraints_from_configuration(&json!({"target_holding_count":2}), &Value::Null) + .unwrap(); + constraints.position_target_bps.insert(symbol(1), 0); + let mut native_selection = selection(3, 2); + native_selection.final_symbols = vec![symbol(2), symbol(3)]; + let plan = condition_plan( + &native_selection, + &rule, + &[position(1), position(2)], + &market, + &constraints, + ); + assert_eq!( + plan.rows + .iter() + .find(|r| r.symbol == symbol(1)) + .unwrap() + .side, + Some(OrderSide::Sell), + "{plan:?}" + ); + assert_ne!( + plan.rows + .iter() + .find(|r| r.symbol == symbol(2)) + .unwrap() + .side, + Some(OrderSide::Sell), + "a failed sell condition must not create a sell; independent buys remain allowed: {plan:?}" + ); + // A stop is an independent exit, not gated by an ordinary sell expression. + constraints.position_target_bps.clear(); + constraints.default_stop_loss = Some(Decimal::new(5, 2)); + let mut adverse = market.clone(); + adverse[1].last_price = 9.into(); + let blocked_quote = normalize_stock_pool_execution_rule( + Some(&json!({"sell_trigger_mode":"condition","sell_condition":"price>100"})), + false, + true, + ) + .unwrap(); + let stop = condition_plan( + &selection(3, 2), + &blocked_quote, + &[position(2)], + &adverse, + &constraints, + ); + assert_eq!( + stop.rows + .iter() + .find(|r| r.symbol == symbol(2)) + .unwrap() + .side, + Some(OrderSide::Sell), + "{stop:?}" + ); +} + +#[test] +fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() { + let mut constraints = StockPoolDecisionConstraints::default(); + constraints.same_day_sold_symbols.insert(symbol(1)); + let plan = condition_plan( + &selection(1, 1), + &StockPoolExecutionRule::default(), + &[position(1)], + "es(1), + &constraints, + ); + assert_eq!(plan.rows[0].target_quantity, 1000.into(), "{plan:?}"); + assert_eq!(plan.rows[0].delta_quantity, Decimal::ZERO, "{plan:?}"); + assert_eq!(plan.rows[0].side, None, "{plan:?}"); +} + +#[test] +fn sell_cooldown_does_not_turn_disabled_weight_rebalancing_back_on() { + let mut constraints = StockPoolDecisionConstraints::default(); + constraints.same_day_sold_symbols.insert(symbol(1)); + constraints.portfolio_policy = Some(StockPoolPortfolioPolicy { + schema_version: 1, + membership: MembershipPolicy::FollowCandidates, + rebalance_weights: false, + }); + let plan = build_stock_pool_target_plan_with_constraints( + &selection(1, 1), + &members(1), + &StockPoolExecutionRule::default(), + &AccountSnapshot { + total_equity: 10000.into(), + cash: Decimal::ZERO, + frozen_cash: Decimal::ZERO, + }, + &[position(1)], + "es(1), + 5000, + Decimal::ZERO, + "hold", + "preserve_existing", + &constraints, + "after-partial-fill", + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + ) + .unwrap(); + assert_eq!(plan.rows[0].status, "PRESERVED_EXISTING_POSITION"); + assert_eq!(plan.rows[0].target_quantity, 1000.into()); + assert_eq!(plan.rows[0].side, None); +} + +#[test] +fn market_quantity_budget_includes_execution_slippage_and_fees() { + let mut market = quotes(1); + market[0].buy_sizing_price = Some(Decimal::new(1002, 2)); + let rule = normalize_stock_pool_execution_rule( + Some(&json!({"pricing_mode":"first_tick"})), + false, + false, + ) + .unwrap(); + for (cash, quantity) in [(1006, 0), (1007, 100)] { + let plan = build_stock_pool_target_plan_with_constraints( + &selection(1, 1), + &members(1), + &rule, + &AccountSnapshot { + total_equity: cash.into(), + cash: cash.into(), + frozen_cash: Decimal::ZERO, + }, + &[], + &market, + 10000, + Decimal::ZERO, + "hold", + "full_rebalance", + &StockPoolDecisionConstraints::default(), + "fees", + Decimal::ZERO, + 5.into(), + Decimal::ZERO, + ) + .unwrap(); + assert_eq!(plan.rows[0].target_quantity, quantity.into(), "{plan:?}"); + assert_eq!( + plan.estimated_buy_amount, + if quantity == 0 { + Decimal::ZERO + } else { + 1007.into() + }, + "{plan:?}" + ); + assert!(plan.estimated_cash_after >= Decimal::ZERO, "{plan:?}"); + } +} + +#[test] +fn confirmed_holdings_not_unsettled_sells_determine_remaining_position_budget() { + for (held, closable, expect_buy) in [(900, 0, 0), (900, 800, 0), (100, 0, 100)] { + let mut stock = position(1); + stock.quantity = held.into(); + stock.closable_quantity = closable.into(); + let plan = build_stock_pool_target_plan_with_constraints( + &selection(2, 2), + &members(2), + &StockPoolExecutionRule::default(), + &AccountSnapshot { + total_equity: 10000.into(), + cash: 10000.into(), + frozen_cash: Decimal::ZERO, + }, + &[stock], + "es(2), + 2000, + Decimal::ZERO, + "hold", + "full_rebalance", + &StockPoolDecisionConstraints { + target_holding_count: Some(2), + ..Default::default() + }, + "budget", + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + ) + .unwrap(); + let buy = plan + .rows + .iter() + .find(|row| row.symbol == symbol(2)) + .unwrap(); + assert_eq!(buy.delta_quantity, expect_buy.into(), "{plan:?}"); + if expect_buy == 0 { + assert_eq!(buy.status, "DEFERRED_POSITION_BUDGET", "{plan:?}"); + } + } +} + +#[test] +fn index_exposure_and_reserved_seats_multiply_without_rounding_the_budget_early() { + use crate::stock_pool_index_policy::{IndexClose, MarketTimingInput}; + let config = json!({"target_holding_count":2,"reserve_cash_slots":1,"market_timing":{ + "enabled":true,"index_code":"000300.SH","fast_window":2,"slow_window":3,"volatility_window":2,"drawdown_window":3, + "bull_exposure":1.,"bear_exposure":0.2,"volatility_threshold":0.1,"volatility_cap":0.1,"drawdown_threshold":0.1,"drawdown_cap":0.1}}); + let mut constraints = stock_pool_constraints_from_configuration(&config, &Value::Null).unwrap(); + let selection = selection(2, 2); + let account = AccountSnapshot { + total_equity: 30000.into(), + cash: 30000.into(), + frozen_cash: Decimal::ZERO, + }; + let calculate = |constraints: &StockPoolDecisionConstraints| { + build_stock_pool_target_plan_with_constraints( + &selection, + &members(2), + &StockPoolExecutionRule::default(), + &account, + &[], + "es(2), + 10000, + Decimal::ZERO, + "hold", + "full_rebalance", + constraints, + "index", + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + ) + }; + assert!( + calculate(&constraints) + .unwrap_err() + .contains("verified_completed_index_input_required") + ); + let dates = [9, 10, 11] + .map(|day| NaiveDate::from_ymd_opt(2026, 9, day).unwrap()) + .to_vec(); + constraints.market_timing_input = Some(MarketTimingInput { + index_code: "000300.SH".into(), + as_of_date: selection.trade_date, + official_dates: dates.clone(), + closes: dates + .into_iter() + .map(|date| IndexClose { date, close: 100. }) + .collect(), + }); + let plan = calculate(&constraints).unwrap(); + assert_eq!(plan.requested_invest_ratio_bps, 10000); + assert_eq!(plan.market_timing.as_ref().unwrap().exposure, 0.2); + assert_eq!(plan.budget, 4000.into()); + assert!( + plan.rows + .iter() + .all(|row| row.target_quantity == 200.into()), + "{plan:?}" + ); + constraints + .market_timing_policy + .as_mut() + .unwrap() + .bear_exposure = Some(0.); + assert_eq!(calculate(&constraints).unwrap().budget, Decimal::ZERO); +} + +fn index_plan( + exposure: f64, + blocked: bool, + ordinary_sell: bool, + rebalance: bool, + protected: bool, +) -> StockPoolPlan { + let policy = json!({"target_holding_count":2,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":rebalance},"market_timing":{ + "enabled":true,"index_code":"000300.SH","fast_window":2,"slow_window":3,"volatility_window":2,"drawdown_window":3, + "bull_exposure":exposure,"bear_exposure":exposure,"volatility_threshold":1.,"volatility_cap":1.,"drawdown_threshold":1.,"drawdown_cap":1.}}); + let mut constraints = stock_pool_constraints_from_configuration(&policy, &Value::Null).unwrap(); + if protected { + constraints.automatic_permissions.insert( + symbol(1), + crate::holding_policy::AutomaticTradePermission { + sell_denial: Some("buy_fill_protection"), + ..Default::default() + }, + ); + } + let days = [9, 10, 11] + .map(|day| NaiveDate::from_ymd_opt(2026, 9, day).unwrap()) + .to_vec(); + constraints.market_timing_input = Some(crate::stock_pool_index_policy::MarketTimingInput { + index_code: "000300.SH".into(), + as_of_date: days[2], + official_dates: days.clone(), + closes: days + .iter() + .map(|date| crate::stock_pool_index_policy::IndexClose { + date: *date, + close: 100., + }) + .collect(), + }); + let mut first = position(1); + first.quantity = 8000.into(); + first.closable_quantity = if blocked { Decimal::ZERO } else { 8000.into() }; + let mut second = position(2); + second.quantity = 2000.into(); + second.closable_quantity = 2000.into(); + let rule = normalize_stock_pool_execution_rule( + Some(&if ordinary_sell { + json!({"sell_trigger_mode":"condition","sell_condition":"price>1000"}) + } else { + json!({}) + }), + false, + false, + ) + .unwrap(); + build_stock_pool_target_plan_with_constraints( + &selection(2, 2), + &members(2), + &rule, + &AccountSnapshot { + total_equity: 100000.into(), + cash: Decimal::ZERO, + frozen_cash: Decimal::ZERO, + }, + &[first, second], + "es(2), + 10000, + Decimal::ZERO, + "hold", + "preserve_existing", + &constraints, + "index-cap", + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + ) + .unwrap() +} + +fn preserved_index_plan(exposure: f64, blocked: bool, ordinary_sell: bool) -> StockPoolPlan { + index_plan(exposure, blocked, ordinary_sell, false, false) +} + +#[test] +fn index_cap_reduces_existing_proportions_without_enabling_weight_rebalancing() { + for ordinary_sell in [false, true] { + let plan = preserved_index_plan(0.3, false, ordinary_sell); + let first = plan + .rows + .iter() + .find(|row| row.symbol == symbol(1)) + .unwrap(); + let second = plan + .rows + .iter() + .find(|row| row.symbol == symbol(2)) + .unwrap(); + assert_eq!(first.target_quantity, 2400.into(), "{plan:?}"); + assert_eq!(second.target_quantity, 600.into(), "{plan:?}"); + assert!( + plan.rows + .iter() + .all(|row| row.side == Some(OrderSide::Sell) && row.reason.contains("指数")), + "{plan:?}" + ); + assert_eq!(plan.budget, 30000.into()); + } + assert!( + preserved_index_plan(1., false, false) + .rows + .iter() + .all(|row| row.side.is_none()), + "a higher cap does not rebalance completed holdings" + ); +} + +#[test] +fn index_cap_keeps_non_sellable_quantity_and_defers_the_remaining_risk_target() { + let plan = preserved_index_plan(0.3, true, false); + let first = plan + .rows + .iter() + .find(|row| row.symbol == symbol(1)) + .unwrap(); + let second = plan + .rows + .iter() + .find(|row| row.symbol == symbol(2)) + .unwrap(); + assert_eq!(first.target_quantity, 8000.into()); + assert_eq!(first.status, "DEFERRED_T_PLUS_ONE"); + assert_eq!(second.target_quantity, Decimal::ZERO); + assert_eq!(second.side, Some(OrderSide::Sell)); +} + +#[test] +fn index_cap_respects_ordinary_weight_changes_and_stronger_fill_protection() { + let balanced = index_plan(0.3, false, false, true, false); + assert!( + balanced + .rows + .iter() + .all(|row| row.target_quantity == 1500.into()), + "{balanced:?}" + ); + let gated = index_plan(0.3, false, true, true, false); + assert_eq!( + gated + .rows + .iter() + .find(|row| row.symbol == symbol(1)) + .unwrap() + .target_quantity, + 2400.into() + ); + assert_eq!( + gated + .rows + .iter() + .find(|row| row.symbol == symbol(2)) + .unwrap() + .target_quantity, + 600.into() + ); + for rebalance in [false, true] { + let protected = index_plan(0.3, false, false, rebalance, true); + let first = protected + .rows + .iter() + .find(|row| row.symbol == symbol(1)) + .unwrap(); + assert_eq!(first.target_quantity, 8000.into(), "{protected:?}"); + assert_eq!(first.status, "AUTOMATIC_TRADE_PROTECTED"); + assert_eq!( + protected + .rows + .iter() + .find(|row| row.symbol == symbol(2)) + .unwrap() + .target_quantity, + Decimal::ZERO + ); + } + assert_eq!( + balanced.rows.len(), + 2, + "one final target row per instrument" + ); +} + +#[test] +fn retained_holdings_reserve_actual_value_and_zero_funding_still_clears() { + let mut first = position(1); + first.quantity = 8000.into(); + first.closable_quantity = 8000.into(); + let constraints = stock_pool_constraints_from_configuration( + &json!({"target_holding_count":2,"top_n_rebalance_policy":"preserve_existing"}), + &Value::Null, + ) + .unwrap(); + let make = |ratio| { + build_stock_pool_target_plan_with_constraints( + &selection(2, 2), + &members(2), + &StockPoolExecutionRule::default(), + &AccountSnapshot { + total_equity: 100000.into(), + cash: 20000.into(), + frozen_cash: Decimal::ZERO, + }, + &[first.clone()], + "es(2), + ratio, + Decimal::ZERO, + "hold", + "preserve_existing", + &constraints, + "retained-budget", + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + ) + .unwrap() + }; + let plan = make(10000); + let added = plan + .rows + .iter() + .find(|row| row.symbol == symbol(2)) + .unwrap(); + assert_eq!(added.target_value, 20000.into(), "{plan:?}"); + assert_eq!(added.target_quantity, 2000.into()); + assert_eq!(added.status, "READY"); + let cleared = make(0); + assert_eq!( + cleared + .rows + .iter() + .find(|row| row.symbol == symbol(1)) + .unwrap() + .target_quantity, + Decimal::ZERO + ); +} diff --git a/crates/fidc-core/src/stock_pool_frozen.rs b/crates/fidc-core/src/stock_pool_frozen.rs new file mode 100644 index 0000000..0e526b2 --- /dev/null +++ b/crates/fidc-core/src/stock_pool_frozen.rs @@ -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, +) -> 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, + frozen: &BTreeMap, +) -> Result { + 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, + constraints: &StockPoolDecisionConstraints, + reserved_slots: usize, + target_count: usize, +) -> Result, String> { + let count = original.len() + reserved_slots; + let order = members + .iter() + .map(|member| (&member.symbol, member.requested_order)) + .collect::>(); + 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::>(); + 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::(); + 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::>(); + let total = free.iter().map(|(_, weight)| *weight).sum::(); + 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::>(); + 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) +} diff --git a/crates/fidc-core/src/stock_pool_index_cap.rs b/crates/fidc-core/src/stock_pool_index_cap.rs new file mode 100644 index 0000000..0b22434 --- /dev/null +++ b/crates/fidc-core/src/stock_pool_index_cap.rs @@ -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, + members: &HashMap, + automatic: &BTreeMap, + already_planned: &[StockPoolPlanRow], + quotes: &HashMap, + frozen: &BTreeMap, + budget: Decimal, +) -> Result, String> { + struct Entry { + symbol: String, + quantity: Decimal, + minimum: Decimal, + price: Decimal, + } + let other = already_planned + .iter() + .map(|row| (&row.symbol, row)) + .collect::>(); + 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::(); + 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) +} diff --git a/crates/fidc-core/src/stock_pool_index_policy.rs b/crates/fidc-core/src/stock_pool_index_policy.rs new file mode 100644 index 0000000..dad0c77 --- /dev/null +++ b/crates/fidc-core/src/stock_pool_index_policy.rs @@ -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, + pub fast_window: Option, + pub slow_window: Option, + pub volatility_window: Option, + pub drawdown_window: Option, + pub bull_exposure: Option, + pub bear_exposure: Option, + pub volatility_threshold: Option, + pub volatility_cap: Option, + pub drawdown_threshold: Option, + pub drawdown_cap: Option, +} + +impl MarketTimingPolicy { + pub fn from_allocation(value: &serde_json::Value) -> Result { + let raw = value.get("market_timing").filter(|value| !value.is_null()); + let policy = raw + .map(|raw| serde_json::from_value::(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 { + 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, + pub closes: Vec, +} + +#[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, + 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 { + 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::>(); + let mean = |window: usize| closes[needed - window..].iter().sum::() / 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::>(); + let average = returns.iter().sum::() / returns.len() as f64; + let volatility = (returns + .iter() + .map(|value| (value - average).powi(2)) + .sum::() + / (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 { + 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) -> MarketTimingInput { + let dates = (0..values.len()) + .map(|i| { + NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Duration::days(i as i64) + }) + .collect::>(); + 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); + } +} diff --git a/crates/fidc-core/src/stock_pool_indicators.rs b/crates/fidc-core/src/stock_pool_indicators.rs new file mode 100644 index 0000000..171587d --- /dev/null +++ b/crates/fidc-core/src/stock_pool_indicators.rs @@ -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, +} + +#[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, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Request { + pub specs: BTreeMap, + pub series: Vec, + pub output_start_date: NaiveDate, + pub output_end_date: NaiveDate, +} + +#[derive(Debug, Serialize)] +pub struct OutputSeries { + pub symbol: String, + pub indices: Vec, + pub values: BTreeMap>>, +} + +pub fn evaluate(request: Request) -> Result, 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::>(); + 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..]); + } +} diff --git a/crates/fidc-core/src/stock_pool_state.rs b/crates/fidc-core/src/stock_pool_state.rs new file mode 100644 index 0000000..3ebc19d --- /dev/null +++ b/crates/fidc-core/src/stock_pool_state.rs @@ -0,0 +1,238 @@ +//! 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, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StockPoolExecutionState { + pub schema_version: u32, + pub last_execution_date: Option, + pub entries: BTreeMap, + #[serde(default)] + pub last_target_weights: BTreeMap, + /// First signal excluding an actually held member; not an acquisition date. + pub removed_since: BTreeMap, +} + +pub struct StockPoolGoalObservation<'a> { + pub symbol: &'a str, + pub target_weight_bps: i32, + pub target_value: Decimal, + pub current_quantity: Decimal, + pub status: &'a str, +} + +impl Default for StockPoolExecutionState { + fn default() -> Self { + Self { + schema_version: 1, + last_execution_date: None, + entries: BTreeMap::new(), + last_target_weights: BTreeMap::new(), + removed_since: BTreeMap::new(), + } + } +} + +impl StockPoolExecutionState { + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != 1 + || self.entries.len() > 10000 + || self.removed_since.len() > 10000 + { + return Err("stock_pool_execution_state_invalid_schema_or_size".into()); + } + for symbol in self + .entries + .keys() + .chain(self.removed_since.keys()) + .chain(self.last_target_weights.keys()) + { + if normalize_stock_symbol(symbol).as_ref() != Some(symbol) { + return Err("stock_pool_execution_state_invalid_symbol".into()); + } + } + if self.last_target_weights.len() > 10000 + || self + .last_target_weights + .values() + .any(|value| !(0..=10000).contains(value)) + { + return Err("stock_pool_execution_state_invalid_weights".into()); + } + if self.entries.values().any(|entry| { + entry.latest_target_value < Decimal::ZERO + || entry.latest_generation.is_empty() + || self + .last_execution_date + .is_none_or(|last| entry.first_decision_date > last) + }) || self + .removed_since + .values() + .any(|day| self.last_execution_date.is_none_or(|last| *day > last)) + { + return Err("stock_pool_execution_state_invalid_goal_or_clock".into()); + } + Ok(()) + } + + pub fn observe( + &self, + decision_date: NaiveDate, + execution_date: NaiveDate, + official_dates: &[NaiveDate], + members: &[StockPoolMemberSpec], + positions: &[Position], + ) -> Result { + self.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::>(); + let held = positions + .iter() + .filter(|position| position.quantity > Decimal::ZERO) + .map(|position| position.symbol.clone()) + .collect::>(); + 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); + } + 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 { + self.entries + .iter() + .filter(|(_, entry)| entry.pending) + .map(|(symbol, _)| symbol.clone()) + .collect() + } + + pub fn next_day_exit_symbols(&self, execution_date: NaiveDate) -> BTreeSet { + 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.record_targets( + decision_date, + generation, + plan.rows.iter().map(|row| StockPoolGoalObservation { + symbol: &row.symbol, + target_weight_bps: row.target_weight_bps, + target_value: row.target_value, + current_quantity: row.current_quantity, + status: &row.status, + }), + ) + } + + pub fn record_targets<'a>( + &self, + decision_date: NaiveDate, + generation: &str, + rows: impl IntoIterator>, + ) -> Result { + self.validate()?; + if generation.is_empty() + || self + .last_execution_date + .is_none_or(|date| decision_date > date) + { + return Err("stock_pool_execution_state_plan_clock_invalid".into()); + } + let mut next = self.clone(); + for row in rows { + if row.target_weight_bps > 0 { + next.last_target_weights + .insert(row.symbol.into(), row.target_weight_bps); + } + let eligible = row.target_weight_bps > 0 && row.target_value > Decimal::ZERO; + let 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; + 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, + }, + ); + } + } + next.validate()?; + Ok(next) + } +} diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index fb76b14..5a3e85e 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -1070,6 +1070,9 @@ pub enum TargetPortfolioOrderPricing { #[derive(Debug, Clone)] pub enum OrderIntent { + StockPool { + contract: Box, + }, WithTimeInForce { intent: Box, 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) { 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 { .. } diff --git a/crates/fidc-core/tests/fixtures/stock_pool_disabled_stops_compiled.json b/crates/fidc-core/tests/fixtures/stock_pool_disabled_stops_compiled.json new file mode 100644 index 0000000..01ed9d6 --- /dev/null +++ b/crates/fidc-core/tests/fixtures/stock_pool_disabled_stops_compiled.json @@ -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" +} diff --git a/crates/fidc-core/tests/stock_pool_execution_contract.rs b/crates/fidc-core/tests/stock_pool_execution_contract.rs new file mode 100644 index 0000000..28b5155 --- /dev/null +++ b/crates/fidc-core/tests/stock_pool_execution_contract.rs @@ -0,0 +1,632 @@ +use chrono::NaiveDate; +use fidc_core::stock_pool_execution::*; +use fidc_core::{ + BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, + ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, + FidcRiskControlConfig, Instrument, MatchingType, OrderIntent, PlatformExprStrategy, + PortfolioState, PriceField, StrategyDecision, platform_expr_config_from_value, +}; +use rust_decimal::Decimal; +use std::collections::{BTreeMap, BTreeSet}; + +fn day(n: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 1, n).unwrap() +} +fn code(n: usize) -> String { + format!("{n:06}.SZ") +} +fn data(low_volume: bool) -> DataSet { + data_with_first_volume(if low_volume { 100 } else { 1_000_000 }) +} +fn data_with_first_volume(first_volume: u64) -> DataSet { + data_with_suspension(first_volume, None) +} +fn data_with_suspension(first_volume: u64, suspension: Option) -> DataSet { + data_with_fund_rules(first_volume, suspension, false) +} +fn data_with_fund_rules( + first_volume: u64, + suspension: Option, + fund_rules: bool, +) -> DataSet { + let mut instruments: Vec = (1..=2) + .map(|n| Instrument { + symbol: code(n), + name: code(n), + board: if fund_rules && n == 2 { + "ETF".into() + } else { + "SZ".into() + }, + round_lot: 100, + listed_at: Some(day(1)), + delisted_at: None, + status: "active".into(), + }) + .collect(); + instruments.push(Instrument { + symbol: "000300.SH".into(), + name: "fixture reference index".into(), + board: "INDEX".into(), + round_lot: 1, + listed_at: Some(day(1)), + delisted_at: None, + status: "active".into(), + }); + let mut market = Vec::new(); + let mut candidates = Vec::new(); + for date in [day(2), day(5), day(6)] { + for n in 1..=2 { + let price = if fund_rules && n == 2 { + 0.934 + } else if n == 1 && date >= day(5) { + 20.0 + } else { + 10.0 + }; + market.push(DailyMarketSnapshot { + date, + symbol: code(n), + timestamp: None, + day_open: price, + open: price, + high: price, + low: price, + close: price, + last_price: price, + bid1: price, + ask1: price, + prev_close: 10., + volume: if n == 1 { first_volume } else { 1_000_000 }, + minute_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: None, + paused: n == 2 && suspension == Some(date), + upper_limit: 100., + lower_limit: 0.1, + price_tick: if fund_rules && n == 2 { 0.001 } else { 0.01 }, + }); + candidates.push(CandidateEligibility { + date, + symbol: code(n), + is_st: false, + is_star_st: false, + is_new_listing: false, + is_paused: n == 2 && suspension == Some(date), + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }); + } + let mut reference = market.last().unwrap().clone(); + reference.symbol = "000300.SH".into(); + reference.day_open = 100.; + reference.open = 100.; + reference.high = 100.; + reference.low = 100.; + reference.close = 100.; + reference.last_price = 100.; + reference.bid1 = 100.; + reference.ask1 = 100.; + reference.prev_close = 100.; + reference.upper_limit = 1000.; + market.push(reference); + } + let benchmarks = [day(2), day(5), day(6)] + .into_iter() + .map(|date| BenchmarkSnapshot { + date, + benchmark: "000300.SH".into(), + open: 100., + close: 100., + prev_close: 100., + volume: 1_000_000, + }) + .collect(); + let factors = [day(2), day(5), day(6)] + .into_iter() + .flat_map(|date| { + (1..=2).map(move |n| DailyFactorSnapshot { + date, + symbol: code(n), + market_cap_bn: 10., + free_float_cap_bn: 10., + pe_ttm: 10., + turnover_ratio: None, + effective_turnover_ratio: None, + adjustment_factor_backward1: Some(1.), + extra_factors: Default::default(), + }) + }) + .collect(); + DataSet::from_components(instruments, market, factors, candidates, benchmarks).unwrap() +} +fn broker(volume: bool) -> BrokerSimulator { + let mut risk = FidcRiskControlConfig::default(); + risk.trading_constraints.commission_rate = 0.; + risk.trading_constraints.minimum_commission = 0.; + risk.trading_constraints.transfer_fee_rate = 0.; + risk.trading_constraints.stamp_tax_rate_before_change = 0.; + risk.trading_constraints.stamp_tax_rate_after_change = 0.; + risk.trading_constraints.volume_limit_enabled = volume; + risk.trading_constraints.volume_percent = 0.25; + risk.trading_constraints.liquidity_limit_enabled = false; + BrokerSimulator::new( + ChinaAShareCostModel::from_trading_constraints(risk.trading_constraints), + ChinaEquityRuleHooks, + ) + .with_matching_type(MatchingType::NextBarOpen) + .with_risk_config(risk) +} +fn contract(signal: NaiveDate, target: usize, preserve: bool) -> FrozenStockPoolIntent { + let symbols = vec![code(1), code(2)]; + FrozenStockPoolIntent { + pool_id: "fixture-pool".into(), + signal_date: signal, + frozen_equity: Decimal::from(30000), + selection: StockPoolSelection { + trade_date: signal, + requested_symbols: symbols.clone(), + normal_trading_symbols: symbols.clone(), + risk_eligible_symbols: symbols, + final_symbols: vec![code(target)], + exclusion_reasons: BTreeMap::new(), + inherited_from_generation: None, + explicit_empty: false, + generation: Some(format!("g-{signal}")), + }, + members: (1..=2) + .map(|n| StockPoolMemberSpec { + symbol: code(n), + requested_order: n as i32, + recommendation_reason: String::new(), + target_weight_bps: None, + stop_loss: None, + take_profit: None, + }) + .collect(), + rule: StockPoolExecutionRule { + pricing_mode: POOL_PRICE_FIRST_TICK.into(), + ..Default::default() + }, + constraints: StockPoolDecisionConstraints { + target_holding_count: Some(1), + portfolio_policy: Some(StockPoolPortfolioPolicy { + schema_version: 1, + membership: MembershipPolicy::FollowCandidates, + rebalance_weights: !preserve, + }), + ..Default::default() + }, + invest_ratio_bps: 10000, + reserve_cash: Decimal::ZERO, + out_of_pool_policy: "hold".into(), + generation: format!("g-{signal}"), + } +} +fn decision(contract: FrozenStockPoolIntent) -> StrategyDecision { + StrategyDecision { + order_intents: vec![OrderIntent::StockPool { + contract: Box::new(contract), + }], + ..Default::default() + } +} + +#[test] +fn paused_execution_day_keeps_the_prior_slot_and_never_submits_an_exit() { + let data = data_with_suspension(1_000_000, Some(day(6))); + let broker = broker(false); + let mut account = PortfolioState::new(30_000.); + let first = broker + .execute_with_event_dates( + day(5), + day(2), + day(2), + &mut account, + &data, + &decision(contract(day(2), 2, false)), + ) + .unwrap(); + assert_eq!(first.fill_events.len(), 1); + let quantity = account.position(&code(2)).unwrap().quantity; + let mut replacement = contract(day(5), 1, false); + replacement + .members + .retain(|member| member.symbol != code(2)); + replacement + .selection + .requested_symbols + .retain(|symbol| symbol != &code(2)); + replacement.out_of_pool_policy = "reduce_to_zero_when_sellable".into(); + let paused = broker + .execute_with_event_dates( + day(6), + day(5), + day(5), + &mut account, + &data, + &decision(replacement), + ) + .unwrap(); + assert!(paused.fill_events.is_empty(), "{paused:?}"); + assert_eq!(account.position(&code(2)).unwrap().quantity, quantity); + assert!( + paused + .diagnostics + .iter() + .any(|line| line.contains("MARKET_SUSPENDED")) + ); +} + +#[test] +fn mixed_fund_and_stock_round_trip_uses_declared_ticks_and_asset_specific_fees() { + let data = data_with_fund_rules(1_000_000, None, true); + let mut costs = ChinaAShareCostModel::default(); + costs.set_transfer_fee_rate(0.00001); + let broker = BrokerSimulator::new(costs, ChinaEquityRuleHooks) + .with_matching_type(MatchingType::NextBarOpen); + let mut account = PortfolioState::new(30_000.); + let mut entry = contract(day(2), 1, false); + entry.selection.final_symbols = vec![code(1), code(2)]; + entry.constraints.target_holding_count = Some(2); + entry.rule.buy_offset_bps = 1; + entry.rule.sell_offset_bps = -1; + let buys = broker + .execute_with_event_dates( + day(5), + day(2), + day(2), + &mut account, + &data, + &decision(entry.clone()), + ) + .unwrap(); + assert_eq!(buys.fill_events.len(), 2, "{buys:?}"); + let fund = buys + .fill_events + .iter() + .find(|fill| fill.symbol == code(2)) + .unwrap(); + assert_eq!(fund.quantity, 16000); + assert_eq!(fund.price, 0.934); + assert_eq!(fund.stamp_tax, 0.); + assert_eq!(fund.transfer_fee, 0.); + let stock = buys + .fill_events + .iter() + .find(|fill| fill.symbol == code(1)) + .unwrap(); + assert_eq!(stock.quantity, 700); + assert_eq!(stock.transfer_fee, 0.14); + entry.signal_date = day(5); + entry.selection.trade_date = day(5); + entry.generation = "exit".into(); + entry.invest_ratio_bps = 0; + let sells = broker + .execute_with_event_dates( + day(6), + day(5), + day(5), + &mut account, + &data, + &decision(entry), + ) + .unwrap(); + assert_eq!(sells.fill_events.len(), 2, "{sells:?}"); + let fund = sells + .fill_events + .iter() + .find(|fill| fill.symbol == code(2)) + .unwrap(); + assert_eq!(fund.stamp_tax, 0.); + assert_eq!(fund.transfer_fee, 0.); + let stock = sells + .fill_events + .iter() + .find(|fill| fill.symbol == code(1)) + .unwrap(); + assert_eq!(stock.stamp_tax, 7.); + assert_eq!(stock.transfer_fee, 0.14); + assert!( + (account.cash() - 29972.72).abs() < 0.000001, + "cash={}", + account.cash() + ); + assert!( + account + .positions() + .values() + .all(|position| position.quantity == 0) + ); +} + +#[test] +fn new_daily_target_sells_old_member_then_buys_using_frozen_equity() { + let data = data(false); + let broker = broker(false); + let mut account = PortfolioState::new(20000.); + account.position_mut(&code(1)).buy(day(2), 1000, 10.); + let report = broker + .execute_with_event_dates( + day(5), + day(2), + day(2), + &mut account, + &data, + &decision(contract(day(2), 2, false)), + ) + .unwrap(); + assert_eq!(report.fill_events.len(), 2, "{report:?}"); + assert!(account.position(&code(1)).is_none_or(|p| p.quantity == 0)); + assert_eq!(account.position(&code(2)).unwrap().quantity, 3000); + assert!( + (account.cash() - 10000.).abs() < 1e-8, + "next-open equity must not replace the frozen 30000 budget" + ); + let unique = report + .fill_events + .iter() + .map(|fill| (fill.symbol.clone(), format!("{:?}", fill.side))) + .collect::>(); + assert_eq!(unique.len(), 2); + let next = broker + .execute_with_event_dates( + day(6), + day(5), + day(5), + &mut account, + &data, + &decision(contract(day(5), 2, true)), + ) + .unwrap(); + assert!( + next.fill_events.is_empty(), + "preserved shares must not be rebalanced: {next:?}" + ); +} + +#[test] +fn partial_sell_does_not_release_a_slot_or_authorize_replacement() { + let data = data(true); + let broker = broker(true).with_matching_type(MatchingType::CurrentBarClose); + let mut account = PortfolioState::new(20000.); + account.position_mut(&code(1)).buy(day(2), 1000, 10.); + let report = broker + .execute_with_event_dates( + day(5), + day(2), + day(2), + &mut account, + &data, + &decision(contract(day(2), 2, false)), + ) + .unwrap(); + assert!(account.position(&code(2)).is_none()); + assert_eq!(account.position(&code(1)).unwrap().quantity, 975); + assert!( + report + .diagnostics + .iter() + .any(|text| text.contains("DEFERRED_POSITION_SLOTS")) + ); +} + +#[test] +fn actual_fill_protection_is_evaluated_on_execution_date() { + let data = data(false); + let broker = broker(false); + let mut account = PortfolioState::new(20000.); + account.position_mut(&code(1)).buy(day(2), 1000, 10.); + let mut intent = contract(day(2), 2, false); + intent.rule.automatic_trade_protection.buy_protection_days = 3; + let report = broker + .execute_with_event_dates( + day(5), + day(2), + day(2), + &mut account, + &data, + &decision(intent), + ) + .unwrap(); + assert!(report.fill_events.is_empty(), "{report:?}"); + assert_eq!(account.position(&code(1)).unwrap().quantity, 1000); +} + +#[test] +fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() { + let intent = contract(day(2), 1, false); + for quote_condition in ["", "price<5"] { + let program = StockPoolProgram { + schema_version: 1, + pool_id: "pool-fixture".into(), + version_id: "version-fixture".into(), + members: intent.members.clone(), + allocation_policy: serde_json::json!({"target_holding_count":1,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":false}}), + // Disabled natural trading must not disable an explicitly requested historical backtest. + timing_policy: serde_json::json!({"auto_execute":false,"pricing_mode":"first_tick","buy_condition":quote_condition}), + stop_take_policy: serde_json::json!({"stop_loss":null,"take_profit":0}), + out_of_pool_policy: "hold".into(), + }; + let mut config=platform_expr_config_from_value("pool-fixture",&code(1),&serde_json::json!({ + "stockPool":program,"signalSymbol":code(1),"benchmark":{"instrumentId":"000300.SH"},"universe":{"include":[code(1),code(2)]} + })).unwrap(); + config.market_cap_field = "close".into(); + config.market_cap_lower_expr = "0".into(); + config.market_cap_upper_expr = "1000000000000".into(); + config.stock_filter_expr = "true".into(); + config.selection_limit_expr = "1".into(); + config.selection_candidate_limit_expr = "2".into(); + config.rank_expr = format!( + "decision_date == \"2026-01-02\" ? (symbol == \"{}\" ? 0 : 1) : (symbol == \"{}\" ? 0 : 1)", + code(1), + code(2) + ); + config.matching_type = MatchingType::CurrentBarClose; + let result = BacktestEngine::new( + data(false), + PlatformExprStrategy::new(config), + broker(false).with_matching_type(MatchingType::CurrentBarClose), + BacktestConfig { + initial_cash: 30000., + benchmark_code: "000300.SH".into(), + start_date: Some(day(2)), + end_date: Some(day(6)), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Close, + }, + ) + .run() + .unwrap(); + if quote_condition.is_empty() { + assert_eq!( + result.fills.len(), + 3, + "fills={:#?}, decisions={:#?}, days={:#?}", + result.fills, + result.risk_decisions, + result + .equity_curve + .iter() + .map(|point| (&point.date, &point.diagnostics)) + .collect::>() + ); + assert_eq!(result.fills[0].symbol, code(1)); + assert_eq!(result.fills[1].symbol, code(1)); + assert_eq!(result.fills[2].symbol, code(2)); + assert_eq!(result.fills[2].quantity, 6000); + } else { + assert!( + result.fills.is_empty(), + "configured quote condition must reach the actual executor" + ); + } + } +} + +#[test] +fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() { + // Generated by OmniQuant's actual handoff and compiler, not a hand-written + // replacement runtimeExpressions contract. It used to inject 0.93/1.07. + let spec: serde_json::Value = serde_json::from_str(include_str!( + "fixtures/stock_pool_disabled_stops_compiled.json" + )) + .unwrap(); + let config = + platform_expr_config_from_value("fixture_hold_without_stops", "000300.SH", &spec).unwrap(); + assert!(config.stop_loss_expr.is_empty()); + assert!(config.take_profit_expr.is_empty()); + let result = BacktestEngine::new( + data(false), + PlatformExprStrategy::new(config), + broker(false).with_matching_type(MatchingType::CurrentBarClose), + BacktestConfig { + initial_cash: 30000., + benchmark_code: "000300.SH".into(), + start_date: Some(day(2)), + end_date: Some(day(6)), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Close, + }, + ) + .run() + .unwrap(); + assert_eq!( + result.fills.len(), + 2, + "stock one doubles in price, but disabled stops and weight rebalancing must not sell it: {:?}", + result.fills + ); + assert!( + result + .fills + .iter() + .all(|fill| fill.side == fidc_core::OrderSide::Buy) + ); + assert_eq!(result.equity_curve.len(), 3); +} + +#[test] +fn partial_backtest_fills_do_not_turn_into_completed_preserved_holdings() { + let data = data_with_first_volume(2000); + let broker = broker(true).with_matching_type(MatchingType::CurrentBarClose); + let mut account = PortfolioState::new(30000.); + let first = broker + .execute_with_event_dates( + day(2), + day(2), + day(2), + &mut account, + &data, + &decision(contract(day(2), 1, true)), + ) + .unwrap(); + let partial = account + .position(&code(1)) + .map(|position| position.quantity) + .unwrap_or(0); + assert!(partial > 0 && partial < 3000, "{first:?}"); + let second = broker + .execute_with_event_dates( + day(5), + day(5), + day(5), + &mut account, + &data, + &decision(contract(day(5), 1, true)), + ) + .unwrap(); + assert!( + account.position(&code(1)).unwrap().quantity > partial, + "partial entry must continue on the next valid execution: {second:?}" + ); +} + +#[test] +fn next_day_outside_policy_executes_after_the_first_exclusion_signal() { + let data = data(false); + let broker = broker(false).with_matching_type(MatchingType::CurrentBarClose); + let mut account = PortfolioState::new(20000.); + account.position_mut(&code(1)).buy(day(1), 1000, 10.); + let outside = |signal| { + let mut value = contract(signal, 2, true); + value.members.retain(|member| member.symbol == code(2)); + value.selection.requested_symbols = vec![code(2)]; + value.selection.normal_trading_symbols = vec![code(2)]; + value.selection.risk_eligible_symbols = vec![code(2)]; + value.out_of_pool_policy = "reduce_next_trading_day".into(); + value + }; + let first = broker + .execute_with_event_dates( + day(2), + day(2), + day(2), + &mut account, + &data, + &decision(outside(day(2))), + ) + .unwrap(); + assert!(first.fill_events.is_empty(), "{first:?}"); + let next = broker + .execute_with_event_dates( + day(5), + day(5), + day(5), + &mut account, + &data, + &decision(outside(day(5))), + ) + .unwrap(); + assert!( + account + .position(&code(1)) + .is_none_or(|position| position.quantity == 0), + "{next:?}" + ); + assert_eq!(account.position(&code(2)).unwrap().quantity, 3000); +} diff --git a/crates/fidc-core/tests/stock_pool_execution_state.rs b/crates/fidc-core/tests/stock_pool_execution_state.rs new file mode 100644 index 0000000..7b7879c --- /dev/null +++ b/crates/fidc-core/tests/stock_pool_execution_state.rs @@ -0,0 +1,217 @@ +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 { + let symbols = members + .iter() + .map(|member| member.symbol.clone()) + .collect::>(); + 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); + build_stock_pool_target_plan_with_constraints( + &selection, + members, + &StockPoolExecutionRule::default(), + &AccountSnapshot { + total_equity: 10000.into(), + cash: cash.into(), + frozen_cash: Decimal::ZERO, + }, + positions, + &[quote()], + 10000, + Decimal::ZERO, + outside, + "preserve_existing", + &constraints, + "same-goal", + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + ) + .unwrap() +} + +#[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, "ENTRY_TARGET_ALREADY_SATISFIED"); + 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()); +}