diff --git a/Cargo.lock b/Cargo.lock index 467ffa2..524e039 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -17,6 +31,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "bt-demo" version = "0.1.0" @@ -63,12 +83,38 @@ dependencies = [ "windows-link", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "equivalent" version = "1.0.2" @@ -81,6 +127,7 @@ version = "0.1.0" dependencies = [ "chrono", "indexmap", + "rhai", "serde", "thiserror", ] @@ -91,6 +138,29 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -133,6 +203,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "itoa" version = "1.0.18" @@ -167,6 +246,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "spin", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -181,6 +269,15 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "proc-macro2" @@ -200,6 +297,41 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rhai" +version = "1.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e35aaaa439a5bda2f8d15251bc375e4edfac75f9865734644782c9701b5709" +dependencies = [ + "ahash", + "bitflags", + "instant", + "no-std-compat", + "num-traits", + "once_cell", + "rhai_codegen", + "smallvec", + "smartstring", + "thin-vec", +] + +[[package]] +name = "rhai_codegen" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -255,6 +387,35 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "syn" version = "2.0.117" @@ -266,6 +427,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thin-vec" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "259cdf8ed4e4aca6f1e9d011e10bd53f524a2d0637d7b28450f6c64ac298c4c6" + [[package]] name = "thiserror" version = "2.0.18" @@ -286,12 +453,42 @@ dependencies = [ "syn", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.117" @@ -396,6 +593,32 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index ddb1301..38080bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,5 +14,6 @@ authors = ["OpenAI Codex"] [workspace.dependencies] chrono = { version = "=0.4.44", features = ["serde"] } indexmap = { version = "=2.11.4", features = ["serde"] } +rhai = { version = "=1.23.6", features = ["sync"] } serde = { version = "=1.0.228", features = ["derive"] } thiserror = "=2.0.18" diff --git a/crates/fidc-core/Cargo.toml b/crates/fidc-core/Cargo.toml index 1882a73..cf334eb 100644 --- a/crates/fidc-core/Cargo.toml +++ b/crates/fidc-core/Cargo.toml @@ -8,5 +8,6 @@ authors.workspace = true [dependencies] chrono.workspace = true indexmap.workspace = true +rhai.workspace = true serde.workspace = true thiserror.workspace = true diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 6529a10..76f22f3 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -843,6 +843,19 @@ impl DataSet { symbol: symbol.to_string(), }) } + + pub fn require_factor( + &self, + date: NaiveDate, + symbol: &str, + ) -> Result<&DailyFactorSnapshot, DataSetError> { + self.factor(date, symbol) + .ok_or_else(|| DataSetError::MissingSnapshot { + kind: "factor", + date, + symbol: symbol.to_string(), + }) + } } fn read_instruments(path: &Path) -> Result, DataSetError> { diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 1dbf26e..78e22cf 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod events; pub mod instrument; pub mod metrics; pub mod portfolio; +pub mod platform_expr_strategy; pub mod rules; pub mod strategy; pub mod universe; @@ -27,6 +28,7 @@ pub use events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, Po pub use instrument::Instrument; pub use metrics::{BacktestMetrics, compute_backtest_metrics}; pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position}; +pub use platform_expr_strategy::{PlatformExprStrategy, PlatformExprStrategyConfig}; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; pub use strategy::{ CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs new file mode 100644 index 0000000..59361ee --- /dev/null +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -0,0 +1,1210 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; +use rhai::{Dynamic, Engine, Scope}; + +use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField}; +use crate::engine::BacktestError; +use crate::events::OrderSide; +use crate::portfolio::PortfolioState; +use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; + +#[derive(Debug, Clone)] +pub struct PlatformExprStrategyConfig { + pub strategy_name: String, + pub market: String, + pub benchmark_symbol: String, + pub signal_symbol: String, + pub refresh_rate: usize, + pub max_positions: usize, + pub prelude: String, + pub universe_exclude: Vec, + pub market_cap_field: String, + pub market_cap_lower_expr: String, + pub market_cap_upper_expr: String, + pub selection_limit_expr: String, + pub stock_filter_expr: String, + pub exposure_expr: String, + pub stop_loss_expr: String, + pub take_profit_expr: String, + pub rank_by: String, + pub rank_desc: bool, + pub benchmark_short_ma_days: usize, + pub benchmark_long_ma_days: usize, + pub stock_short_ma_days: usize, + pub stock_mid_ma_days: usize, + pub stock_long_ma_days: usize, + pub skip_month_day_ranges: Vec<(u32, u32, u32)>, +} + +impl PlatformExprStrategyConfig { + pub fn microcap_rotation() -> Self { + Self { + strategy_name: "microcap_rotation".to_string(), + market: "CN_A".to_string(), + benchmark_symbol: "000852.SH".to_string(), + signal_symbol: "000001.SH".to_string(), + refresh_rate: 15, + max_positions: 40, + prelude: r#"let stocknum = 40; +let ma_ratio = 1.0001; +fn band_low(index_close) { + round((index_close - 2000) * 4 / 500 + 7) +}"# + .to_string(), + universe_exclude: vec![ + "paused".to_string(), + "st".to_string(), + "kcb".to_string(), + "one_yuan".to_string(), + "new_listing".to_string(), + ], + market_cap_field: "market_cap".to_string(), + market_cap_lower_expr: "band_low(signal_close)".to_string(), + market_cap_upper_expr: "band_low(signal_close) + 10".to_string(), + selection_limit_expr: "stocknum".to_string(), + stock_filter_expr: + "stock_ma_short > stock_ma_mid * ma_ratio && stock_ma_mid > stock_ma_long" + .to_string(), + exposure_expr: + "benchmark_ma_short < benchmark_ma_long * ma_ratio ? 0.5 : 1.0".to_string(), + stop_loss_expr: "0.93".to_string(), + take_profit_expr: "1.07".to_string(), + rank_by: "market_cap".to_string(), + rank_desc: false, + benchmark_short_ma_days: 5, + benchmark_long_ma_days: 10, + stock_short_ma_days: 5, + stock_mid_ma_days: 10, + stock_long_ma_days: 20, + skip_month_day_ranges: Vec::new(), + } + } + + fn in_skip_window(&self, date: NaiveDate) -> bool { + let month = date.month(); + let day = date.day(); + self.skip_month_day_ranges + .iter() + .any(|(m, start_day, end_day)| month == *m && day >= *start_day && day <= *end_day) + } +} + +#[derive(Default)] +struct ProjectedExecutionState { + execution_cursors: BTreeMap, + intraday_turnover: BTreeMap, +} + +#[derive(Debug, Clone, Copy)] +struct ProjectedExecutionFill { + price: f64, + quantity: u32, + next_cursor: NaiveDateTime, +} + +#[derive(Debug, Clone)] +struct DayExpressionState { + signal_close: f64, + benchmark_close: f64, + benchmark_ma_short: f64, + benchmark_ma_long: f64, + benchmark_ma5: f64, + benchmark_ma10: f64, + benchmark_ma20: f64, + signal_ma5: f64, + signal_ma10: f64, + signal_ma20: f64, +} + +#[derive(Debug, Clone)] +struct StockExpressionState { + market_cap: f64, + free_float_cap: f64, + open: f64, + close: f64, + last: f64, + prev_close: f64, + upper_limit: f64, + lower_limit: f64, + paused: bool, + is_st: bool, + is_kcb: bool, + is_one_yuan: bool, + is_new_listing: bool, + allow_buy: bool, + allow_sell: bool, + listed_days: i64, + stock_ma_short: f64, + stock_ma_mid: f64, + stock_ma_long: f64, + stock_ma5: f64, + stock_ma10: f64, + stock_ma20: f64, +} + +#[derive(Debug, Clone)] +struct PositionExpressionState { + avg_cost: f64, + current_price: f64, + holding_return: f64, + quantity: i64, + sellable_qty: i64, +} + +pub struct PlatformExprStrategy { + config: PlatformExprStrategyConfig, + engine: Engine, +} + +impl PlatformExprStrategy { + pub fn new(config: PlatformExprStrategyConfig) -> Self { + let mut engine = Engine::new(); + engine.register_fn("round", |value: f64| value.round()); + engine.register_fn("floor", |value: f64| value.floor()); + engine.register_fn("ceil", |value: f64| value.ceil()); + engine.register_fn("abs", |value: f64| value.abs()); + engine.register_fn("min", |lhs: f64, rhs: f64| lhs.min(rhs)); + engine.register_fn("max", |lhs: f64, rhs: f64| lhs.max(rhs)); + Self { config, engine } + } + + fn intraday_execution_start_time(&self) -> NaiveTime { + NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18") + } + + fn buy_commission(&self, gross_amount: f64) -> f64 { + (gross_amount * 0.0003).max(5.0) + } + + fn sell_cost(&self, gross_amount: f64) -> f64 { + (gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001) + } + + fn round_lot_quantity(&self, quantity: u32, round_lot: u32) -> u32 { + let lot = round_lot.max(1); + (quantity / lot) * lot + } + + fn projected_round_lot(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 { + ctx.data + .instrument(symbol) + .map(|instrument| instrument.effective_round_lot()) + .unwrap_or(100) + .max(1) + } + + fn projected_execution_price( + &self, + market: &DailyMarketSnapshot, + side: OrderSide, + ) -> f64 { + match side { + OrderSide::Buy => market.buy_price(PriceField::Last), + OrderSide::Sell => market.sell_price(PriceField::Last), + } + } + + fn projected_execution_start_cursor( + &self, + date: NaiveDate, + _symbol: &str, + _execution_state: &ProjectedExecutionState, + ) -> NaiveDateTime { + date.and_time(self.intraday_execution_start_time()) + } + + fn projected_select_execution_fill( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + side: OrderSide, + requested_qty: u32, + round_lot: u32, + cash_limit: Option, + gross_limit: Option, + execution_state: &ProjectedExecutionState, + ) -> Option { + if requested_qty == 0 { + return None; + } + + if let Some(market) = ctx.data.market(date, symbol) { + let execution_price = self.projected_execution_price(market, side); + if execution_price.is_finite() && execution_price > 0.0 { + let quantity = match side { + OrderSide::Buy => { + let cash = cash_limit.unwrap_or(f64::INFINITY); + let lot = round_lot.max(1); + let mut take_qty = self.round_lot_quantity(requested_qty, lot); + while take_qty > 0 { + let candidate_gross = execution_price * take_qty as f64; + if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { + take_qty = take_qty.saturating_sub(lot); + continue; + } + let candidate_cash = candidate_gross + self.buy_commission(candidate_gross); + if candidate_cash <= cash + 1e-6 { + break; + } + take_qty = take_qty.saturating_sub(lot); + } + take_qty + } + OrderSide::Sell => requested_qty, + }; + if quantity > 0 { + return Some(ProjectedExecutionFill { + price: execution_price, + quantity, + next_cursor: date.and_time(self.intraday_execution_start_time()) + + Duration::seconds(1), + }); + } + } + } + + let lot = round_lot.max(1); + let start_cursor = self.projected_execution_start_cursor(date, symbol, execution_state); + let quotes = ctx.data.execution_quotes_on(date, symbol); + let mut filled_qty = 0_u32; + let mut gross_amount = 0.0_f64; + let mut last_timestamp = None; + + for quote in quotes { + if quote.timestamp < start_cursor { + continue; + } + let fallback_quote_price = match side { + OrderSide::Buy => quote.buy_price(), + OrderSide::Sell => quote.sell_price(), + }; + let Some(quote_price) = fallback_quote_price else { + continue; + }; + let available_qty = match side { + OrderSide::Buy => quote.ask1_volume, + OrderSide::Sell => quote.bid1_volume, + } + .saturating_mul(lot as u64) + .min(u32::MAX as u64) as u32; + if available_qty == 0 { + continue; + } + + let remaining_qty = requested_qty.saturating_sub(filled_qty); + if remaining_qty == 0 { + break; + } + let mut take_qty = self.round_lot_quantity(remaining_qty.min(available_qty), lot); + if take_qty == 0 { + continue; + } + + if let Some(cash) = cash_limit { + while take_qty > 0 { + let candidate_gross = gross_amount + quote_price * take_qty as f64; + if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { + take_qty = take_qty.saturating_sub(lot); + continue; + } + if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 { + break; + } + take_qty = take_qty.saturating_sub(lot); + } + if take_qty == 0 { + break; + } + } + + gross_amount += quote_price * take_qty as f64; + filled_qty += take_qty; + last_timestamp = Some(quote.timestamp); + if filled_qty >= requested_qty { + break; + } + } + + if filled_qty == 0 { + return None; + } + Some(ProjectedExecutionFill { + price: gross_amount / filled_qty as f64, + quantity: filled_qty, + next_cursor: last_timestamp.unwrap_or(start_cursor) + Duration::seconds(1), + }) + } + + fn project_target_zero( + &self, + ctx: &StrategyContext<'_>, + projected: &mut PortfolioState, + date: NaiveDate, + symbol: &str, + execution_state: &mut ProjectedExecutionState, + ) -> Option { + let quantity = projected.position(symbol)?.quantity; + if quantity == 0 { + return None; + } + let market = ctx.data.market(date, symbol)?; + let round_lot = self.projected_round_lot(ctx, symbol); + let fill = self + .projected_select_execution_fill( + ctx, + date, + symbol, + OrderSide::Sell, + quantity, + round_lot, + None, + None, + execution_state, + ) + .unwrap_or(ProjectedExecutionFill { + price: self.projected_execution_price(market, OrderSide::Sell), + quantity, + next_cursor: date.and_time(self.intraday_execution_start_time()) + + Duration::seconds(1), + }); + let gross_amount = fill.price * fill.quantity as f64; + let net_cash = gross_amount - self.sell_cost(gross_amount); + projected + .position_mut(symbol) + .sell(fill.quantity, fill.price) + .ok()?; + projected.apply_cash_delta(net_cash); + *execution_state + .intraday_turnover + .entry(symbol.to_string()) + .or_default() += fill.quantity; + execution_state + .execution_cursors + .insert(symbol.to_string(), fill.next_cursor); + projected.prune_flat_positions(); + Some(fill.quantity) + } + + fn project_order_value( + &self, + ctx: &StrategyContext<'_>, + projected: &mut PortfolioState, + date: NaiveDate, + symbol: &str, + order_value: f64, + execution_state: &mut ProjectedExecutionState, + ) -> u32 { + if order_value <= 0.0 { + return 0; + } + let round_lot = self.projected_round_lot(ctx, symbol); + let market = match ctx.data.market(date, symbol) { + Some(market) => market, + None => return 0, + }; + let sizing_price = market.price(PriceField::Last); + if !sizing_price.is_finite() || sizing_price <= 0.0 { + return 0; + } + let snapshot_requested_qty = self.round_lot_quantity( + ((projected.cash().min(order_value)) / sizing_price).floor() as u32, + round_lot, + ); + let execution_price = self.projected_execution_price(market, OrderSide::Buy); + let mut quantity = snapshot_requested_qty; + while quantity > 0 { + let gross_amount = execution_price * quantity as f64; + if gross_amount <= order_value + 400.0 && gross_amount + self.buy_commission(gross_amount) <= projected.cash() + 1e-6 { + break; + } + quantity = quantity.saturating_sub(round_lot); + } + if quantity == 0 { + return 0; + } + let fill = self + .projected_select_execution_fill( + ctx, + date, + symbol, + OrderSide::Buy, + quantity, + round_lot, + Some(projected.cash()), + Some(order_value + 400.0), + execution_state, + ) + .unwrap_or(ProjectedExecutionFill { + price: execution_price, + quantity, + next_cursor: date.and_time(self.intraday_execution_start_time()) + + Duration::seconds(1), + }); + let gross_amount = fill.price * fill.quantity as f64; + let cash_out = gross_amount + self.buy_commission(gross_amount); + if cash_out > projected.cash() + 1e-6 { + return 0; + } + projected.apply_cash_delta(-cash_out); + projected.position_mut(symbol).buy(date, fill.quantity, fill.price); + *execution_state + .intraday_turnover + .entry(symbol.to_string()) + .or_default() += fill.quantity; + execution_state + .execution_cursors + .insert(symbol.to_string(), fill.next_cursor); + fill.quantity + } + + fn special_name(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool { + let instrument_name = ctx + .data + .instruments() + .get(symbol) + .map(|instrument| instrument.name.as_str()) + .unwrap_or(""); + instrument_name.contains("ST") + || instrument_name.contains('*') + || instrument_name.contains('退') + } + + fn day_state( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + ) -> Result { + let signal_close = ctx + .data + .market_decision_close(date, &self.config.signal_symbol) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: self.config.signal_symbol.clone(), + field: "decision_close", + })?; + let benchmark_close = ctx + .data + .benchmark(date) + .ok_or(BacktestError::MissingBenchmark { date })? + .close; + let benchmark_ma_short = ctx + .data + .market_decision_close_moving_average( + date, + &self.config.signal_symbol, + self.config.benchmark_short_ma_days, + ) + .ok_or_else(|| { + BacktestError::Execution(format!( + "insufficient benchmark short MA history for {} on {}", + self.config.signal_symbol, date + )) + })?; + let benchmark_ma_long = ctx + .data + .market_decision_close_moving_average( + date, + &self.config.signal_symbol, + self.config.benchmark_long_ma_days, + ) + .ok_or_else(|| { + BacktestError::Execution(format!( + "insufficient benchmark long MA history for {} on {}", + self.config.signal_symbol, date + )) + })?; + let benchmark_ma5 = ctx + .data + .market_decision_close_moving_average(date, &self.config.signal_symbol, 5) + .unwrap_or(benchmark_ma_short); + let benchmark_ma10 = ctx + .data + .market_decision_close_moving_average(date, &self.config.signal_symbol, 10) + .unwrap_or(benchmark_ma_long); + let benchmark_ma20 = ctx + .data + .market_decision_close_moving_average(date, &self.config.signal_symbol, 20) + .unwrap_or(benchmark_ma10); + + Ok(DayExpressionState { + signal_close, + benchmark_close, + benchmark_ma_short, + benchmark_ma_long, + benchmark_ma5, + benchmark_ma10, + benchmark_ma20, + signal_ma5: benchmark_ma5, + signal_ma10: benchmark_ma10, + signal_ma20: benchmark_ma20, + }) + } + + fn stock_state( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + ) -> Result { + let market = ctx.data.require_market(date, symbol)?; + let factor = ctx.data.require_factor(date, symbol)?; + let candidate = ctx.data.require_candidate(date, symbol)?; + let stock_ma_short = ctx + .data + .market_decision_close_moving_average(date, symbol, self.config.stock_short_ma_days) + .unwrap_or(0.0); + let stock_ma_mid = ctx + .data + .market_decision_close_moving_average(date, symbol, self.config.stock_mid_ma_days) + .unwrap_or(0.0); + let stock_ma_long = ctx + .data + .market_decision_close_moving_average(date, symbol, self.config.stock_long_ma_days) + .unwrap_or(0.0); + let stock_ma5 = ctx + .data + .market_decision_close_moving_average(date, symbol, 5) + .unwrap_or(stock_ma_short); + let stock_ma10 = ctx + .data + .market_decision_close_moving_average(date, symbol, 10) + .unwrap_or(stock_ma_mid); + let stock_ma20 = ctx + .data + .market_decision_close_moving_average(date, symbol, 20) + .unwrap_or(stock_ma_long); + + Ok(StockExpressionState { + market_cap: factor.market_cap_bn, + free_float_cap: factor.free_float_cap_bn, + open: market.day_open, + close: market.close, + last: market.last_price, + prev_close: market.prev_close, + upper_limit: market.upper_limit, + lower_limit: market.lower_limit, + paused: market.paused || candidate.is_paused, + is_st: candidate.is_st || self.special_name(ctx, symbol), + is_kcb: candidate.is_kcb, + is_one_yuan: candidate.is_one_yuan || market.day_open <= 1.0, + is_new_listing: candidate.is_new_listing, + allow_buy: candidate.allow_buy, + allow_sell: candidate.allow_sell, + listed_days: if candidate.is_new_listing { 0 } else { 365 }, + stock_ma_short, + stock_ma_mid, + stock_ma_long, + stock_ma5, + stock_ma10, + stock_ma20, + }) + } + + fn eval_scope( + &self, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + position: Option<&PositionExpressionState>, + ) -> Scope<'static> { + let mut scope = Scope::new(); + scope.push("signal_close", day.signal_close); + scope.push("benchmark_close", day.benchmark_close); + scope.push("signal_ma5", day.signal_ma5); + scope.push("signal_ma10", day.signal_ma10); + scope.push("signal_ma20", day.signal_ma20); + scope.push("benchmark_ma5", day.benchmark_ma5); + scope.push("benchmark_ma10", day.benchmark_ma10); + scope.push("benchmark_ma20", day.benchmark_ma20); + scope.push("benchmark_ma_short", day.benchmark_ma_short); + scope.push("benchmark_ma_long", day.benchmark_ma_long); + if let Some(stock) = stock { + scope.push("market_cap", stock.market_cap); + scope.push("free_float_cap", stock.free_float_cap); + scope.push("open", stock.open); + scope.push("close", stock.close); + scope.push("last", stock.last); + scope.push("last_price", stock.last); + scope.push("prev_close", stock.prev_close); + scope.push("upper_limit", stock.upper_limit); + scope.push("lower_limit", stock.lower_limit); + scope.push("paused", stock.paused); + scope.push("is_st", stock.is_st); + scope.push("is_kcb", stock.is_kcb); + scope.push("is_one_yuan", stock.is_one_yuan); + scope.push("is_new_listing", stock.is_new_listing); + scope.push("allow_buy", stock.allow_buy); + scope.push("allow_sell", stock.allow_sell); + scope.push("listed_days", stock.listed_days); + scope.push("stock_ma_short", stock.stock_ma_short); + scope.push("stock_ma_mid", stock.stock_ma_mid); + scope.push("stock_ma_long", stock.stock_ma_long); + scope.push("stock_ma5", stock.stock_ma5); + scope.push("stock_ma10", stock.stock_ma10); + scope.push("stock_ma20", stock.stock_ma20); + } + if let Some(position) = position { + scope.push("avg_cost", position.avg_cost); + scope.push("current_price", position.current_price); + scope.push("holding_return", position.holding_return); + scope.push("quantity", position.quantity); + scope.push("sellable_qty", position.sellable_qty); + } + scope + } + + fn eval_dynamic( + &self, + expr: &str, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + position: Option<&PositionExpressionState>, + ) -> Result { + let mut scope = self.eval_scope(day, stock, position); + let normalized_expr = Self::normalize_expr(expr); + let script = if self.config.prelude.trim().is_empty() { + normalized_expr + } else { + format!("{}\n{}", self.config.prelude, normalized_expr) + }; + self.engine + .eval_with_scope::(&mut scope, &script) + .map_err(|error| BacktestError::Execution(format!("platform expr eval failed: {}", error))) + } + + fn normalize_expr(expr: &str) -> String { + Self::rewrite_ternary(expr.trim()) + } + + fn rewrite_ternary(expr: &str) -> String { + let Some((question_idx, colon_idx)) = Self::find_top_level_ternary(expr) else { + return expr.trim().to_string(); + }; + let condition = Self::rewrite_ternary(expr[..question_idx].trim()); + let when_true = Self::rewrite_ternary(expr[question_idx + 1..colon_idx].trim()); + let when_false = Self::rewrite_ternary(expr[colon_idx + 1..].trim()); + format!("if {} {{ {} }} else {{ {} }}", condition, when_true, when_false) + } + + fn find_top_level_ternary(expr: &str) -> Option<(usize, usize)> { + let mut paren_depth = 0i32; + let mut brace_depth = 0i32; + let mut bracket_depth = 0i32; + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escaped = false; + let mut question_stack = Vec::new(); + + for (idx, ch) in expr.char_indices() { + if escaped { + escaped = false; + continue; + } + match ch { + '\\' if in_single_quote || in_double_quote => { + escaped = true; + } + '\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + } + '"' if !in_single_quote => { + in_double_quote = !in_double_quote; + } + _ if in_single_quote || in_double_quote => {} + '(' => paren_depth += 1, + ')' => paren_depth -= 1, + '{' => brace_depth += 1, + '}' => brace_depth -= 1, + '[' => bracket_depth += 1, + ']' => bracket_depth -= 1, + '?' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => { + question_stack.push(idx); + } + ':' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => { + if let Some(question_idx) = question_stack.pop() { + if question_stack.is_empty() { + return Some((question_idx, idx)); + } + } + } + _ => {} + } + } + None + } + + fn eval_float( + &self, + expr: &str, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + position: Option<&PositionExpressionState>, + ) -> Result { + let value = self.eval_dynamic(expr, day, stock, position)?; + if let Some(number) = value.clone().try_cast::() { + return Ok(number); + } + if let Some(number) = value.clone().try_cast::() { + return Ok(number as f64); + } + if let Some(boolean) = value.try_cast::() { + return Ok(if boolean { 1.0 } else { 0.0 }); + } + Err(BacktestError::Execution(format!( + "platform expr did not produce a number: {}", + expr + ))) + } + + fn eval_bool( + &self, + expr: &str, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + position: Option<&PositionExpressionState>, + ) -> Result { + let value = self.eval_dynamic(expr, day, stock, position)?; + if let Some(boolean) = value.clone().try_cast::() { + return Ok(boolean); + } + if let Some(number) = value.clone().try_cast::() { + return Ok(number != 0.0); + } + if let Some(number) = value.try_cast::() { + return Ok(number != 0); + } + Err(BacktestError::Execution(format!( + "platform expr did not produce a bool: {}", + expr + ))) + } + + fn trading_ratio( + &self, + day: &DayExpressionState, + ) -> Result { + self.eval_float(&self.config.exposure_expr, day, None, None) + .map(|value| value.clamp(0.0, 1.0)) + } + + fn market_cap_band( + &self, + day: &DayExpressionState, + ) -> Result<(f64, f64), BacktestError> { + let low = self.eval_float(&self.config.market_cap_lower_expr, day, None, None)?; + let high = self.eval_float(&self.config.market_cap_upper_expr, day, None, None)?; + Ok((low.min(high), low.max(high))) + } + + fn selection_limit( + &self, + day: &DayExpressionState, + ) -> Result { + let value = self.eval_float(&self.config.selection_limit_expr, day, None, None)?; + Ok(value.round().max(1.0) as usize) + } + + fn stock_passes_expr( + &self, + day: &DayExpressionState, + stock: &StockExpressionState, + ) -> Result { + if self.config.stock_filter_expr.trim().is_empty() { + return Ok(true); + } + self.eval_bool(&self.config.stock_filter_expr, day, Some(stock), None) + } + + fn field_value(&self, row: &EligibleUniverseSnapshot) -> f64 { + match self.config.market_cap_field.as_str() { + "free_float_cap" | "free_float_market_cap" => row.free_float_cap_bn, + _ => row.market_cap_bn, + } + } + + fn rank_value(&self, row: &EligibleUniverseSnapshot) -> f64 { + match self.config.rank_by.as_str() { + "free_float_cap" | "free_float_market_cap" => row.free_float_cap_bn, + _ => row.market_cap_bn, + } + } + + fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool { + let Some(position) = ctx.portfolio.position(symbol) else { + return false; + }; + if position.quantity == 0 || position.sellable_qty(date) == 0 { + return false; + } + let Ok(market) = ctx.data.require_market(date, symbol) else { + return false; + }; + let Ok(candidate) = ctx.data.require_candidate(date, symbol) else { + return false; + }; + let lower_limit_check_price = market.price(PriceField::Last); + !(market.paused + || candidate.is_paused + || !candidate.allow_sell + || market.is_at_lower_limit_price(lower_limit_check_price)) + } + + fn buy_rejection_reason( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + stock: &StockExpressionState, + ) -> Result, BacktestError> { + let market = ctx.data.require_market(date, symbol)?; + let candidate = ctx.data.require_candidate(date, symbol)?; + + let excludes = &self.config.universe_exclude; + if excludes.iter().any(|item| item == "paused") && (market.paused || candidate.is_paused) { + return Ok(Some("paused".to_string())); + } + if excludes.iter().any(|item| item == "st") && stock.is_st { + return Ok(Some("st_or_special_name".to_string())); + } + if excludes.iter().any(|item| item == "kcb") && candidate.is_kcb { + return Ok(Some("kcb".to_string())); + } + if excludes.iter().any(|item| item == "new_listing") && candidate.is_new_listing { + return Ok(Some("new_listing".to_string())); + } + if excludes.iter().any(|item| item == "one_yuan") && stock.is_one_yuan { + return Ok(Some("one_yuan".to_string())); + } + if !candidate.allow_buy { + return Ok(Some("buy_disabled".to_string())); + } + if market.is_at_upper_limit_price(market.day_open) + || market.is_at_upper_limit_price(market.buy_price(PriceField::Last)) + { + return Ok(Some("upper_limit".to_string())); + } + if market.is_at_lower_limit_price(market.day_open) + || market.is_at_lower_limit_price(market.sell_price(PriceField::Last)) + { + return Ok(Some("lower_limit".to_string())); + } + Ok(None) + } + + fn select_symbols( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + day: &DayExpressionState, + band_low: f64, + band_high: f64, + limit: usize, + ) -> Result<(Vec, Vec), BacktestError> { + let universe = ctx.data.eligible_universe_on(date); + let mut diagnostics = Vec::new(); + let mut candidates = universe + .iter() + .filter(|candidate| { + let field_value = self.field_value(candidate); + field_value >= band_low && field_value <= band_high + }) + .cloned() + .collect::>(); + candidates.sort_by(|lhs, rhs| { + let lhs_value = self.rank_value(lhs); + let rhs_value = self.rank_value(rhs); + if self.config.rank_desc { + rhs_value + .partial_cmp(&lhs_value) + .unwrap_or(std::cmp::Ordering::Equal) + } else { + lhs_value + .partial_cmp(&rhs_value) + .unwrap_or(std::cmp::Ordering::Equal) + } + }); + + let mut selected = Vec::new(); + for candidate in candidates { + let stock = self.stock_state(ctx, date, &candidate.symbol)?; + if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol, &stock)? { + if diagnostics.len() < 12 { + diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason)); + } + continue; + } + if !self.stock_passes_expr(day, &stock)? { + if diagnostics.len() < 12 { + diagnostics.push(format!("{} rejected by stock_expr", candidate.symbol)); + } + continue; + } + selected.push(candidate.symbol.clone()); + if selected.len() >= limit { + break; + } + } + + Ok((selected, diagnostics)) + } + + fn stop_take_action( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + day: &DayExpressionState, + symbol: &str, + ) -> Result<(bool, bool), BacktestError> { + let Some(position) = ctx.portfolio.position(symbol) else { + return Ok((false, false)); + }; + if position.quantity == 0 || position.average_cost <= 0.0 { + return Ok((false, false)); + } + let stock = self.stock_state(ctx, date, symbol)?; + let current_price = stock.last; + let holding_return = if position.average_cost > 0.0 { + current_price / position.average_cost - 1.0 + } else { + 0.0 + }; + let position_state = PositionExpressionState { + avg_cost: position.average_cost, + current_price, + holding_return, + quantity: position.quantity as i64, + sellable_qty: position.sellable_qty(date) as i64, + }; + let stop_result = self.eval_dynamic(&self.config.stop_loss_expr, day, Some(&stock), Some(&position_state))?; + let stop_hit = if let Some(boolean) = stop_result.clone().try_cast::() { + boolean + } else if let Some(multiplier) = stop_result.clone().try_cast::() { + current_price <= position.average_cost * multiplier + } else if let Some(multiplier) = stop_result.try_cast::() { + current_price <= position.average_cost * multiplier as f64 + } else { + false + }; + let take_result = self.eval_dynamic(&self.config.take_profit_expr, day, Some(&stock), Some(&position_state))?; + let profit_hit = if let Some(boolean) = take_result.clone().try_cast::() { + boolean + } else if let Some(multiplier) = take_result.clone().try_cast::() { + !ctx.data.require_market(date, symbol)?.is_at_upper_limit_price(current_price) + && current_price / position.average_cost > multiplier + } else if let Some(multiplier) = take_result.try_cast::() { + !ctx.data.require_market(date, symbol)?.is_at_upper_limit_price(current_price) + && current_price / position.average_cost > multiplier as f64 + } else { + false + }; + Ok((stop_hit, profit_hit)) + } +} + +impl Strategy for PlatformExprStrategy { + fn name(&self) -> &str { + self.config.strategy_name.as_str() + } + + fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { + let date = ctx.execution_date; + if self.config.in_skip_window(date) { + return Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), + order_intents: ctx + .portfolio + .positions() + .keys() + .cloned() + .map(|symbol| OrderIntent::TargetValue { + symbol, + target_value: 0.0, + reason: "seasonal_stop_window".to_string(), + }) + .collect(), + notes: vec![format!("seasonal stop window on {}", date)], + diagnostics: vec!["platform expr skip window forced all cash".to_string()], + }); + } + + let day = self.day_state(ctx, date)?; + let trading_ratio = self.trading_ratio(&day)?; + let (band_low, band_high) = self.market_cap_band(&day)?; + let selection_limit = self.selection_limit(&day)?.min(self.config.max_positions.max(1)); + let (stock_list, selection_notes) = + self.select_symbols(ctx, date, &day, band_low, band_high, selection_limit)?; + let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; + let mut projected = ctx.portfolio.clone(); + let mut projected_execution_state = ProjectedExecutionState::default(); + let mut order_intents = Vec::new(); + let mut exit_symbols = BTreeSet::new(); + + for position in ctx.portfolio.positions().values() { + if position.quantity == 0 || position.average_cost <= 0.0 { + continue; + } + let (stop_hit, profit_hit) = + self.stop_take_action(ctx, date, &day, &position.symbol)?; + let can_sell = self.can_sell_position(ctx, date, &position.symbol); + if stop_hit || profit_hit { + let sell_reason = if stop_hit { + "stop_loss_exit" + } else { + "take_profit_exit" + }; + exit_symbols.insert(position.symbol.clone()); + order_intents.push(OrderIntent::TargetValue { + symbol: position.symbol.clone(), + target_value: 0.0, + reason: sell_reason.to_string(), + }); + if can_sell { + self.project_target_zero( + ctx, + &mut projected, + date, + &position.symbol, + &mut projected_execution_state, + ); + } + + if projected.positions().len() < selection_limit { + let remaining_slots = selection_limit - projected.positions().len(); + if remaining_slots > 0 { + let replacement_cash = + projected.cash() * trading_ratio / remaining_slots as f64; + for symbol in &stock_list { + if symbol == &position.symbol + || projected.positions().contains_key(symbol) + { + continue; + } + let stock = self.stock_state(ctx, date, symbol)?; + if self.buy_rejection_reason(ctx, date, symbol, &stock)?.is_some() { + continue; + } + if !self.stock_passes_expr(&day, &stock)? { + continue; + } + order_intents.push(OrderIntent::Value { + symbol: symbol.clone(), + value: replacement_cash, + reason: format!("replacement_after_{}", sell_reason), + }); + self.project_order_value( + ctx, + &mut projected, + date, + symbol, + replacement_cash, + &mut projected_execution_state, + ); + break; + } + } + } + } + } + + if periodic_rebalance { + let pre_rebalance_symbols = projected + .positions() + .keys() + .cloned() + .collect::>(); + for symbol in pre_rebalance_symbols.iter() { + if stock_list.iter().any(|candidate| candidate == symbol) { + continue; + } + if !self.can_sell_position(ctx, date, symbol) { + continue; + } + order_intents.push(OrderIntent::TargetValue { + symbol: symbol.clone(), + target_value: 0.0, + reason: "periodic_rebalance_sell".to_string(), + }); + self.project_target_zero( + ctx, + &mut projected, + date, + symbol, + &mut projected_execution_state, + ); + } + + let fixed_buy_cash = projected.cash() * trading_ratio / selection_limit as f64; + for symbol in &stock_list { + if projected.positions().len() >= selection_limit { + break; + } + if pre_rebalance_symbols.contains(symbol) + || projected.positions().contains_key(symbol) + { + continue; + } + let stock = self.stock_state(ctx, date, symbol)?; + if self.buy_rejection_reason(ctx, date, symbol, &stock)?.is_some() { + continue; + } + if !self.stock_passes_expr(&day, &stock)? { + continue; + } + order_intents.push(OrderIntent::Value { + symbol: symbol.clone(), + value: fixed_buy_cash, + reason: "periodic_rebalance_buy".to_string(), + }); + self.project_order_value( + ctx, + &mut projected, + date, + symbol, + fixed_buy_cash, + &mut projected_execution_state, + ); + } + } + + let mut diagnostics = vec![ + format!( + "platform_expr signal={} last={:.2} ma_short={:.2} ma_long={:.2} band={:.2}-{:.2} tr={:.2}", + self.config.signal_symbol, + day.signal_close, + day.benchmark_ma_short, + day.benchmark_ma_long, + band_low, + band_high, + trading_ratio + ), + format!( + "selected={} periodic_rebalance={} exits={} projected_positions={} intents={} limit={}", + stock_list.len(), + periodic_rebalance, + exit_symbols.len(), + projected.positions().len(), + order_intents.len(), + selection_limit + ), + "platform strategy script executed through expression runtime + bid1/ask1 snapshot execution".to_string(), + ]; + diagnostics.extend(selection_notes); + + let notes = vec![ + format!("stock_list={}", stock_list.len()), + format!("projected_positions={}", projected.positions().len()), + ]; + + Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols, + order_intents, + notes, + diagnostics, + }) + } +}