From d071a8a1906c0ea442237d9edf343e5c8f93c50d Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 03:25:41 +0800 Subject: [PATCH 01/26] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=95=B0=E5=80=BC?= =?UTF-8?q?=E8=A1=A8=E8=BE=BE=E5=BC=8Fhelper=E6=89=A7=E8=A1=8C=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 260 +++++++++--------- 1 file changed, 127 insertions(+), 133 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 1002c51..f1b6aa2 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -926,7 +926,18 @@ enum RuntimeExpressionSegment { struct RuntimeHelperBinding { name: String, args: Vec, - scope_name: String, +} + +/// Typed result of resolving a runtime helper. +/// +/// The Rhai path still binds numeric values into its scope, but the numeric +/// VM consumes these values directly. Keeping the typed value here avoids a +/// per-evaluation `Dynamic -> String -> f64/bool` round trip. +#[derive(Debug)] +enum RuntimeHelperResolution { + Expression(String), + Number(f64), + Boolean(bool), } pub struct PlatformExprStrategy { @@ -4792,21 +4803,13 @@ impl PlatformExprStrategy { *self.numeric_vm_fallbacks.borrow_mut() += 1; return Ok(None); }; - let mut helper_scope = Scope::new(); let mut scratch = self.numeric_vm_scratch.borrow_mut(); let value = vm_plan .program .evaluate(&mut scratch, |index, identifier, expected_type| { if let Some(binding) = vm_plan.helper_bindings[index].as_ref() { return self - .numeric_vm_runtime_helper_value( - ctx, - day, - stock, - binding, - expected_type, - &mut helper_scope, - ) + .numeric_vm_runtime_helper_value(ctx, day, stock, binding, expected_type) .map_err(|error| NumericVmEvalError::new(error.to_string())); } self.numeric_vm_identifier_value(ctx, day, stock, position, identifier) @@ -4832,63 +4835,41 @@ impl PlatformExprStrategy { stock: Option<&StockExpressionState>, binding: &RuntimeHelperBinding, expected_type: NumericVmValueType, - scope: &mut Scope<'_>, ) -> Result { - let resolved = self.resolve_runtime_helper( - ctx, - day, - stock, - &binding.name, - &binding.args, - &binding.scope_name, - scope, - )?; - if resolved == binding.scope_name { - return match expected_type { - NumericVmValueType::Number => scope - .get_value::(&binding.scope_name) + let resolved = + self.resolve_runtime_helper(ctx, day, stock, &binding.name, &binding.args)?; + match (expected_type, resolved) { + (NumericVmValueType::Number, RuntimeHelperResolution::Number(value)) => { + Ok(NumericVmValue::Number(value)) + } + (NumericVmValueType::Boolean, RuntimeHelperResolution::Boolean(value)) => { + Ok(NumericVmValue::Boolean(value)) + } + (expected_type, RuntimeHelperResolution::Expression(expression)) => match expected_type + { + NumericVmValueType::Number => expression + .trim() + .parse::() .map(NumericVmValue::Number) - .or_else(|| { - scope - .get_value::(&binding.scope_name) - .map(|value| NumericVmValue::Number(value as f64)) - }) - .ok_or_else(|| { + .map_err(|_| { BacktestError::Execution(format!( - "runtime helper {} did not bind a numeric value", + "runtime helper {} produced non-numeric expression {expression:?}", binding.name )) }), - NumericVmValueType::Boolean => scope - .get_value::(&binding.scope_name) - .map(NumericVmValue::Boolean) - .ok_or_else(|| { - BacktestError::Execution(format!( - "runtime helper {} did not bind a boolean value", - binding.name - )) - }), - }; - } - match expected_type { - NumericVmValueType::Number => resolved - .trim() - .parse::() - .map(NumericVmValue::Number) - .map_err(|_| { - BacktestError::Execution(format!( - "runtime helper {} produced non-numeric expression {resolved:?}", + NumericVmValueType::Boolean => match expression.trim() { + "true" => Ok(NumericVmValue::Boolean(true)), + "false" => Ok(NumericVmValue::Boolean(false)), + _ => Err(BacktestError::Execution(format!( + "runtime helper {} produced non-boolean expression {expression:?}", binding.name - )) - }), - NumericVmValueType::Boolean => match resolved.trim() { - "true" => Ok(NumericVmValue::Boolean(true)), - "false" => Ok(NumericVmValue::Boolean(false)), - _ => Err(BacktestError::Execution(format!( - "runtime helper {} produced non-boolean expression {resolved:?}", - binding.name - ))), + ))), + }, }, + (expected_type, value) => Err(BacktestError::Execution(format!( + "runtime helper {} returned {:?}, expected {:?}", + binding.name, value, expected_type + ))), } } @@ -5849,7 +5830,6 @@ impl PlatformExprStrategy { let binding = RuntimeHelperBinding { name: name.clone(), args: args.clone(), - scope_name: scope_name.clone(), }; if let Some(existing) = bindings.get(scope_name) && (existing.name != binding.name || existing.args != binding.args) @@ -5981,9 +5961,17 @@ impl PlatformExprStrategy { name, args, scope_name, - } => output.push_str( - &self.resolve_runtime_helper(ctx, day, stock, name, args, scope_name, scope)?, - ), + } => match self.resolve_runtime_helper(ctx, day, stock, name, args)? { + RuntimeHelperResolution::Expression(expression) => output.push_str(&expression), + RuntimeHelperResolution::Number(value) => { + scope.push(scope_name.clone(), value); + output.push_str(scope_name); + } + RuntimeHelperResolution::Boolean(value) => { + scope.push(scope_name.clone(), value); + output.push_str(scope_name); + } + }, } } Ok(output) @@ -5996,9 +5984,7 @@ impl PlatformExprStrategy { stock: Option<&StockExpressionState>, helper: &str, args: &[String], - scope_name: &str, - scope: &mut Scope<'_>, - ) -> Result { + ) -> Result { match helper { "factor" => { let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier( @@ -6025,19 +6011,21 @@ impl PlatformExprStrategy { stock.symbol, day.date )) })?; - return Ok(Self::push_runtime_helper_value( - scope, - scope_name, - Dynamic::from(value), - )); + return Ok(RuntimeHelperResolution::Number(value)); } - Ok(format!("factors[{}]", Self::quote_rhai_string(&key))) + Ok(RuntimeHelperResolution::Expression(format!( + "factors[{}]", + Self::quote_rhai_string(&key) + ))) } "day_factor" => { let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier( args.first().map(String::as_str).unwrap_or_default(), )?); - Ok(format!("day_factors[{}]", Self::quote_rhai_string(&key))) + Ok(RuntimeHelperResolution::Expression(format!( + "day_factors[{}]", + Self::quote_rhai_string(&key) + ))) } "rolling_mean" | "sma" | "ma" => { if args.len() != 2 { @@ -6048,11 +6036,7 @@ impl PlatformExprStrategy { let field = Self::parse_string_or_identifier(&args[0])?; let lookback = Self::parse_positive_usize(&args[1])?; let value = self.resolve_rolling_mean(ctx, day, stock, &field, lookback)?; - Ok(Self::push_runtime_helper_value( - scope, - scope_name, - Dynamic::from(value), - )) + Ok(RuntimeHelperResolution::Number(value)) } "rolling_mean_current" => { if args.len() != 2 { @@ -6063,11 +6047,7 @@ impl PlatformExprStrategy { let field = Self::parse_string_or_identifier(&args[0])?; let lookback = Self::parse_positive_usize(&args[1])?; let value = self.resolve_current_rolling_mean(ctx, day, stock, &field, lookback)?; - Ok(Self::push_runtime_helper_value( - scope, - scope_name, - Dynamic::from(value), - )) + Ok(RuntimeHelperResolution::Number(value)) } "rolling_max_current" => { if args.len() != 2 { @@ -6080,7 +6060,7 @@ impl PlatformExprStrategy { let values = self.resolve_current_rolling_values(ctx, day, stock, &field, lookback)?; let value = values.iter().copied().fold(f64::NEG_INFINITY, f64::max); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "rolling_return_stddev_current" => { if args.len() != 2 { @@ -6106,7 +6086,9 @@ impl PlatformExprStrategy { "invalid current rolling return for field {field} with count {return_count}" ))); } - Ok(Self::format_rhai_float(rolling_sample_stddev(&returns))) + Ok(Self::normalized_runtime_number(rolling_sample_stddev( + &returns, + ))) } "vma" => { if args.len() != 1 { @@ -6116,11 +6098,7 @@ impl PlatformExprStrategy { } let lookback = Self::parse_positive_usize(&args[0])?; let value = self.resolve_rolling_mean(ctx, day, stock, "volume", lookback)?; - Ok(Self::push_runtime_helper_value( - scope, - scope_name, - Dynamic::from(value), - )) + Ok(RuntimeHelperResolution::Number(value)) } "rolling_sum" | "rolling_min" | "rolling_max" | "rolling_stddev" | "stddev" | "rolling_zscore" => { @@ -6140,7 +6118,7 @@ impl PlatformExprStrategy { "rolling_zscore" => rolling_zscore(&values), _ => 0.0, }; - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "pct_change" => { if args.len() != 2 { @@ -6164,7 +6142,7 @@ impl PlatformExprStrategy { } else { last / first - 1.0 }; - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "factor_value" | "get_factor_value" => { if args.is_empty() || args.len() > 2 { @@ -6183,7 +6161,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "factor_text" | "get_factor_text" => { if args.is_empty() || args.len() > 2 { @@ -6202,7 +6180,9 @@ impl PlatformExprStrategy { .last() .map(|row| row.value.clone()) .unwrap_or_default(); - Ok(Self::quote_rhai_string(&value)) + Ok(RuntimeHelperResolution::Expression( + Self::quote_rhai_string(&value), + )) } "dividend_cash" | "has_dividend" => { let (symbol, lookback) = @@ -6215,9 +6195,9 @@ impl PlatformExprStrategy { .map(|row| row.dividend_cash_before_tax) .sum::(); if helper == "has_dividend" { - Ok((total.abs() > f64::EPSILON).to_string()) + Ok(RuntimeHelperResolution::Boolean(total.abs() > f64::EPSILON)) } else { - Ok(Self::format_rhai_float(total)) + Ok(Self::normalized_runtime_number(total)) } } "split_ratio" | "has_split" => { @@ -6227,9 +6207,9 @@ impl PlatformExprStrategy { let splits = ctx.data.get_split(&symbol, start, day.date); let ratio = splits.iter().map(|row| row.split_ratio).product::(); if helper == "has_split" { - Ok((!splits.is_empty()).to_string()) + Ok(RuntimeHelperResolution::Boolean(!splits.is_empty())) } else { - Ok(Self::format_rhai_float(if splits.is_empty() { + Ok(Self::normalized_runtime_number(if splits.is_empty() { 1.0 } else { ratio @@ -6253,7 +6233,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "shares" | "get_shares_value" => { let stock = stock.ok_or_else(|| { @@ -6267,7 +6247,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "turnover_rate" | "get_turnover_rate_value" => { let stock = stock.ok_or_else(|| { @@ -6281,7 +6261,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "price_change_rate" | "get_price_change_rate_value" => { if args.len() > 1 { @@ -6299,7 +6279,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "stock_connect" | "get_stock_connect_value" => { let stock = stock.ok_or_else(|| { @@ -6313,7 +6293,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "current_performance" => { let stock = stock.ok_or_else(|| { @@ -6327,7 +6307,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "fundamental" | "get_fundamentals_value" => { let stock = stock.ok_or_else(|| { @@ -6341,7 +6321,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "financial" | "get_financials_value" => { let stock = stock.ok_or_else(|| { @@ -6355,7 +6335,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "pit_financial" | "get_pit_financials_value" => { let stock = stock.ok_or_else(|| { @@ -6369,7 +6349,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "industry_code" | "get_industry_code" => { if args.len() > 2 { @@ -6390,7 +6370,7 @@ impl PlatformExprStrategy { .get_industry(&stock.symbol, &source, level) .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "industry_name" | "get_industry_name" => { if args.len() > 2 { @@ -6411,7 +6391,9 @@ impl PlatformExprStrategy { .get_industry_name(&stock.symbol, &source, level) .map(|row| row.value) .unwrap_or_default(); - Ok(Self::quote_rhai_string(&value)) + Ok(RuntimeHelperResolution::Expression( + Self::quote_rhai_string(&value), + )) } "yield_curve" | "get_yield_curve_value" => { if args.is_empty() || args.len() > 2 { @@ -6427,7 +6409,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "is_margin_stock" => { if args.len() > 1 { @@ -6447,7 +6429,7 @@ impl PlatformExprStrategy { .get_margin_stocks(&margin_type) .iter() .any(|symbol| symbol == &stock.symbol); - Ok(matched.to_string()) + Ok(RuntimeHelperResolution::Boolean(matched)) } "dominant_future" | "get_dominant_future" => { if args.len() != 1 { @@ -6457,7 +6439,9 @@ impl PlatformExprStrategy { } let underlying = Self::parse_string_or_identifier(&args[0])?; let symbol = ctx.get_dominant_future(&underlying).unwrap_or_default(); - Ok(Self::quote_rhai_string(&symbol)) + Ok(RuntimeHelperResolution::Expression( + Self::quote_rhai_string(&symbol), + )) } "dominant_future_price" | "get_dominant_future_price_value" => { if args.is_empty() || args.len() > 3 { @@ -6478,7 +6462,7 @@ impl PlatformExprStrategy { .last() .map(|row| Self::price_bar_field(row, &field)) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } other => Err(BacktestError::Execution(format!( "unsupported platform helper: {other}" @@ -6574,6 +6558,17 @@ impl PlatformExprStrategy { )) } + fn normalized_runtime_number(value: f64) -> RuntimeHelperResolution { + let value = if value.is_finite() { + format!("{value:.12}") + .parse::() + .expect("formatted finite runtime helper number must parse") + } else { + 0.0 + }; + RuntimeHelperResolution::Number(value) + } + fn parse_optional_positive_usize( raw: Option<&String>, fallback: usize, @@ -6583,24 +6578,6 @@ impl PlatformExprStrategy { .map(|value| value.unwrap_or(fallback)) } - fn format_rhai_float(value: f64) -> String { - if value.is_finite() { - format!("{value:.12}") - } else { - "0.0".to_string() - } - } - - fn push_runtime_helper_value( - scope: &mut Scope<'_>, - scope_name: &str, - value: Dynamic, - ) -> String { - let name = scope_name.to_string(); - scope.push_dynamic(name.clone(), value); - name - } - fn runtime_helper_scope_name(helper: &str, args: &[String]) -> String { let signature = format!("{helper}({})", args.join(",")); let mut hash = 14_695_981_039_346_656_037u64; @@ -11989,8 +11966,8 @@ mod tests { PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformPortfolioDrawdownControlConfig, PlatformPortfolioDrawdownController, PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, - PlatformTradeAction, PlatformUniverseActionKind, SelectionRiskDeferral, - StockFilterQuoteUsage, framework_stock_rolling_factor_requirement, + PlatformTradeAction, PlatformUniverseActionKind, RuntimeHelperResolution, + SelectionRiskDeferral, StockFilterQuoteUsage, framework_stock_rolling_factor_requirement, }; use crate::{ AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, @@ -12058,6 +12035,23 @@ mod tests { assert_eq!(framework_stock_rolling_factor_requirement("alpha001"), None); } + #[test] + fn typed_runtime_numbers_preserve_legacy_rhai_formatting() { + let RuntimeHelperResolution::Number(value) = + PlatformExprStrategy::normalized_runtime_number(1.2345678901235) + else { + panic!("expected numeric helper result"); + }; + assert_eq!(value, "1.234567890123".parse::().unwrap()); + + let RuntimeHelperResolution::Number(value) = + PlatformExprStrategy::normalized_runtime_number(f64::NAN) + else { + panic!("expected numeric helper result"); + }; + assert_eq!(value, 0.0); + } + #[test] fn platform_rebalance_keeps_unresolved_delisted_position_without_orders_or_replacement() { let previous_date = d(2025, 1, 2); From 6604afd24f13f592b3583a3e209541a09271a90e Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 04:46:08 +0800 Subject: [PATCH 02/26] =?UTF-8?q?=E5=87=8F=E5=B0=91=E6=97=A5=E9=A2=91?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=AF=BB=E5=8F=96=E4=B8=B4=E6=97=B6=E5=88=86?= =?UTF-8?q?=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 14 ++++++++++++++ crates/fidc-core/src/engine.rs | 6 +++--- crates/fidc-core/src/platform_expr_strategy.rs | 6 +++--- crates/fidc-core/src/strategy.rs | 2 +- crates/fidc-core/src/universe.rs | 4 ++-- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 1ccf1cd..b3f815d 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -2456,6 +2456,13 @@ impl DataSet { .unwrap_or_default() } + pub fn market_snapshot_rows_on(&self, date: NaiveDate) -> &[DailyMarketSnapshot] { + self.market_by_date + .get(&date) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + pub fn candidate_snapshots_on(&self, date: NaiveDate) -> Vec<&CandidateEligibility> { self.candidate_by_date .get(&date) @@ -2463,6 +2470,13 @@ impl DataSet { .unwrap_or_default() } + pub fn candidate_snapshot_rows_on(&self, date: NaiveDate) -> &[CandidateEligibility] { + self.candidate_by_date + .get(&date) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + pub fn bundle_on(&self, date: NaiveDate) -> Result { let benchmark = self .benchmark(date) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index be7bf87..189275a 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1778,11 +1778,11 @@ where .filter(|date| self.config.end_date.map(|end| *date <= end).unwrap_or(true)) .collect::>(); let has_decision_inputs = |date: NaiveDate| { - !self.data.factor_snapshots_on(date).is_empty() - && !self.data.candidate_snapshots_on(date).is_empty() + !self.data.factor_snapshot_rows_on(date).is_empty() + && !self.data.candidate_snapshot_rows_on(date).is_empty() }; let has_execution_market = - |date: NaiveDate| !self.data.market_snapshots_on(date).is_empty(); + |date: NaiveDate| !self.data.market_snapshot_rows_on(date).is_empty(); let mut execution_dates = Vec::new(); let mut decision_slots = Vec::new(); for (calendar_idx, execution_date) in calendar_dates.iter().copied().enumerate() { diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index f1b6aa2..5624451 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3678,8 +3678,8 @@ impl PlatformExprStrategy { is_month_end, available_factor_names: if self.stock_extra_factors_required { ctx.data - .factor_snapshots_on(date) - .into_iter() + .factor_snapshot_rows_on(date) + .iter() .flat_map(|row| row.extra_factors.keys().map(|key| key.to_string())) .collect() } else { @@ -7797,7 +7797,7 @@ impl PlatformExprStrategy { return; }; let mut map = Map::new(); - for snapshot in ctx.data.market_snapshots_on(date) { + for snapshot in ctx.data.market_snapshot_rows_on(date) { if let Some(value) = Self::market_scope_value(snapshot, field) { map.insert(snapshot.symbol.clone().into(), Dynamic::from(value)); } diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 8c8aed1..3470740 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -2541,7 +2541,7 @@ impl OmniMicroCapStrategy { date: NaiveDate, ) -> Vec { let mut decisions = Vec::new(); - for factor in ctx.data.factor_snapshots_on(date) { + for factor in ctx.data.factor_snapshot_rows_on(date) { if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) { continue; } diff --git a/crates/fidc-core/src/universe.rs b/crates/fidc-core/src/universe.rs index c92b556..8c6d959 100644 --- a/crates/fidc-core/src/universe.rs +++ b/crates/fidc-core/src/universe.rs @@ -80,7 +80,7 @@ impl SelectionContext<'_> { } }; let mut decisions = Vec::new(); - for factor in self.data.factor_snapshots_on(self.decision_date) { + for factor in self.data.factor_snapshot_rows_on(self.decision_date) { if self .dynamic_universe .is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(&factor.symbol)) @@ -213,7 +213,7 @@ impl UniverseSelector for DynamicMarketCapBandSelector { risk_decisions: Vec::new(), }; - diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len(); + diagnostics.factor_total = ctx.data.factor_snapshot_rows_on(ctx.decision_date).len(); diagnostics.risk_decisions = ctx.selection_risk_decisions(); diagnostics.not_eligible_count = diagnostics.risk_decisions.len(); diagnostics.paused_count = diagnostics From 782bc640ff39914949c223535b0a2d8969bd6743 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 05:07:19 +0800 Subject: [PATCH 03/26] =?UTF-8?q?=E5=87=8F=E5=B0=91=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E5=9B=A0=E5=AD=90=E8=AF=BB=E5=8F=96=E4=B8=B4=E6=97=B6=E5=88=86?= =?UTF-8?q?=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 7 +++++++ crates/fidc-core/src/platform_expr_strategy.rs | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index b3f815d..c4f3e94 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -2449,6 +2449,13 @@ impl DataSet { .unwrap_or_default() } + pub fn factor_text_rows_on(&self, date: NaiveDate) -> &[FactorTextValue] { + self.factor_text_by_date + .get(&date) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + pub fn market_snapshots_on(&self, date: NaiveDate) -> Vec<&DailyMarketSnapshot> { self.market_by_date .get(&date) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 5624451..bbf87bc 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3687,8 +3687,8 @@ impl PlatformExprStrategy { }, available_text_factor_names: if self.stock_text_factors_required { ctx.data - .factor_text_snapshots_on(date) - .into_iter() + .factor_text_rows_on(date) + .iter() .map(|row| row.field.clone()) .collect() } else { @@ -4081,8 +4081,8 @@ impl PlatformExprStrategy { extra_factors, extra_text_factors: if self.stock_text_factors_required { ctx.data - .factor_text_snapshots_on(date) - .into_iter() + .factor_text_rows_on(date) + .iter() .filter(|row| row.symbol == symbol) .map(|row| (row.field.clone(), row.value.clone())) .collect() From 8b246a63f01f284f6bd413cf0fe12ad4f9580d13 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 06:38:19 +0800 Subject: [PATCH 04/26] =?UTF-8?q?=E8=B7=B3=E8=BF=87=E9=9B=B6=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=B4=B9=E6=97=A0=E6=95=88=E9=9B=86=E5=90=88=E5=A4=8D?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/engine.rs | 35 ++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 189275a..a1a6ab5 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -2952,20 +2952,27 @@ where merge_broker_report(&mut directive_report, futures_daily_settlement_report); let futures_expiration_report = self.settle_futures_expirations(execution_date); merge_broker_report(&mut directive_report, futures_expiration_report); - let dynamic_universe_snapshot = self.dynamic_universe.clone(); - let subscriptions_snapshot = self.subscriptions.clone(); - let management_fee_report = self.apply_management_fee( - execution_date, - decision_date, - decision_index, - &mut portfolio, - &post_close_open_orders, - dynamic_universe_snapshot.as_ref(), - &subscriptions_snapshot, - &mut process_events, - result.order_events.as_slice(), - result.fills.as_slice(), - )?; + let management_fee_report = if portfolio.management_fee_rate() <= 0.0 { + BrokerExecutionReport::default() + } else { + // The strategy context needs an immutable view while the + // engine mutably invokes the strategy. Avoid cloning these + // potentially large sets unless management fees are enabled. + let dynamic_universe_snapshot = self.dynamic_universe.clone(); + let subscriptions_snapshot = self.subscriptions.clone(); + self.apply_management_fee( + execution_date, + decision_date, + decision_index, + &mut portfolio, + &post_close_open_orders, + dynamic_universe_snapshot.as_ref(), + &subscriptions_snapshot, + &mut process_events, + result.order_events.as_slice(), + result.fills.as_slice(), + )? + }; merge_broker_report(&mut directive_report, management_fee_report); publish_phase_event( &mut self.strategy, From 670686681d1773505ba3ac3dd576b68617d9025c Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 06:43:36 +0800 Subject: [PATCH 05/26] =?UTF-8?q?=E5=87=8F=E5=B0=91=E6=AF=8F=E6=97=A5?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E6=96=87=E6=9C=AC=E4=B8=B4=E6=97=B6=E5=88=86?= =?UTF-8?q?=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/engine.rs | 63 ++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index a1a6ab5..255834c 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1935,14 +1935,14 @@ where .ok_or(BacktestError::MissingBenchmark { date: execution_date, })?; - let notes = corporate_action_notes.join(" | "); - let diagnostics = std::iter::once(format!( - "decision_lag_warmup lag_days={} execution_index={}", - self.config.decision_lag_trading_days, execution_idx - )) - .chain(broker_diagnostics.into_iter()) - .collect::>() - .join(" | "); + let notes = join_text_parts(corporate_action_notes.into_iter()); + let diagnostics = join_text_parts( + std::iter::once(format!( + "decision_lag_warmup lag_days={} execution_index={}", + self.config.decision_lag_trading_days, execution_idx + )) + .chain(broker_diagnostics.into_iter()), + ); let holdings_for_day = portfolio.holdings_summary(execution_date); let holding_start = result.daily_holdings.len(); let holding_count = holdings_for_day.len(); @@ -3012,17 +3012,17 @@ where .ok_or(BacktestError::MissingBenchmark { date: execution_date, })?; - let notes = corporate_action_notes - .into_iter() - .chain(decision.notes.into_iter()) - .collect::>() - .join(" | "); - let diagnostics = decision - .diagnostics - .into_iter() - .chain(broker_diagnostics.into_iter()) - .collect::>() - .join(" | "); + let notes = join_text_parts( + corporate_action_notes + .into_iter() + .chain(decision.notes.into_iter()), + ); + let diagnostics = join_text_parts( + decision + .diagnostics + .into_iter() + .chain(broker_diagnostics.into_iter()), + ); let holdings_for_day = portfolio.holdings_summary(execution_date); let holding_start = result.daily_holdings.len(); let holding_count = holdings_for_day.len(); @@ -4303,6 +4303,22 @@ fn futures_limit_satisfied(side: OrderSide, price: f64, limit_price: Option } } +fn join_text_parts(parts: I) -> String +where + I: IntoIterator, +{ + let mut iterator = parts.into_iter(); + let Some(first) = iterator.next() else { + return String::new(); + }; + let mut result = first; + for part in iterator { + result.push_str(" | "); + result.push_str(&part); + } + result +} + fn futures_cancel_report( date: NaiveDate, order: FuturesOpenOrder, @@ -4392,6 +4408,15 @@ mod tests { const SYMBOL: &str = "000001.SZ"; + #[test] + fn join_text_parts_matches_vec_join_contract() { + assert_eq!(super::join_text_parts(Vec::::new()), ""); + assert_eq!( + super::join_text_parts(vec!["a".to_string(), "".to_string(), "c".to_string()]), + "a | | c" + ); + } + #[derive(Debug)] struct BuyWhenDecisionDateStrategy { decision_date: NaiveDate, From 283bf56e9f70df4c6c418852ac2b9eef5373b4c2 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 07:57:23 +0800 Subject: [PATCH 06/26] =?UTF-8?q?=E4=B8=8B=E6=8E=A8=E5=88=86=E9=92=9F?= =?UTF-8?q?=E6=8A=A5=E4=BB=B7=E8=AE=A2=E9=98=85=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 28 +++++++++++++++++++++++++++- crates/fidc-core/src/engine.rs | 13 ++++--------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index c4f3e94..92e9995 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; use std::cmp::Reverse; -use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet}; use std::sync::{Arc, OnceLock}; use ahash::AHashMap; @@ -1692,11 +1692,24 @@ impl DataSet { } pub fn execution_quotes_on_date(&self, date: NaiveDate) -> Vec { + self.execution_quotes_on_date_for_symbols(date, None) + } + + pub fn execution_quotes_on_date_for_symbols( + &self, + date: NaiveDate, + symbols: Option<&BTreeSet>, + ) -> Vec { let Some(rows_by_symbol) = self.execution_quotes_by_date.get(&date) else { return Vec::new(); }; let mut streams = rows_by_symbol .iter() + .filter(|(symbol, _)| { + symbols + .map(|allowed_symbols| allowed_symbols.contains(*symbol)) + .unwrap_or(true) + }) .map(|(symbol, rows)| (symbol.as_str(), rows.as_slice())) .collect::>(); streams.sort_by_key(|(symbol, _)| *symbol); @@ -3929,6 +3942,19 @@ mod tests { ] ); assert_eq!(merged[2].last_price, 10.0); + let allowed_symbols = BTreeSet::from(["000001.SZ".to_string()]); + let filtered = run_data.execution_quotes_on_date_for_symbols(date, Some(&allowed_symbols)); + assert_eq!( + filtered + .iter() + .map(|row| (row.timestamp.time().to_string(), row.symbol.clone())) + .collect::>(), + vec![ + ("09:30:00".to_string(), "000001.SZ".to_string()), + ("09:31:00".to_string(), "000001.SZ".to_string()), + ("09:32:00".to_string(), "000001.SZ".to_string()), + ] + ); assert_eq!(run_data.remove_execution_quotes_on_date(date), 5); assert_eq!(run_data.execution_quote_count(), 0); } diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 255834c..090090a 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -2529,15 +2529,10 @@ where &mut minute_symbols, )?; } - let filter_by_subscription = !self.subscriptions.is_empty(); - let minute_quotes = self - .data - .execution_quotes_on_date(execution_date) - .into_iter() - .filter(|quote| { - !filter_by_subscription || self.subscriptions.contains("e.symbol) - }) - .collect::>(); + let minute_quotes = self.data.execution_quotes_on_date_for_symbols( + execution_date, + (!self.subscriptions.is_empty()).then_some(&self.subscriptions), + ); let requires_minute_callbacks = self.strategy.requires_minute_callbacks(); let has_minute_process_listeners = self.process_event_bus.has_listeners_for(&[ ProcessEventKind::PreMinute, From 33370fb6944d197b21e8e84d722cdba73da082cf Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 13:34:36 +0800 Subject: [PATCH 07/26] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=9B=9E=E6=B5=8B?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E8=BE=B9=E7=95=8C=E7=8A=B6=E6=80=81=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/engine.rs | 196 ++++++++++++++++++++++--- crates/fidc-core/src/lib.rs | 3 +- crates/fidc-core/tests/engine_hooks.rs | 72 ++++++++- 3 files changed, 246 insertions(+), 25 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 090090a..6b99072 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -105,6 +105,79 @@ pub struct BacktestResult { pub holdings_summary: Vec, pub daily_holdings: Vec, pub metrics: BacktestMetrics, + pub terminal_audit: BacktestTerminalAudit, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BacktestTerminalStatus { + Clean, + CompletedWithPendingState, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BacktestTerminalOpenOrder { + pub asset_class: BacktestTerminalAssetClass, + pub order_id: u64, + pub symbol: String, + pub side: String, + pub requested_quantity: u32, + pub filled_quantity: u32, + pub remaining_quantity: u32, + pub limit_price: f64, + pub reason: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BacktestTerminalAssetClass { + Stock, + Futures, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BacktestTerminalAudit { + pub status: BacktestTerminalStatus, + pub last_execution_date: Option, + pub stock_open_order_count: usize, + pub futures_open_order_count: usize, + pub pending_cash_flow_count: usize, + pub pending_cash_flow_net_amount: f64, + pub cash_receivable_count: usize, + pub cash_receivable_total_amount: f64, + pub earliest_deferred_cash_date: Option, + pub open_order_samples: Vec, + pub omitted_open_order_count: usize, +} + +impl Default for BacktestTerminalAudit { + fn default() -> Self { + Self { + status: BacktestTerminalStatus::Clean, + last_execution_date: None, + stock_open_order_count: 0, + futures_open_order_count: 0, + pending_cash_flow_count: 0, + pending_cash_flow_net_amount: 0.0, + cash_receivable_count: 0, + cash_receivable_total_amount: 0.0, + earliest_deferred_cash_date: None, + open_order_samples: Vec::new(), + omitted_open_order_count: 0, + } + } +} + +impl BacktestTerminalAudit { + pub fn is_clean(&self) -> bool { + self.status == BacktestTerminalStatus::Clean + } + + pub fn open_order_count(&self) -> usize { + self.stock_open_order_count + self.futures_open_order_count + } } #[derive(Debug, Clone)] @@ -186,6 +259,7 @@ pub struct AnalyzerReport { pub equity_curve: Vec, pub benchmark_series: Vec, pub metrics: BacktestMetrics, + pub terminal_audit: BacktestTerminalAudit, } impl BacktestResult { @@ -228,6 +302,7 @@ impl BacktestResult { equity_curve: self.equity_curve.clone(), benchmark_series: self.benchmark_series.clone(), metrics: self.metrics.clone(), + terminal_audit: self.terminal_audit.clone(), } } @@ -1065,28 +1140,111 @@ where fn open_order_views(&self) -> Vec { let mut views = self.broker.open_order_views(); - views.extend( - self.futures_open_orders - .iter() - .map(|order| crate::strategy::OpenOrderView { - order_id: order.order_id, - symbol: order.intent.symbol.clone(), - side: order.intent.side(), - requested_quantity: order.requested_quantity, - filled_quantity: order.filled_quantity, - remaining_quantity: order.remaining_quantity, - unfilled_quantity: order.remaining_quantity, - status: OrderStatus::Pending, - avg_price: 0.0, - transaction_cost: 0.0, - limit_price: order.limit_price, - reason: order.reason.clone(), - }), - ); + views.extend(self.futures_open_order_views()); views.sort_by_key(|order| order.order_id); views } + fn futures_open_order_views(&self) -> Vec { + self.futures_open_orders + .iter() + .map(|order| crate::strategy::OpenOrderView { + order_id: order.order_id, + symbol: order.intent.symbol.clone(), + side: order.intent.side(), + requested_quantity: order.requested_quantity, + filled_quantity: order.filled_quantity, + remaining_quantity: order.remaining_quantity, + unfilled_quantity: order.remaining_quantity, + status: OrderStatus::Pending, + avg_price: 0.0, + transaction_cost: 0.0, + limit_price: order.limit_price, + reason: order.reason.clone(), + }) + .collect() + } + + fn terminal_audit( + &self, + portfolio: &PortfolioState, + last_execution_date: Option, + ) -> BacktestTerminalAudit { + const OPEN_ORDER_SAMPLE_LIMIT: usize = 20; + + let stock_open_orders = self.broker.open_order_views(); + let futures_open_orders = self.futures_open_order_views(); + let stock_open_order_count = stock_open_orders.len(); + let futures_open_order_count = futures_open_orders.len(); + let open_order_count = stock_open_order_count + futures_open_order_count; + let pending_cash_flow_count = portfolio.pending_cash_flows().len(); + let cash_receivable_count = portfolio.cash_receivables().len(); + let pending_cash_flow_net_amount = portfolio + .pending_cash_flows() + .iter() + .map(|flow| flow.amount) + .sum(); + let cash_receivable_total_amount = portfolio + .cash_receivables() + .iter() + .map(|receivable| receivable.amount) + .sum(); + let earliest_deferred_cash_date = portfolio + .pending_cash_flows() + .iter() + .map(|flow| flow.payable_date) + .chain( + portfolio + .cash_receivables() + .iter() + .map(|receivable| receivable.payable_date), + ) + .min(); + let open_order_samples = stock_open_orders + .iter() + .map(|order| (BacktestTerminalAssetClass::Stock, order)) + .chain( + futures_open_orders + .iter() + .map(|order| (BacktestTerminalAssetClass::Futures, order)), + ) + .take(OPEN_ORDER_SAMPLE_LIMIT) + .map(|(asset_class, order)| BacktestTerminalOpenOrder { + asset_class, + order_id: order.order_id, + symbol: order.symbol.clone(), + side: order.side.as_str().to_string(), + requested_quantity: order.requested_quantity, + filled_quantity: order.filled_quantity, + remaining_quantity: order.remaining_quantity, + limit_price: order.limit_price, + reason: order.reason.clone(), + }) + .collect::>(); + let status = if open_order_count == 0 + && pending_cash_flow_count == 0 + && cash_receivable_count == 0 + { + BacktestTerminalStatus::Clean + } else { + BacktestTerminalStatus::CompletedWithPendingState + }; + + BacktestTerminalAudit { + status, + last_execution_date, + stock_open_order_count, + futures_open_order_count, + pending_cash_flow_count, + pending_cash_flow_net_amount, + cash_receivable_count, + cash_receivable_total_amount, + earliest_deferred_cash_date, + omitted_open_order_count: open_order_count.saturating_sub(open_order_samples.len()), + open_order_samples, + } + } + fn has_open_orders(&self) -> bool { self.broker.has_open_orders() || !self.futures_open_orders.is_empty() } @@ -1840,6 +1998,7 @@ where holdings_summary: Vec::new(), daily_holdings: Vec::new(), metrics: BacktestMetrics::default(), + terminal_audit: BacktestTerminalAudit::default(), }; let mut stock_equity_by_date = BTreeMap::::new(); let mut previous_external_cash_flow_total = portfolio.external_cash_flow_total(); @@ -3086,6 +3245,7 @@ where if let Some(last_date) = execution_dates.last().copied() { result.holdings_summary = portfolio.holdings_summary(last_date); } + result.terminal_audit = self.terminal_audit(&portfolio, execution_dates.last().copied()); result.metrics = compute_backtest_metrics( &result.equity_curve, &result.fills, diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 957054c..723f687 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -37,7 +37,8 @@ pub use data::{ pub use engine::{ AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary, AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, - BacktestResult, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig, + BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder, + BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig, }; pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus}; pub use events::{ diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index e2db4e3..3a1162e 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -6,12 +6,13 @@ use std::sync::{Arc, Mutex}; use chrono::{NaiveDate, NaiveDateTime}; use fidc_core::{ BacktestConfig, BacktestEngine, BacktestProcessMod, BacktestProcessModLoader, - BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, - ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, ExecutionQuoteRequest, - FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection, - FuturesOrderIntent, FuturesPositionEffect, FuturesTradingParameter, FuturesValidationConfig, - Instrument, IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, - NumericFactorMap, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PlatformExprStrategy, + BacktestTerminalAssetClass, BacktestTerminalStatus, BenchmarkSnapshot, BrokerSimulator, + CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, + DailyMarketSnapshot, DataSet, ExecutionQuoteRequest, FuturesAccountState, + FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesOrderIntent, + FuturesPositionEffect, FuturesTradingParameter, FuturesValidationConfig, Instrument, + IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, NumericFactorMap, + OpenOrderView, OrderIntent, OrderSide, OrderStatus, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformTradeAction, PortfolioState, PriceField, ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, @@ -1683,6 +1684,61 @@ fn engine_matches_pending_futures_limit_order_with_data_driven_costs() { .expect("long futures position"); assert_eq!(position.quantity, 2); assert!((position.contract_multiplier - 300.0).abs() < 1e-6); + assert_eq!(result.terminal_audit.status, BacktestTerminalStatus::Clean); + assert_eq!(result.terminal_audit.open_order_count(), 0); +} + +#[test] +fn engine_reports_pending_futures_order_at_backtest_boundary() { + let date = d(2025, 1, 2); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut engine = BacktestEngine::new( + two_day_futures_data(), + FuturesLimitOrderStrategy, + broker, + BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ) + .with_futures_initial_cash(1_000_000.0); + + let result = engine.run().expect("backtest succeeds"); + + assert!(result.fills.is_empty()); + assert_eq!( + result.terminal_audit.status, + BacktestTerminalStatus::CompletedWithPendingState + ); + assert_eq!(result.terminal_audit.last_execution_date, Some(date)); + assert_eq!(result.terminal_audit.stock_open_order_count, 0); + assert_eq!(result.terminal_audit.futures_open_order_count, 1); + assert_eq!(result.terminal_audit.open_order_count(), 1); + assert_eq!(result.terminal_audit.omitted_open_order_count, 0); + assert_eq!(result.terminal_audit.open_order_samples.len(), 1); + assert_eq!( + result.terminal_audit.open_order_samples[0].asset_class, + BacktestTerminalAssetClass::Futures + ); + assert_eq!(result.terminal_audit.open_order_samples[0].symbol, "IF2501"); + assert_eq!( + result.terminal_audit.open_order_samples[0].remaining_quantity, + 2 + ); + assert!( + result + .order_events + .iter() + .any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::Pending }) + ); } #[test] @@ -2895,6 +2951,8 @@ fn engine_applies_account_cash_flow_and_financing_intents() { assert!(result.process_events.iter().any(|event| { event.kind == ProcessEventKind::AccountManagementFee && event.detail.contains("fee=42.00") })); + assert_eq!(result.terminal_audit.status, BacktestTerminalStatus::Clean); + assert_eq!(result.terminal_audit.pending_cash_flow_count, 0); } #[test] @@ -3066,6 +3124,8 @@ fn engine_rejects_pending_limit_orders_at_market_close() { assert!(result.process_events.iter().any(|event| { event.date == date1 && event.kind == ProcessEventKind::OrderUnsolicitedUpdate })); + assert_eq!(result.terminal_audit.status, BacktestTerminalStatus::Clean); + assert_eq!(result.terminal_audit.stock_open_order_count, 0); } #[test] From c18306aed9429592fc73f0da9e6c0730816c72df Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 13:59:18 +0800 Subject: [PATCH 08/26] =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=E8=B5=84=E9=87=91=E5=88=B0=E8=B4=A6=E8=A1=A8=E8=BE=BE=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_strategy_spec.rs | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 63207e7..4c9bbbf 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -777,6 +777,8 @@ pub struct StrategyExpressionActionConfig { pub symbols_expr: Option, #[serde(default)] pub amount_expr: Option, + #[serde(default, alias = "receiving_days_expr")] + pub receiving_days_expr: Option, #[serde(default)] pub direction: Option, #[serde(default)] @@ -2413,7 +2415,12 @@ fn parse_platform_trade_action( .map(str::trim) .filter(|value| !value.is_empty())? .to_string(), - receiving_days_expr: None, + receiving_days_expr: action + .receiving_days_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), when_expr, reason, }), @@ -2641,6 +2648,39 @@ mod tests { assert_eq!(cfg.explicit_actions.len(), 1); } + #[test] + fn parses_delayed_deposit_receiving_days_expression() { + let spec = serde_json::json!({ + "runtimeExpressions": { + "trading": { + "rotationEnabled": false, + "actions": [{ + "kind": "deposit_withdraw", + "amountExpr": "1000", + "receivingDaysExpr": "1", + "reason": "delayed capital injection" + }] + } + } + }); + + let cfg = platform_expr_config_from_value("cash-flow", "000300.SH", &spec) + .expect("delayed deposit config"); + + assert!(matches!( + cfg.explicit_actions.as_slice(), + [PlatformTradeAction::Account { + kind: PlatformAccountActionKind::DepositWithdraw, + amount_expr, + receiving_days_expr: Some(receiving_days_expr), + reason, + .. + }] if amount_expr == "1000" + && receiving_days_expr == "1" + && reason == "delayed capital injection" + )); + } + #[test] fn parses_generic_futures_actions_and_rejects_incomplete_contracts() { let spec = serde_json::json!({ From 935dd47e344c927a0182f1e7a4b794dcbc18a3a7 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 14:27:00 +0800 Subject: [PATCH 09/26] =?UTF-8?q?=E5=85=81=E8=AE=B8=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=80=81=E8=BF=BD=E5=8A=A0=E7=BB=93=E7=AE=97=E4=BA=A4=E6=98=93?= =?UTF-8?q?=E6=97=A5=E5=8E=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 92e9995..c9c4a1d 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -1188,6 +1188,16 @@ pub struct DataSet { } impl DataSet { + pub fn with_additional_trading_dates( + mut self, + dates: impl IntoIterator, + ) -> Self { + let mut calendar_dates = self.calendar.days().to_vec(); + calendar_dates.extend(dates); + self.calendar = Arc::new(TradingCalendar::new(calendar_dates)); + self + } + pub fn from_components( instruments: Vec, market: Vec, @@ -3869,6 +3879,37 @@ mod tests { assert_eq!(run_data.execution_quote_count(), 1); } + #[test] + fn additional_terminal_calendar_dates_are_isolated_from_shared_market_data() { + let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); + let next_date = NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "平安银行".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![market_row("2025-01-02", 10.0, 1_000_000)], + Vec::new(), + Vec::new(), + vec![benchmark_row("2025-01-02", 12.0)], + ) + .unwrap(); + + let run_data = data + .clone() + .with_additional_trading_dates([next_date, next_date]); + + assert_eq!(data.next_trading_date(date, 1), None); + assert_eq!(run_data.next_trading_date(date, 1), Some(next_date)); + assert!(run_data.market(next_date, "000001.SZ").is_none()); + assert!(Arc::ptr_eq(&data.market_by_date, &run_data.market_by_date)); + } + #[test] fn execution_quotes_use_stable_k_way_merge_and_release_by_date() { let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); From 0793473210b5581106cb14e503e07544b13bfa23 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 18:28:19 +0800 Subject: [PATCH 10/26] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=9B=9E=E6=B5=8B?= =?UTF-8?q?=E9=83=A8=E5=88=86=E6=88=90=E4=BA=A4=E7=BB=88=E6=80=81=E5=90=88?= =?UTF-8?q?=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 49 +++-------- crates/fidc-core/src/events.rs | 84 +++++++++++++++++++ crates/fidc-core/tests/explicit_order_flow.rs | 7 +- 3 files changed, 103 insertions(+), 37 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index e443483..ae15040 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -29,6 +29,15 @@ pub struct BrokerExecutionReport { pub diagnostics: Vec, } +impl BrokerExecutionReport { + fn validate(&self) -> Result<(), BacktestError> { + for event in &self.order_events { + event.validate().map_err(BacktestError::Execution)?; + } + Ok(()) + } +} + #[derive(Debug, Clone, Copy)] struct ExecutionLeg { price: f64, @@ -1038,6 +1047,7 @@ where self.runtime_target_position_limit .set(previous_target_position_limit); portfolio.prune_flat_positions(); + report.validate()?; return Ok(report); } @@ -1153,6 +1163,7 @@ where } portfolio.prune_flat_positions(); + report.validate()?; Ok(report) } @@ -3850,7 +3861,7 @@ where let status = if keep_open { OrderStatus::PartiallyFilled } else if filled_qty < requested_qty { - final_partial_fill_status(partial_fill_reason.as_deref()) + OrderStatus::Canceled } else { OrderStatus::Filled }; @@ -3862,14 +3873,6 @@ where "order_partial_fill symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}; remaining open" )); format!("{reason}: partial fill due to {detail}; remaining quantity pending") - } else if status == OrderStatus::PartiallyFilled { - let detail = partial_fill_reason - .as_deref() - .unwrap_or("remaining quantity could not be filled"); - report.diagnostics.push(format!( - "order_partial_fill symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}" - )); - format!("{reason}: partial fill due to {detail}") } else if status == OrderStatus::Canceled && filled_qty < requested_qty { let detail = partial_fill_reason .as_deref() @@ -5506,7 +5509,7 @@ where let status = if keep_open { OrderStatus::PartiallyFilled } else if filled_qty < requested_qty { - final_partial_fill_status(partial_fill_reason.as_deref()) + OrderStatus::Canceled } else { OrderStatus::Filled }; @@ -5518,14 +5521,6 @@ where "order_partial_fill symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}; remaining open" )); format!("{reason}: partial fill due to {detail}; remaining quantity pending") - } else if status == OrderStatus::PartiallyFilled { - let detail = partial_fill_reason - .as_deref() - .unwrap_or("remaining quantity could not be filled"); - report.diagnostics.push(format!( - "order_partial_fill symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}" - )); - format!("{reason}: partial fill due to {detail}") } else if status == OrderStatus::Canceled && filled_qty < requested_qty { let detail = partial_fill_reason .as_deref() @@ -6589,24 +6584,6 @@ fn zero_fill_status_for_reason(reason: &str) -> OrderStatus { } } -fn final_partial_fill_status(partial_reason: Option<&str>) -> OrderStatus { - match partial_reason { - Some(reason) - if reason.contains("market liquidity or volume limit") - || reason.contains("intraday quote liquidity exhausted") - || reason.contains("no execution quotes at or before start") - || reason.contains("no execution quotes after start") - || reason.contains("upper_limit") - || reason.contains("lower_limit") - || reason.contains("open at or above upper limit") - || reason.contains("open at or below lower limit") => - { - OrderStatus::Canceled - } - _ => OrderStatus::PartiallyFilled, - } -} - fn price_field_name(field: PriceField) -> &'static str { match field { PriceField::DayOpen => "day_open", diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 6aafe4a..0db981e 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -106,6 +106,49 @@ pub struct OrderEvent { pub reason: String, } +impl OrderEvent { + pub fn validate(&self) -> Result<(), String> { + if self.symbol.trim().is_empty() || self.requested_quantity == 0 { + return Err(format!( + "invalid order identity/quantity order_id={:?} symbol={} requested={}", + self.order_id, self.symbol, self.requested_quantity + )); + } + if self.filled_quantity > self.requested_quantity { + return Err(format!( + "order overfill order_id={:?} requested={} filled={}", + self.order_id, self.requested_quantity, self.filled_quantity + )); + } + let quantity_valid = match self.status { + OrderStatus::Pending => self.filled_quantity < self.requested_quantity, + OrderStatus::Filled => self.filled_quantity == self.requested_quantity, + OrderStatus::PartiallyFilled => { + self.filled_quantity > 0 && self.filled_quantity < self.requested_quantity + } + OrderStatus::Canceled => self.filled_quantity < self.requested_quantity, + OrderStatus::Rejected => self.filled_quantity == 0, + }; + if !quantity_valid { + return Err(format!( + "order status/quantity mismatch order_id={:?} status={} requested={} filled={}", + self.order_id, + self.status.as_str(), + self.requested_quantity, + self.filled_quantity + )); + } + if self.reason.trim().is_empty() { + return Err(format!( + "order reason is empty order_id={:?} status={}", + self.order_id, + self.status.as_str() + )); + } + Ok(()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FillEvent { #[serde(with = "date_format")] @@ -250,3 +293,44 @@ pub struct ProcessEvent { pub side: Option, pub detail: String, } + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + + use super::{OrderEvent, OrderSide, OrderStatus}; + + fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent { + OrderEvent { + date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), + decision_date: None, + order_created_date: None, + execution_date: None, + order_id: Some(1), + symbol: "600000.SH".to_string(), + side: OrderSide::Buy, + requested_quantity: 100, + filled_quantity, + status, + reason: "test".to_string(), + } + } + + #[test] + fn order_event_status_quantity_contract_is_explicit() { + assert!(order_event(OrderStatus::Pending, 0).validate().is_ok()); + assert!( + order_event(OrderStatus::PartiallyFilled, 40) + .validate() + .is_ok() + ); + assert!(order_event(OrderStatus::Filled, 100).validate().is_ok()); + assert!(order_event(OrderStatus::Canceled, 40).validate().is_ok()); + assert!(order_event(OrderStatus::Rejected, 0).validate().is_ok()); + + assert!(order_event(OrderStatus::PartiallyFilled, 0).validate().is_err()); + assert!(order_event(OrderStatus::Filled, 99).validate().is_err()); + assert!(order_event(OrderStatus::Canceled, 100).validate().is_err()); + assert!(order_event(OrderStatus::Rejected, 1).validate().is_err()); + } +} diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 7336ba8..e84fef4 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -5150,8 +5150,13 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() { assert_eq!(report.order_events.len(), 2); assert_eq!(report.order_events[0].status, OrderStatus::Pending); - assert_eq!(report.order_events[1].status, OrderStatus::PartiallyFilled); + assert_eq!(report.order_events[1].status, OrderStatus::Canceled); assert_eq!(report.order_events[1].filled_quantity, 100); + assert!(report.order_events[1].reason.contains("remaining quantity canceled")); + let open_orders = broker.open_order_views(); + assert_eq!(open_orders.len(), 1); + assert_eq!(open_orders[0].reason, "reserve_sell"); + assert_eq!(open_orders[0].remaining_quantity, 200); assert_eq!( portfolio.position("000002.SZ").expect("position").quantity, 200 From 88f5a1a0aece92b8e52ecca9374e6cbbca0b2c3f Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 19:48:21 +0800 Subject: [PATCH 11/26] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=8C=96=E8=AE=A2=E5=8D=95=E6=9C=89=E6=95=88=E6=9C=9F=E5=90=88?= =?UTF-8?q?=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 381 ++++++++++++++++-- crates/fidc-core/src/engine.rs | 7 +- crates/fidc-core/src/events.rs | 11 +- crates/fidc-core/src/lib.rs | 4 +- .../fidc-core/src/platform_expr_strategy.rs | 100 ++++- .../fidc-core/src/platform_strategy_spec.rs | 72 +++- crates/fidc-core/src/strategy.rs | 174 ++++++++ crates/fidc-core/src/strategy_ai.rs | 5 +- crates/fidc-core/tests/engine_hooks.rs | 6 +- crates/fidc-core/tests/explicit_order_flow.rs | 338 +++++++++++++++- 10 files changed, 1025 insertions(+), 73 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index ae15040..a1a9e0c 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -16,7 +16,8 @@ use crate::portfolio::PortfolioState; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, RiskCheckScope}; use crate::rules::{EquityRuleHooks, RuleCheck}; use crate::strategy::{ - AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing, + AlgoOrderStyle, OpenOrderView, OrderIntent, OrderTimeInForce, StrategyDecision, + TargetPortfolioOrderPricing, }; #[derive(Debug, Default)] @@ -64,6 +65,9 @@ struct OpenOrder { filled_quantity: u32, remaining_quantity: u32, limit_price: f64, + time_in_force: OrderTimeInForce, + commission_remaining: Option, + execution_cursor: Option, reason: String, } @@ -98,6 +102,14 @@ pub enum RebalanceCashMode { PreOpenCash, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemainderPolicy { + Cancel, + KeepUntilClose, + KeepUntilCanceled, + FillOrKill, +} + impl Default for RebalanceCashMode { fn default() -> Self { Self::SellThenBuy @@ -205,6 +217,7 @@ pub struct BrokerSimulator { runtime_order_created_date: Cell>, runtime_decision_total_equity: Cell>, runtime_target_position_limit: Cell>, + runtime_time_in_force: Cell>, next_order_id: Cell, open_orders: RefCell>, } @@ -235,6 +248,7 @@ impl BrokerSimulator { runtime_order_created_date: Cell::new(None), runtime_decision_total_equity: Cell::new(None), runtime_target_position_limit: Cell::new(None), + runtime_time_in_force: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), } @@ -269,6 +283,7 @@ impl BrokerSimulator { runtime_order_created_date: Cell::new(None), runtime_decision_total_equity: Cell::new(None), runtime_target_position_limit: Cell::new(None), + runtime_time_in_force: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), } @@ -366,6 +381,35 @@ impl BrokerSimulator { } } + fn effective_remainder_policy(&self, allow_pending_limit: bool) -> RemainderPolicy { + match self.runtime_time_in_force.get() { + Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill, + Some(OrderTimeInForce::Gtc) => RemainderPolicy::KeepUntilCanceled, + Some(OrderTimeInForce::Day) if allow_pending_limit => RemainderPolicy::KeepUntilClose, + Some(OrderTimeInForce::Day) => RemainderPolicy::Cancel, + Some(OrderTimeInForce::Ioc) => RemainderPolicy::Cancel, + None if allow_pending_limit => RemainderPolicy::KeepUntilClose, + None => RemainderPolicy::Cancel, + } + } + + fn pending_time_in_force(remainder_policy: RemainderPolicy) -> OrderTimeInForce { + match remainder_policy { + RemainderPolicy::KeepUntilClose => OrderTimeInForce::Day, + RemainderPolicy::KeepUntilCanceled => OrderTimeInForce::Gtc, + RemainderPolicy::Cancel | RemainderPolicy::FillOrKill => { + unreachable!("non-pending policy cannot create an open order") + } + } + } + + fn keeps_remainder_open(remainder_policy: RemainderPolicy) -> bool { + matches!( + remainder_policy, + RemainderPolicy::KeepUntilClose | RemainderPolicy::KeepUntilCanceled + ) + } + pub fn execution_price_field(&self) -> PriceField { self.execution_price_field } @@ -507,7 +551,7 @@ where } }; - match intent { + match intent.unwrapped() { OrderIntent::Shares { quantity, .. } | OrderIntent::LimitShares { quantity, .. } => { Some(if *quantity < 0 { OrderSide::Sell @@ -596,7 +640,7 @@ where } fn target_position_intent(intent: &OrderIntent) -> Option<(&str, bool)> { - match intent { + match intent.unwrapped() { OrderIntent::TargetShares { symbol, target_quantity, @@ -1248,7 +1292,39 @@ where commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { + if let OrderIntent::WithTimeInForce { + intent: wrapped, + time_in_force, + } = intent + { + if self.runtime_time_in_force.get().is_some() { + return Err(BacktestError::Execution( + "nested time-in-force wrappers are not allowed".to_string(), + )); + } + if !wrapped.supports_time_in_force(*time_in_force) { + return Err(BacktestError::Execution(format!( + "time_in_force={} is not supported for this order intent", + time_in_force.as_str() + ))); + } + let previous = self.runtime_time_in_force.replace(Some(*time_in_force)); + let result = self.process_order_intent( + date, + portfolio, + data, + wrapped, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ); + self.runtime_time_in_force.set(previous); + return result; + } match intent { + OrderIntent::WithTimeInForce { .. } => unreachable!("wrapper handled above"), OrderIntent::Shares { symbol, quantity, @@ -1883,6 +1959,47 @@ where .retain(|existing| existing.order_id != order_id); } + fn emit_fill_or_kill_canceled( + report: &mut BrokerExecutionReport, + date: NaiveDate, + order_id: u64, + symbol: &str, + side: OrderSide, + requested_quantity: u32, + possible_quantity: u32, + reason: &str, + ) { + let detail = format!( + "{reason}: FOK not fully fillable requested={requested_quantity} possible={possible_quantity}" + ); + report.order_events.push(OrderEvent { + date, + decision_date: None, + order_created_date: None, + execution_date: None, + order_id: Some(order_id), + symbol: symbol.to_string(), + side, + requested_quantity, + filled_quantity: 0, + status: OrderStatus::Canceled, + reason: detail.clone(), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + side, + format!("status=Canceled reason={detail}"), + ); + report.diagnostics.push(format!( + "fok_order_canceled symbol={symbol} side={} requested={requested_quantity} possible={possible_quantity}", + side.as_str() + )); + } + fn mark_same_day_sold(&self, date: NaiveDate, symbol: &str) { self.same_day_sold_symbols .borrow_mut() @@ -1954,12 +2071,29 @@ where for order in pending_orders { let order_event_start = report.order_events.len(); let fill_event_start = report.fill_events.len(); + if let Some(commission_remaining) = order.commission_remaining { + commission_state.insert(order.order_id, commission_remaining); + } + if let Some(cursor) = order.execution_cursor { + execution_cursors + .entry(order.symbol.clone()) + .and_modify(|existing| *existing = (*existing).max(cursor)) + .or_insert(cursor); + if self.uses_serial_execution_cursor(&order.reason) + && global_execution_cursor.is_none_or(|existing| cursor > existing) + { + *global_execution_cursor = Some(cursor); + } + } let signed_quantity = if order.side == OrderSide::Buy { order.remaining_quantity as i32 } else { -(order.remaining_quantity as i32) }; - self.process_limit_shares_internal( + let previous_time_in_force = self + .runtime_time_in_force + .replace(Some(order.time_in_force)); + let execution_result = self.process_limit_shares_internal( date, portfolio, data, @@ -1974,7 +2108,80 @@ where global_execution_cursor, commission_state, report, - )?; + ); + self.runtime_time_in_force.set(previous_time_in_force); + execution_result?; + let attempt_filled = report.fill_events[fill_event_start..] + .iter() + .filter(|fill| fill.order_id == Some(order.order_id)) + .map(|fill| fill.quantity) + .sum::(); + let cumulative_filled = order.filled_quantity.saturating_add(attempt_filled); + let remaining_quantity = order.requested_quantity.saturating_sub(cumulative_filled); + let mut remains_open = false; + { + let mut open_orders = self.open_orders.borrow_mut(); + if let Some(reopened) = open_orders + .iter_mut() + .find(|reopened| reopened.order_id == order.order_id) + { + remains_open = remaining_quantity > 0; + reopened.decision_date = order.decision_date; + reopened.order_created_date = order.order_created_date; + reopened.requested_quantity = order.requested_quantity; + reopened.filled_quantity = cumulative_filled; + reopened.remaining_quantity = remaining_quantity; + reopened.time_in_force = order.time_in_force; + reopened.commission_remaining = commission_state.get(&order.order_id).copied(); + reopened.execution_cursor = execution_cursors.get(&order.symbol).copied(); + } + if !remains_open { + open_orders.retain(|open| open.order_id != order.order_id); + } + } + if report.order_events.len() == order_event_start && !remains_open { + report.order_events.push(OrderEvent { + date, + decision_date: order.decision_date, + order_created_date: order.order_created_date, + execution_date: Some(date), + order_id: Some(order.order_id), + symbol: order.symbol.clone(), + side: order.side, + requested_quantity: order.requested_quantity, + filled_quantity: cumulative_filled, + status: OrderStatus::Canceled, + reason: format!( + "{}: open order remainder canceled because no executable position remained", + order.reason + ), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order.order_id, + &order.symbol, + order.side, + "status=Canceled reason=no executable position remained", + ); + } + for event in &mut report.order_events[order_event_start..] { + if event.order_id != Some(order.order_id) { + continue; + } + event.requested_quantity = order.requested_quantity; + event.filled_quantity = cumulative_filled; + if remains_open { + event.status = if cumulative_filled == 0 { + OrderStatus::Pending + } else { + OrderStatus::PartiallyFilled + }; + } else if cumulative_filled > 0 && event.status == OrderStatus::Rejected { + event.status = OrderStatus::Canceled; + } + } Self::annotate_report_range( report, order_event_start, @@ -2130,9 +2337,13 @@ where std::mem::take(&mut *open_orders) }; for order in pending { + if order.time_in_force == OrderTimeInForce::Gtc { + self.upsert_open_order(order); + continue; + } let market_close_reason = format!( - "Order Rejected: {} can not match. Market close.", - order.symbol + "DAY order expired at market close: {} remaining_quantity={}", + order.symbol, order.remaining_quantity ); report.order_events.push(OrderEvent { date, @@ -2144,7 +2355,7 @@ where side: order.side, requested_quantity: order.requested_quantity, filled_quantity: order.filled_quantity, - status: OrderStatus::Rejected, + status: OrderStatus::Expired, reason: market_close_reason.clone(), }); Self::emit_order_process_event( @@ -2155,7 +2366,7 @@ where &order.symbol, order.side, format!( - "status=Rejected requested_quantity={} filled_quantity={} reason={market_close_reason}", + "status=Expired requested_quantity={} filled_quantity={} reason={market_close_reason}", order.requested_quantity, order.filled_quantity ), ); @@ -3339,6 +3550,7 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { + let remainder_policy = self.effective_remainder_policy(allow_pending_limit); let Some(position) = portfolio.position(symbol) else { return Ok(()); }; @@ -3479,7 +3691,20 @@ where quantity } Err(limit_reason) => { - if allow_pending_limit { + if remainder_policy == RemainderPolicy::FillOrKill { + Self::emit_fill_or_kill_canceled( + report, + date, + order_id, + symbol, + OrderSide::Sell, + requested_qty, + 0, + reason, + ); + return Ok(()); + } + if Self::keeps_remainder_open(remainder_policy) { self.upsert_open_order(OpenOrder { order_id, decision_date: Some(self.current_decision_date(date)), @@ -3490,6 +3715,9 @@ where filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit sell"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); report.order_events.push(OrderEvent { @@ -3545,7 +3773,20 @@ where } }; if fillable_qty == 0 { - if allow_pending_limit { + if remainder_policy == RemainderPolicy::FillOrKill { + Self::emit_fill_or_kill_canceled( + report, + date, + order_id, + symbol, + OrderSide::Sell, + requested_qty, + 0, + reason, + ); + return Ok(()); + } + if Self::keeps_remainder_open(remainder_policy) { let detail = partial_fill_reason .as_deref() .unwrap_or("no sellable quantity"); @@ -3559,6 +3800,9 @@ where filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit sell"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); report.order_events.push(OrderEvent { @@ -3628,14 +3872,10 @@ where algo_request, limit_price, ); - let (filled_qty, execution_legs) = if let Some(fill) = fill { - execution_cursors.insert(symbol.to_string(), fill.next_cursor); - if self.uses_serial_execution_cursor(reason) { - *global_execution_cursor = Some(fill.next_cursor); - } + let (filled_qty, execution_legs, next_cursor) = if let Some(fill) = fill { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); - (fill.quantity, fill.legs) + (fill.quantity, fill.legs, Some(fill.next_cursor)) } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(fillable_qty)); @@ -3643,7 +3883,7 @@ where self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price) { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new()) + (0, Vec::new(), None) } else if !self.price_satisfies_limit( OrderSide::Sell, execution_price, @@ -3654,7 +3894,7 @@ where partial_fill_reason, Some("limit price not marketable yet"), ); - (0, Vec::new()) + (0, Vec::new(), None) } else { match self.execution_price_with_limit_slippage_or_rejection( snapshot, @@ -3669,20 +3909,43 @@ where mark_price: self.snapshot_mark_price(snapshot, OrderSide::Sell), quantity: fillable_qty, }], + None, ), Err(reason) => { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new()) + (0, Vec::new(), None) } } } }; + if remainder_policy == RemainderPolicy::FillOrKill && filled_qty < requested_qty { + self.clear_open_order(order_id); + Self::emit_fill_or_kill_canceled( + report, + date, + order_id, + symbol, + OrderSide::Sell, + requested_qty, + filled_qty, + reason, + ); + return Ok(()); + } + if let Some(next_cursor) = next_cursor { + execution_cursors.insert(symbol.to_string(), next_cursor); + if self.uses_serial_execution_cursor(reason) { + *global_execution_cursor = Some(next_cursor); + } + } if filled_qty == 0 { let detail = partial_fill_reason .as_deref() .unwrap_or("limit price not marketable yet"); - if allow_pending_limit && Self::limit_order_can_remain_open(Some(detail)) { + if Self::keeps_remainder_open(remainder_policy) + && Self::limit_order_can_remain_open(Some(detail)) + { self.upsert_open_order(OpenOrder { order_id, decision_date: Some(self.current_decision_date(date)), @@ -3693,6 +3956,9 @@ where filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit sell"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); report.order_events.push(OrderEvent { @@ -3838,7 +4104,7 @@ where *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let remaining_qty = requested_qty.saturating_sub(filled_qty); - let keep_open = allow_pending_limit + let keep_open = Self::keeps_remainder_open(remainder_policy) && remaining_qty > 0 && Self::limit_order_can_remain_open(partial_fill_reason.as_deref()); if keep_open { @@ -3852,6 +4118,9 @@ where filled_quantity: filled_qty, remaining_quantity: remaining_qty, limit_price: limit_price.expect("limit price for pending limit sell"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); } else { @@ -4979,6 +5248,7 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { + let remainder_policy = self.effective_remainder_policy(allow_pending_limit); if portfolio .position(symbol) .is_none_or(|position| position.quantity == 0) @@ -5130,7 +5400,20 @@ where quantity } Err(limit_reason) => { - if allow_pending_limit { + if remainder_policy == RemainderPolicy::FillOrKill { + Self::emit_fill_or_kill_canceled( + report, + date, + order_id, + symbol, + OrderSide::Buy, + requested_qty, + 0, + reason, + ); + return Ok(()); + } + if Self::keeps_remainder_open(remainder_policy) { self.upsert_open_order(OpenOrder { order_id, decision_date: Some(self.current_decision_date(date)), @@ -5141,6 +5424,9 @@ where filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit buy"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); report.order_events.push(OrderEvent { @@ -5223,14 +5509,10 @@ where algo_request, limit_price, ); - let (filled_qty, execution_legs) = if let Some(fill) = fill { - execution_cursors.insert(symbol.to_string(), fill.next_cursor); - if self.uses_serial_execution_cursor(reason) { - *global_execution_cursor = Some(fill.next_cursor); - } + let (filled_qty, execution_legs, next_cursor) = if let Some(fill) = fill { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); - (fill.quantity, fill.legs) + (fill.quantity, fill.legs, Some(fill.next_cursor)) } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(constrained_qty)); @@ -5238,7 +5520,7 @@ where self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price) { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new()) + (0, Vec::new(), None) } else if !self.price_satisfies_limit( OrderSide::Buy, execution_price, @@ -5249,7 +5531,7 @@ where partial_fill_reason, Some("limit price not marketable yet"), ); - (0, Vec::new()) + (0, Vec::new(), None) } else { match self.execution_price_with_limit_slippage_or_rejection( snapshot, @@ -5260,7 +5542,7 @@ where Err(reason) => { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new()) + (0, Vec::new(), None) } Ok(mut execution_price) => { let mut filled_qty = self.affordable_buy_quantity( @@ -5297,7 +5579,7 @@ where } } if blocked_by_final_price { - (0, Vec::new()) + (0, Vec::new(), None) } else { if filled_qty < constrained_qty { partial_fill_reason = merge_partial_fill_reason( @@ -5318,17 +5600,40 @@ where mark_price: self.snapshot_mark_price(snapshot, OrderSide::Buy), quantity: filled_qty, }], + None, ) } } } } }; + if remainder_policy == RemainderPolicy::FillOrKill && filled_qty < requested_qty { + self.clear_open_order(order_id); + Self::emit_fill_or_kill_canceled( + report, + date, + order_id, + symbol, + OrderSide::Buy, + requested_qty, + filled_qty, + reason, + ); + return Ok(()); + } + if let Some(next_cursor) = next_cursor { + execution_cursors.insert(symbol.to_string(), next_cursor); + if self.uses_serial_execution_cursor(reason) { + *global_execution_cursor = Some(next_cursor); + } + } if filled_qty == 0 { let detail = partial_fill_reason .as_deref() .unwrap_or("insufficient cash after fees"); - if allow_pending_limit && Self::limit_order_can_remain_open(Some(detail)) { + if Self::keeps_remainder_open(remainder_policy) + && Self::limit_order_can_remain_open(Some(detail)) + { self.upsert_open_order(OpenOrder { order_id, decision_date: Some(self.current_decision_date(date)), @@ -5339,6 +5644,9 @@ where filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit buy"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); report.order_events.push(OrderEvent { @@ -5486,7 +5794,7 @@ where *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let remaining_qty = requested_qty.saturating_sub(filled_qty); - let keep_open = allow_pending_limit + let keep_open = Self::keeps_remainder_open(remainder_policy) && remaining_qty > 0 && Self::limit_order_can_remain_open(partial_fill_reason.as_deref()); if keep_open { @@ -5500,6 +5808,9 @@ where filled_quantity: filled_qty, remaining_quantity: remaining_qty, limit_price: limit_price.expect("limit price for pending limit buy"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); } else { diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 6b99072..5f57246 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -3824,7 +3824,7 @@ fn has_execution_quote_near_start_time( fn decision_has_algo_execution(decision: &StrategyDecision) -> bool { decision.order_intents.iter().any(|intent| { matches!( - intent, + intent.unwrapped(), OrderIntent::AlgoValue { .. } | OrderIntent::AlgoPercent { .. } | OrderIntent::TimedTargetValue { .. } @@ -3852,7 +3852,7 @@ fn execution_quote_symbols_for_decision( } for intent in &decision.order_intents { - match intent { + match intent.unwrapped() { OrderIntent::Shares { symbol, .. } | OrderIntent::LimitShares { symbol, .. } | OrderIntent::Lots { symbol, .. } @@ -3880,6 +3880,7 @@ fn execution_quote_symbols_for_decision( OrderIntent::CancelAll { .. } => { symbols.extend(open_orders.iter().map(|order| order.symbol.clone())); } + OrderIntent::WithTimeInForce { .. } => unreachable!("intent is unwrapped"), OrderIntent::UpdateUniverse { .. } | OrderIntent::Subscribe { .. } | OrderIntent::Unsubscribe { .. } @@ -3901,7 +3902,7 @@ fn algo_execution_quote_windows_for_decision( ) -> BTreeMap<(Option, Option), BTreeSet> { let mut groups = BTreeMap::<(Option, Option), BTreeSet>::new(); for intent in &decision.order_intents { - match intent { + match intent.unwrapped() { OrderIntent::AlgoValue { symbol, start_time, diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 0db981e..6eddd15 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -72,6 +72,7 @@ pub enum OrderStatus { PartiallyFilled, Canceled, Rejected, + Expired, } impl OrderStatus { @@ -82,6 +83,7 @@ impl OrderStatus { Self::PartiallyFilled => "partially_filled", Self::Canceled => "canceled", Self::Rejected => "rejected", + Self::Expired => "expired", } } } @@ -128,6 +130,7 @@ impl OrderEvent { } OrderStatus::Canceled => self.filled_quantity < self.requested_quantity, OrderStatus::Rejected => self.filled_quantity == 0, + OrderStatus::Expired => self.filled_quantity < self.requested_quantity, }; if !quantity_valid { return Err(format!( @@ -327,10 +330,16 @@ mod tests { assert!(order_event(OrderStatus::Filled, 100).validate().is_ok()); assert!(order_event(OrderStatus::Canceled, 40).validate().is_ok()); assert!(order_event(OrderStatus::Rejected, 0).validate().is_ok()); + assert!(order_event(OrderStatus::Expired, 40).validate().is_ok()); - assert!(order_event(OrderStatus::PartiallyFilled, 0).validate().is_err()); + assert!( + order_event(OrderStatus::PartiallyFilled, 0) + .validate() + .is_err() + ); assert!(order_event(OrderStatus::Filled, 99).validate().is_err()); assert!(order_event(OrderStatus::Canceled, 100).validate().is_err()); assert!(order_event(OrderStatus::Rejected, 1).validate().is_err()); + assert!(order_event(OrderStatus::Expired, 100).validate().is_err()); } } diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 723f687..c9a73a8 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -89,8 +89,8 @@ pub use scheduler::{ }; pub use strategy::{ AlgoOrderStyle, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, OmniMicroCapConfig, - OmniMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, PortfolioRuntimeView, - Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing, + OmniMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, OrderTimeInForce, + PortfolioRuntimeView, Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing, }; pub use strategy_ai::{ ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index bbf87bc..2e27992 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -28,7 +28,7 @@ use crate::scheduler::{ ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time, }; use crate::strategy::{ - AlgoOrderStyle, OrderIntent, Strategy, StrategyContext, StrategyDecision, + AlgoOrderStyle, OrderIntent, OrderTimeInForce, Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing, }; @@ -298,6 +298,7 @@ pub enum PlatformTradeAction { symbol: String, amount_expr: String, limit_price_expr: Option, + time_in_force: Option, start_time_expr: Option, end_time_expr: Option, when_expr: Option, @@ -307,6 +308,7 @@ pub enum PlatformTradeAction { target_weights_expr: String, order_prices_expr: Option, valuation_prices_expr: Option, + time_in_force: Option, when_expr: Option, reason: String, }, @@ -7851,6 +7853,7 @@ impl PlatformExprStrategy { symbol, amount_expr, limit_price_expr, + time_in_force, start_time_expr, end_time_expr, when_expr, @@ -7872,6 +7875,7 @@ impl PlatformExprStrategy { )); continue; } + let intent_start = intents.len(); match kind { PlatformExplicitOrderKind::Shares => { let quantity = @@ -8218,6 +8222,18 @@ impl PlatformExprStrategy { }); } } + if let Some(time_in_force) = time_in_force + && intents.len() > intent_start + { + let intent = intents.pop().expect("explicit order intent was appended"); + if !intent.supports_time_in_force(*time_in_force) { + return Err(BacktestError::Execution(format!( + "time_in_force={} is unsupported for action={kind:?}", + time_in_force.as_str() + ))); + } + intents.push(intent.with_time_in_force(*time_in_force)); + } } PlatformTradeAction::Futures { symbol, @@ -8423,6 +8439,7 @@ impl PlatformExprStrategy { target_weights_expr, order_prices_expr, valuation_prices_expr, + time_in_force, when_expr, reason, } => { @@ -8461,12 +8478,23 @@ impl PlatformExprStrategy { .as_deref() .map(|expr| self.eval_float_map_expr(ctx, expr, day, None, None)) .transpose()?; - intents.push(OrderIntent::TargetPortfolioSmart { + let intent = OrderIntent::TargetPortfolioSmart { target_weights, order_prices, valuation_prices, reason: reason.clone(), - }); + }; + if let Some(time_in_force) = time_in_force { + if !intent.supports_time_in_force(*time_in_force) { + return Err(BacktestError::Execution(format!( + "time_in_force={} is unsupported for target_portfolio_smart", + time_in_force.as_str() + ))); + } + intents.push(intent.with_time_in_force(*time_in_force)); + } else { + intents.push(intent); + } } } } @@ -8484,6 +8512,7 @@ impl PlatformExprStrategy { let mut filtered = Vec::with_capacity(intents.len()); let mut diagnostics = Vec::new(); for intent in intents { + let (intent, time_in_force) = intent.into_time_in_force_parts(); if let OrderIntent::TargetPortfolioSmart { mut target_weights, order_prices, @@ -8503,12 +8532,15 @@ impl PlatformExprStrategy { symbol, reason )); } - filtered.push(OrderIntent::TargetPortfolioSmart { - target_weights, - order_prices, - valuation_prices, - reason, - }); + filtered.push( + OrderIntent::TargetPortfolioSmart { + target_weights, + order_prices, + valuation_prices, + reason, + } + .apply_time_in_force(time_in_force), + ); continue; } @@ -8577,7 +8609,7 @@ impl PlatformExprStrategy { )); continue; } - filtered.push(intent); + filtered.push(intent.apply_time_in_force(time_in_force)); } (filtered, diagnostics) } @@ -11974,8 +12006,8 @@ mod tests { DailyFactorSnapshot, DailyMarketSnapshot, DataSet, EligibleUniverseSnapshot, FactorTextValue, FuturesCommissionType, FuturesTradingParameter, Instrument, IntradayExecutionQuote, MatchingType, OpenOrderView, OrderIntent, OrderSide, - PortfolioState, ProcessEvent, ProcessEventKind, RebalanceCashMode, ScheduleStage, - ScheduleTimeRule, Scheduler, SlippageModel, Strategy, StrategyContext, + OrderTimeInForce, PortfolioState, ProcessEvent, ProcessEventKind, RebalanceCashMode, + ScheduleStage, ScheduleTimeRule, Scheduler, SlippageModel, Strategy, StrategyContext, TargetPortfolioOrderPricing, TradingCalendar, default_stage_time, }; @@ -12323,6 +12355,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: Some("close * 1.01".to_string()), + time_in_force: None, start_time_expr: Some("\"10:00\"".to_string()), end_time_expr: Some("\"10:30\"".to_string()), when_expr: Some("close > 0.0".to_string()), @@ -21904,6 +21937,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some("allow_buy && !touched_upper_limit".to_string()), @@ -22141,6 +22175,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "1000".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some(concat!( @@ -22303,6 +22338,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "1000".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some( @@ -22911,6 +22947,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "1000".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some( @@ -30402,6 +30439,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "2000".to_string(), limit_price_expr: None, + time_in_force: Some(OrderTimeInForce::Fok), start_time_expr: None, end_time_expr: None, when_expr: Some("allow_buy".to_string()), @@ -30413,15 +30451,21 @@ mod tests { assert_eq!(decision.order_intents.len(), 1); match &decision.order_intents[0] { - crate::strategy::OrderIntent::TargetShares { - symbol, - target_quantity, - reason, - } => { - assert_eq!(symbol, "000001.SZ"); - assert_eq!(*target_quantity, 2000); - assert_eq!(reason, "platform_target_shares"); - } + crate::strategy::OrderIntent::WithTimeInForce { + intent, + time_in_force: OrderTimeInForce::Fok, + } => match intent.as_ref() { + crate::strategy::OrderIntent::TargetShares { + symbol, + target_quantity, + reason, + } => { + assert_eq!(symbol, "000001.SZ"); + assert_eq!(*target_quantity, 2000); + assert_eq!(reason, "platform_target_shares"); + } + other => panic!("unexpected wrapped target shares intent: {other:?}"), + }, other => panic!("unexpected explicit target shares intent: {other:?}"), } } @@ -30524,6 +30568,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: Some("\"09:31\"".to_string()), end_time_expr: Some("\"09:40\"".to_string()), when_expr: Some("allow_buy".to_string()), @@ -30534,6 +30579,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "0.05".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: Some("\"10:00\"".to_string()), end_time_expr: Some("\"10:30\"".to_string()), when_expr: Some("allow_buy".to_string()), @@ -30651,6 +30697,7 @@ mod tests { valuation_prices_expr: Some( "{\"000001.SZ\": signal_close, \"000002.SZ\": benchmark_close / 100.0}".to_string(), ), + time_in_force: None, when_expr: Some("benchmark_close > 0".to_string()), reason: "platform_target_portfolio_smart".to_string(), }]; @@ -30801,6 +30848,7 @@ mod tests { target_weights_expr: "{\"000001.SZ\": 0.50, \"000002.SZ\": 0.50}".to_string(), order_prices_expr: None, valuation_prices_expr: None, + time_in_force: None, when_expr: None, reason: "fixed_signal_target".to_string(), }]; @@ -30906,6 +30954,7 @@ mod tests { .to_string(), order_prices_expr: None, valuation_prices_expr: None, + time_in_force: None, when_expr: Some( "current_date >= \"2023-01-03\" && current_date <= \"2023-03-02\"".to_string(), ), @@ -31089,6 +31138,7 @@ mod tests { .to_string(), order_prices_expr: Some("execution_day_open".to_string()), valuation_prices_expr: Some("execution_day_open".to_string()), + time_in_force: None, when_expr: Some( "decision_date == \"2023-01-03\" && execution_date == \"2023-01-04\"" .to_string(), @@ -31194,6 +31244,7 @@ mod tests { target_weights_expr: "{\"000001.SZ\": 0.30}".to_string(), order_prices_expr: Some("VWAPOrder(930, 940)".to_string()), valuation_prices_expr: Some("{\"000001.SZ\": signal_close}".to_string()), + time_in_force: None, when_expr: None, reason: "platform_target_portfolio_smart_algo".to_string(), }]; @@ -31299,6 +31350,7 @@ mod tests { valuation_prices_expr: Some( "{\"000001.SZ\": signal_close, \"000002.SZ\": signal_close}".to_string(), ), + time_in_force: None, when_expr: Some("subscription_guard_required".to_string()), reason: "guarded_target_portfolio_smart".to_string(), }]; @@ -31427,6 +31479,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "0.25".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some("allow_buy".to_string()), @@ -31570,6 +31623,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "0.25".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some("allow_buy".to_string()), @@ -31709,6 +31763,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some( @@ -32222,6 +32277,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some( @@ -32332,6 +32388,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some("allow_buy".to_string()), @@ -32578,6 +32635,7 @@ let target_exposure = csi_ready ? dynamic_exposure : 0.0; symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some( diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 4c9bbbf..33b3799 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -10,7 +10,7 @@ use crate::{ PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel, futures::FuturesDirection, - futures::FuturesPositionEffect, + futures::FuturesPositionEffect, strategy::OrderTimeInForce, }; #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -793,6 +793,8 @@ pub struct StrategyExpressionActionConfig { pub end_time_expr: Option, #[serde(default)] pub limit_price_expr: Option, + #[serde(default, alias = "time_in_force")] + pub time_in_force: Option, #[serde(default)] pub target_weights_expr: Option, #[serde(default)] @@ -2178,6 +2180,15 @@ fn parse_platform_trade_action( .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string); + let time_in_force = match action + .time_in_force + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(value) => Some(OrderTimeInForce::parse(value)?), + None => None, + }; match kind.as_str() { "target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart { target_weights_expr: action @@ -2198,6 +2209,7 @@ fn parse_platform_trade_action( .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string), + time_in_force, when_expr, reason, }), @@ -2327,6 +2339,7 @@ fn parse_platform_trade_action( .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string), + time_in_force, start_time_expr: action .start_time_expr .as_deref() @@ -2648,6 +2661,63 @@ mod tests { assert_eq!(cfg.explicit_actions.len(), 1); } + #[test] + fn parses_typed_time_in_force_for_explicit_orders() { + let spec = serde_json::json!({ + "runtimeExpressions": { + "trading": { + "rotationEnabled": false, + "actions": [{ + "kind": "limit_shares", + "symbol": "000001.SZ", + "amountExpr": "200", + "limitPriceExpr": "10.25", + "timeInForce": "FOK", + "reason": "fok_entry" + }] + } + } + }); + + let cfg = platform_expr_config_from_value("tif", "000300.SH", &spec) + .expect("time-in-force config"); + + assert!(matches!( + cfg.explicit_actions.as_slice(), + [PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::LimitShares, + time_in_force: Some(OrderTimeInForce::Fok), + .. + }] + )); + } + + #[test] + fn rejects_unknown_time_in_force_in_runtime_contract() { + let spec = serde_json::json!({ + "runtimeExpressions": { + "trading": { + "rotationEnabled": false, + "actions": [{ + "kind": "shares", + "symbol": "000001.SZ", + "amountExpr": "200", + "timeInForce": "until_lucky", + "reason": "invalid_tif" + }] + } + } + }); + + let error = platform_expr_config_from_value("tif", "000300.SH", &spec) + .expect_err("unknown time-in-force must be rejected"); + assert!( + error + .to_string() + .contains("runtimeExpressions.trading.actions[0]") + ); + } + #[test] fn parses_delayed_deposit_receiving_days_expression() { let spec = serde_json::json!({ diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 3470740..538a814 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -1014,6 +1014,35 @@ pub enum AlgoOrderStyle { Twap, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OrderTimeInForce { + Day, + Ioc, + Fok, + Gtc, +} + +impl OrderTimeInForce { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "day" => Some(Self::Day), + "ioc" | "immediate_or_cancel" | "immediate-or-cancel" => Some(Self::Ioc), + "fok" | "fill_or_kill" | "fill-or-kill" => Some(Self::Fok), + "gtc" | "good_til_canceled" | "good-til-canceled" => Some(Self::Gtc), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Day => "day", + Self::Ioc => "ioc", + Self::Fok => "fok", + Self::Gtc => "gtc", + } + } +} + #[derive(Debug, Clone)] pub enum TargetPortfolioOrderPricing { LimitPrices(BTreeMap), @@ -1026,6 +1055,10 @@ pub enum TargetPortfolioOrderPricing { #[derive(Debug, Clone)] pub enum OrderIntent { + WithTimeInForce { + intent: Box, + time_in_force: OrderTimeInForce, + }, Shares { symbol: String, quantity: i32, @@ -1174,6 +1207,100 @@ pub enum OrderIntent { }, } +impl OrderIntent { + pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self { + match self { + Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce { + intent, + time_in_force, + }, + intent => Self::WithTimeInForce { + intent: Box::new(intent), + time_in_force, + }, + } + } + + pub fn time_in_force(&self) -> Option { + match self { + Self::WithTimeInForce { time_in_force, .. } => Some(*time_in_force), + _ => None, + } + } + + pub fn into_time_in_force_parts(self) -> (Self, Option) { + match self { + Self::WithTimeInForce { + intent, + time_in_force, + } => (*intent, Some(time_in_force)), + intent => (intent, None), + } + } + + pub fn apply_time_in_force(self, time_in_force: Option) -> Self { + match time_in_force { + Some(time_in_force) => self.with_time_in_force(time_in_force), + None => self, + } + } + + pub fn unwrapped(&self) -> &Self { + match self { + Self::WithTimeInForce { intent, .. } => intent.unwrapped(), + _ => self, + } + } + + pub fn supports_time_in_force(&self, time_in_force: OrderTimeInForce) -> bool { + let intent = self.unwrapped(); + if matches!( + intent, + Self::CancelOrder { .. } + | Self::CancelSymbol { .. } + | Self::CancelAll { .. } + | Self::UpdateUniverse { .. } + | Self::Subscribe { .. } + | Self::Unsubscribe { .. } + | Self::DepositWithdraw { .. } + | Self::FinanceRepay { .. } + | Self::SetManagementFeeRate { .. } + | Self::Futures { .. } + ) { + return false; + } + let is_algo = matches!( + intent, + Self::AlgoValue { .. } | Self::AlgoPercent { .. } | Self::TimedTargetValue { .. } + ) || matches!( + intent, + Self::TargetPortfolioSmart { + order_prices: Some(TargetPortfolioOrderPricing::AlgoOrder { .. }), + .. + } + ); + let is_limit = matches!( + intent, + Self::LimitShares { .. } + | Self::LimitLots { .. } + | Self::LimitTargetShares { .. } + | Self::LimitTargetValue { .. } + | Self::LimitValue { .. } + | Self::LimitPercent { .. } + | Self::LimitTargetPercent { .. } + | Self::TargetPortfolioSmart { + order_prices: Some(TargetPortfolioOrderPricing::LimitPrices(_)), + .. + } + ); + match time_in_force { + OrderTimeInForce::Day | OrderTimeInForce::Ioc => true, + OrderTimeInForce::Fok => !is_algo, + OrderTimeInForce::Gtc => is_limit, + } + } +} + #[derive(Debug, Clone)] pub struct CnSmallCapRotationConfig { pub strategy_name: String, @@ -2909,6 +3036,53 @@ mod tests { use super::*; use crate::{BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot}; + #[test] + fn order_time_in_force_parsing_and_order_type_contract_are_explicit() { + assert_eq!(OrderTimeInForce::parse("DAY"), Some(OrderTimeInForce::Day)); + assert_eq!( + OrderTimeInForce::parse("immediate_or_cancel"), + Some(OrderTimeInForce::Ioc) + ); + assert_eq!( + OrderTimeInForce::parse("fill-or-kill"), + Some(OrderTimeInForce::Fok) + ); + assert_eq!( + OrderTimeInForce::parse("good_til_canceled"), + Some(OrderTimeInForce::Gtc) + ); + assert_eq!(OrderTimeInForce::parse("unknown"), None); + + let market = OrderIntent::Shares { + symbol: "000001.SZ".to_string(), + quantity: 100, + reason: "market".to_string(), + }; + assert!(market.supports_time_in_force(OrderTimeInForce::Day)); + assert!(market.supports_time_in_force(OrderTimeInForce::Ioc)); + assert!(market.supports_time_in_force(OrderTimeInForce::Fok)); + assert!(!market.supports_time_in_force(OrderTimeInForce::Gtc)); + + let limit = OrderIntent::LimitShares { + symbol: "000001.SZ".to_string(), + quantity: 100, + limit_price: 10.0, + reason: "limit".to_string(), + }; + assert!(limit.supports_time_in_force(OrderTimeInForce::Gtc)); + + let algo = OrderIntent::AlgoValue { + symbol: "000001.SZ".to_string(), + value: 10_000.0, + style: AlgoOrderStyle::Vwap, + start_time: None, + end_time: None, + reason: "algo".to_string(), + }; + assert!(!algo.supports_time_in_force(OrderTimeInForce::Fok)); + assert!(!algo.supports_time_in_force(OrderTimeInForce::Gtc)); + } + #[test] fn omni_microcap_projection_uses_configured_trading_cost() { let mut cfg = OmniMicroCapConfig::omni_microcap(); diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 8d58a0d..b37c173 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -266,7 +266,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualSection { title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(), - detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), + detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForce:DAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), }, ManualSection { title: "when / unless / else".to_string(), @@ -672,6 +672,9 @@ mod tests { assert!(markdown.contains("源策略明确写出的业务选股排除属于策略本身")); assert!(markdown.contains("不能反向修改冻结的 reject_*_selection 开关")); assert!(markdown.contains("冻结的 `reject_*_selection` 值不得改变")); + assert!(markdown.contains("time_in_force=\"day|ioc|fok|gtc\"")); + assert!(markdown.contains("FOK 必须全量可成交否则零成交")); + assert!(markdown.contains("GTC 仅支持限价单并跨交易日保留")); } #[test] diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 3a1162e..5e1d104 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -2956,7 +2956,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() { } #[test] -fn engine_rejects_pending_limit_orders_at_market_close() { +fn engine_expires_pending_day_limit_orders_at_market_close() { let date1 = d(2025, 1, 2); let date2 = d(2025, 1, 3); let data = DataSet::from_components( @@ -3118,8 +3118,8 @@ fn engine_rejects_pending_limit_orders_at_market_close() { ); assert!(result.order_events.iter().any(|event| { event.date == date1 - && event.status == fidc_core::OrderStatus::Rejected - && event.reason.contains("Market close") + && event.status == fidc_core::OrderStatus::Expired + && event.reason.contains("DAY order expired at market close") })); assert!(result.process_events.iter().any(|event| { event.date == date1 && event.kind == ProcessEventKind::OrderUnsolicitedUpdate diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index e84fef4..3796cb3 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -3,8 +3,8 @@ use fidc_core::{ AlgoOrderStyle, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, DynamicSlippageConfig, FidcRiskControlConfig, Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, - OrderStatus, PortfolioState, PriceField, ProcessEventKind, SlippageModel, StrategyDecision, - TargetPortfolioOrderPricing, + OrderStatus, OrderTimeInForce, PortfolioState, PriceField, ProcessEventKind, SlippageModel, + StrategyDecision, TargetPortfolioOrderPricing, }; use std::collections::{BTreeMap, BTreeSet}; @@ -4715,7 +4715,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet { } #[test] -fn broker_rejects_open_limit_buy_at_market_close() { +fn broker_expires_day_limit_buy_at_market_close() { let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); let data = two_day_limit_order_data(10.0, 9.7); @@ -4756,11 +4756,11 @@ fn broker_rejects_open_limit_buy_at_market_close() { assert!(close_report.fill_events.is_empty()); assert_eq!(close_report.order_events.len(), 1); assert_eq!(close_report.order_events[0].order_id, Some(order_id)); - assert_eq!(close_report.order_events[0].status, OrderStatus::Rejected); + assert_eq!(close_report.order_events[0].status, OrderStatus::Expired); assert!( close_report.order_events[0] .reason - .contains("Order Rejected: 000002.SZ can not match. Market close.") + .contains("DAY order expired at market close") ); assert!(close_report.process_events.iter().any(|event| { event.kind == ProcessEventKind::OrderUnsolicitedUpdate && event.order_id == Some(order_id) @@ -4787,6 +4787,328 @@ fn broker_rejects_open_limit_buy_at_market_close() { assert!(portfolio.position("000002.SZ").is_none()); } +#[test] +fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = two_day_limit_order_data(10.0, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_volume_limit(true) + .with_volume_percent(0.001) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000002.SZ".to_string(), + quantity: 200, + limit_price: 10.1, + reason: "ioc_limit_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Ioc), + ], + ..StrategyDecision::default() + }, + ) + .expect("IOC execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].quantity, 100); + assert_eq!(report.order_events.len(), 1); + assert_eq!(report.order_events[0].status, OrderStatus::Canceled); + assert_eq!(report.order_events[0].filled_quantity, 100); + assert!(broker.open_order_views().is_empty()); + assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100); +} + +#[test] +fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = two_day_limit_order_data(10.0, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_volume_limit(true) + .with_volume_percent(0.001) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::Shares { + symbol: "000002.SZ".to_string(), + quantity: 200, + reason: "day_market_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Day), + ], + ..StrategyDecision::default() + }, + ) + .expect("DAY market execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].quantity, 100); + assert_eq!(report.order_events[0].status, OrderStatus::Canceled); + assert_eq!(report.order_events[0].filled_quantity, 100); + assert!(broker.open_order_views().is_empty()); +} + +#[test] +fn broker_fok_order_is_atomic_when_liquidity_is_insufficient() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = two_day_limit_order_data(10.0, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_volume_limit(true) + .with_volume_percent(0.001) + .with_liquidity_limit(false); + let initial_cash = 1_000_000.0; + let mut portfolio = PortfolioState::new(initial_cash); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::Shares { + symbol: "000002.SZ".to_string(), + quantity: 200, + reason: "fok_market_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Fok), + ], + ..StrategyDecision::default() + }, + ) + .expect("FOK execution"); + + assert!(report.fill_events.is_empty()); + assert_eq!(report.order_events.len(), 1); + assert_eq!(report.order_events[0].status, OrderStatus::Canceled); + assert_eq!(report.order_events[0].filled_quantity, 0); + assert!( + report.order_events[0] + .reason + .contains("FOK not fully fillable") + ); + assert!(portfolio.position("000002.SZ").is_none()); + assert!((portfolio.cash() - initial_cash).abs() < 1e-9); + assert!(broker.open_order_views().is_empty()); +} + +#[test] +fn broker_fok_order_fills_when_full_quantity_is_available() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = two_day_limit_order_data(10.0, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_volume_limit(false) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::Shares { + symbol: "000002.SZ".to_string(), + quantity: 200, + reason: "fok_market_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Fok), + ], + ..StrategyDecision::default() + }, + ) + .expect("FOK execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].quantity, 200); + assert_eq!(report.order_events[0].status, OrderStatus::Filled); + assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 200); +} + +#[test] +fn broker_gtc_limit_order_survives_close_and_fills_next_day() { + let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); + let data = two_day_limit_order_data(10.0, 9.7); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let day1_report = broker + .execute( + day1, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000002.SZ".to_string(), + quantity: 200, + limit_price: 9.8, + reason: "gtc_limit_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Gtc), + ], + ..StrategyDecision::default() + }, + ) + .expect("GTC day one execution"); + assert_eq!(day1_report.order_events[0].status, OrderStatus::Pending); + assert_eq!(broker.open_order_views().len(), 1); + + let close_report = broker.after_trading(day1); + assert!(close_report.order_events.is_empty()); + assert_eq!(broker.open_order_views().len(), 1); + + let day2_report = broker + .execute(day2, &mut portfolio, &data, &StrategyDecision::default()) + .expect("GTC day two execution"); + assert_eq!(day2_report.fill_events.len(), 1); + assert_eq!(day2_report.fill_events[0].quantity, 200); + assert_eq!(day2_report.order_events[0].status, OrderStatus::Filled); + assert!(broker.open_order_views().is_empty()); + assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 200); +} + +#[test] +fn broker_gtc_partial_fills_preserve_cumulative_order_and_commission_state() { + let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); + let data = two_day_limit_order_data(10.0, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_volume_limit(true) + .with_volume_percent(0.001) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let day1_report = broker + .execute( + day1, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000002.SZ".to_string(), + quantity: 200, + limit_price: 10.1, + reason: "gtc_partial_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Gtc), + ], + ..StrategyDecision::default() + }, + ) + .expect("GTC first partial fill"); + assert_eq!(day1_report.fill_events[0].quantity, 100); + assert_eq!( + day1_report.order_events[0].status, + OrderStatus::PartiallyFilled + ); + let open_order = broker + .open_order_views() + .pop() + .expect("remaining GTC order"); + assert_eq!(open_order.requested_quantity, 200); + assert_eq!(open_order.filled_quantity, 100); + assert_eq!(open_order.remaining_quantity, 100); + assert!(broker.after_trading(day1).order_events.is_empty()); + + let day2_report = broker + .execute(day2, &mut portfolio, &data, &StrategyDecision::default()) + .expect("GTC final fill"); + assert_eq!(day2_report.fill_events[0].quantity, 100); + assert_eq!(day2_report.order_events[0].requested_quantity, 200); + assert_eq!(day2_report.order_events[0].filled_quantity, 200); + assert_eq!(day2_report.order_events[0].status, OrderStatus::Filled); + assert!(broker.open_order_views().is_empty()); + + let total_commission = day1_report + .fill_events + .iter() + .chain(day2_report.fill_events.iter()) + .map(|fill| fill.commission) + .sum::(); + assert!((total_commission - 5.0).abs() < 1e-9, "{total_commission}"); + assert_eq!(day2_report.fill_events[0].commission, 0.0); +} + +#[test] +fn broker_rejects_gtc_for_market_order() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = two_day_limit_order_data(10.0, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let error = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::Shares { + symbol: "000002.SZ".to_string(), + quantity: 200, + reason: "invalid_gtc_market_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Gtc), + ], + ..StrategyDecision::default() + }, + ) + .expect_err("market GTC must be rejected"); + + assert!( + error + .to_string() + .contains("time_in_force=gtc is not supported") + ); + assert!(portfolio.position("000002.SZ").is_none()); +} + #[test] fn broker_uses_limit_price_slippage_for_limit_orders() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); @@ -5152,7 +5474,11 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() { assert_eq!(report.order_events[0].status, OrderStatus::Pending); assert_eq!(report.order_events[1].status, OrderStatus::Canceled); assert_eq!(report.order_events[1].filled_quantity, 100); - assert!(report.order_events[1].reason.contains("remaining quantity canceled")); + assert!( + report.order_events[1] + .reason + .contains("remaining quantity canceled") + ); let open_orders = broker.open_order_views(); assert_eq!(open_orders.len(), 1); assert_eq!(open_orders[0].reason, "reserve_sell"); From dbaf7b45af2b46839e132551d78633559da1d3b1 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 22:11:16 +0800 Subject: [PATCH 12/26] =?UTF-8?q?=E9=99=90=E5=AE=9A=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E6=9C=89=E6=95=88=E6=9C=9F=E8=BF=90=E8=A1=8C=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/strategy_ai.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index b37c173..7ea2dad 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -268,6 +268,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(), detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForce:DAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), }, + ManualSection { + title: "order.time_in_force target runtime scope".to_string(), + detail: "回测支持 DAY/IOC/FOK/GTC;paper/live 当前只支持 DAY/IOC/FOK。GTC 需要持久化跨交易日 parent/child 重挂账本和券商适配器能力,在该合同实现前只允许回测,paper/live 必须明确拒绝并禁止降级为 DAY。生成策略前必须按目标运行模式选择能力。".to_string(), + }, ManualSection { title: "when / unless / else".to_string(), detail: "条件块支持按日期、指数、仓位等动态切换规则。".to_string(), @@ -675,6 +679,8 @@ mod tests { assert!(markdown.contains("time_in_force=\"day|ioc|fok|gtc\"")); assert!(markdown.contains("FOK 必须全量可成交否则零成交")); assert!(markdown.contains("GTC 仅支持限价单并跨交易日保留")); + assert!(markdown.contains("paper/live 当前只支持 DAY/IOC/FOK")); + assert!(markdown.contains("paper/live 必须明确拒绝并禁止降级为 DAY")); } #[test] From a72a4518d3c3872675ed70cc16e1c4b3464ec51f Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 22:54:11 +0800 Subject: [PATCH 13/26] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=A3=8E=E6=8E=A7?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E6=89=A7=E8=A1=8C=E5=88=AB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/platform_strategy_spec.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 33b3799..c928459 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -272,11 +272,21 @@ pub struct StrategyRiskPolicySpec { alias = "blacklist" )] pub blacklisted_symbols: Vec, - #[serde(default, alias = "volume_limit_enabled")] + #[serde( + default, + alias = "volume_limit_enabled", + alias = "volume_limit", + alias = "volumeLimit" + )] pub volume_limit_enabled: Option, #[serde(default, alias = "volume_percent")] pub volume_percent: Option, - #[serde(default, alias = "liquidity_limit_enabled")] + #[serde( + default, + alias = "liquidity_limit_enabled", + alias = "liquidity_limit", + alias = "liquidityLimit" + )] pub liquidity_limit_enabled: Option, #[serde(default, alias = "commission_rate")] pub commission_rate: Option, @@ -3190,6 +3200,8 @@ mod tests { "reject_st_buy": 0, "volumePercent": 0.25, "volume_percent": 25, + "volume_limit": false, + "liquidityLimit": true, "minimumCommission": 5, "minimum_commission": "5" } @@ -3200,6 +3212,8 @@ mod tests { assert!(!cfg.risk_config.static_rules.reject_st_buy); assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12); + assert!(!cfg.volume_limit); + assert!(cfg.liquidity_limit); assert_eq!(cfg.risk_config.trading_constraints.minimum_commission, 5.0); } From 861ed483b5be49592a94ecd02ff88a24e9798c49 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 22:59:05 +0800 Subject: [PATCH 14/26] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=A3=8E=E6=8E=A7?= =?UTF-8?q?=E5=88=AB=E5=90=8D=E6=B5=8B=E8=AF=95=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/platform_strategy_spec.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index c928459..b745333 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -3212,8 +3212,8 @@ mod tests { assert!(!cfg.risk_config.static_rules.reject_st_buy); assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12); - assert!(!cfg.volume_limit); - assert!(cfg.liquidity_limit); + assert!(!cfg.risk_config.trading_constraints.volume_limit_enabled); + assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled); assert_eq!(cfg.risk_config.trading_constraints.minimum_commission, 5.0); } From b05bd3fc1bbcdd8d43abe41cacbc607366d5044f Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 23:01:33 +0800 Subject: [PATCH 15/26] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=A3=8E=E6=8E=A7=E7=AD=96=E7=95=A5=E8=A7=84=E6=A0=BC=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index c9a73a8..26fdd16 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -75,7 +75,8 @@ pub use platform_strategy_spec::{ StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig, StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig, StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig, - StrategyRuntimeEnvironment, StrategyRuntimeExpressions, StrategyRuntimeSpec, + StrategyRiskPolicySpec, StrategyRuntimeEnvironment, StrategyRuntimeExpressions, + StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value, }; pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position}; From 32693dad30ce9596c268a98350a93a3784bc5d8f Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 23:06:20 +0800 Subject: [PATCH 16/26] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E8=A7=84=E6=A0=BC=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_strategy_spec.rs | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index b745333..fad5d07 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -76,9 +76,17 @@ pub struct StrategyExecutionSpec { pub slippage_model: Option, #[serde(default, alias = "slippage_value")] pub slippage_value: Option, - #[serde(default, alias = "slippage_impact_coefficient")] + #[serde( + default, + alias = "slippage_impact_coefficient", + alias = "slippageImpact" + )] pub slippage_impact_coefficient: Option, - #[serde(default, alias = "slippage_volatility_coefficient")] + #[serde( + default, + alias = "slippage_volatility_coefficient", + alias = "slippageVolatility" + )] pub slippage_volatility_coefficient: Option, #[serde(default, alias = "slippage_max_value", alias = "slippage_max_rate")] pub slippage_max_value: Option, @@ -101,12 +109,28 @@ pub struct StrategyExecutionSpec { pub stamp_tax_rate_after_change: Option, #[serde(default, alias = "stamp_tax_change_date")] pub stamp_tax_change_date: Option, - #[serde(default, alias = "volume_limit")] + #[serde( + default, + alias = "volume_limit", + alias = "volumeLimitEnabled", + alias = "volume_limit_enabled" + )] pub volume_limit: Option, - #[serde(default, alias = "liquidity_limit")] + #[serde( + default, + alias = "liquidity_limit", + alias = "liquidityLimitEnabled", + alias = "liquidity_limit_enabled" + )] pub liquidity_limit: Option, + #[serde(default, alias = "inactive_limit")] + pub inactive_limit: Option, + #[serde(default, alias = "same_day_buy_close_mark_at_fill")] + pub same_day_buy_close_mark_at_fill: Option, #[serde(default, alias = "volume_percent")] pub volume_percent: Option, + #[serde(default, alias = "execution_granularity")] + pub execution_granularity: Option, #[serde(default, alias = "risk_policy")] pub risk_policy: Option, #[serde(default, alias = "strict_value_budget")] From b92a09b5ed9948839d27616a4eb973163bf25bdc Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 23:18:43 +0800 Subject: [PATCH 17/26] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E8=A7=84=E6=A0=BC=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_strategy_spec.rs | 90 ++++++++++++------- 1 file changed, 56 insertions(+), 34 deletions(-) diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index fad5d07..35ffa41 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -146,27 +146,27 @@ pub struct StrategyExecutionSpec { pub struct StrategyEngineConfig { #[serde(default)] pub frequency: Option, - #[serde(default)] + #[serde(default, alias = "template_id")] pub template_id: Option, #[serde(default, alias = "benchmark_symbol")] pub benchmark_symbol: Option, #[serde(default, alias = "signal_symbol")] pub signal_symbol: Option, - #[serde(default)] + #[serde(default, alias = "rank_limit")] pub rank_limit: Option, - #[serde(default)] + #[serde(default, alias = "refresh_rate")] pub refresh_rate: Option, - #[serde(default)] + #[serde(default, alias = "rsi_rate")] pub rsi_rate: Option, - #[serde(default)] + #[serde(default, alias = "dynamic_range")] pub dynamic_range: Option, - #[serde(default)] + #[serde(default, alias = "stock_ma_filter")] pub stock_ma_filter: Option, - #[serde(default)] + #[serde(default, alias = "index_throttle")] pub index_throttle: Option, - #[serde(default)] + #[serde(default, alias = "stop_loss_multiplier")] pub stop_loss_multiplier: Option, - #[serde(default)] + #[serde(default, alias = "take_profit_multiplier")] pub take_profit_multiplier: Option, #[serde(default, alias = "matching_type")] pub matching_type: Option, @@ -174,9 +174,17 @@ pub struct StrategyEngineConfig { pub slippage_model: Option, #[serde(default, alias = "slippage_value")] pub slippage_value: Option, - #[serde(default, alias = "slippage_impact_coefficient")] + #[serde( + default, + alias = "slippage_impact_coefficient", + alias = "slippageImpact" + )] pub slippage_impact_coefficient: Option, - #[serde(default, alias = "slippage_volatility_coefficient")] + #[serde( + default, + alias = "slippage_volatility_coefficient", + alias = "slippageVolatility" + )] pub slippage_volatility_coefficient: Option, #[serde(default, alias = "slippage_max_value", alias = "slippage_max_rate")] pub slippage_max_value: Option, @@ -199,10 +207,24 @@ pub struct StrategyEngineConfig { pub stamp_tax_rate_after_change: Option, #[serde(default, alias = "stamp_tax_change_date")] pub stamp_tax_change_date: Option, - #[serde(default, alias = "volume_limit")] + #[serde( + default, + alias = "volume_limit", + alias = "volumeLimitEnabled", + alias = "volume_limit_enabled" + )] pub volume_limit: Option, - #[serde(default, alias = "liquidity_limit")] + #[serde( + default, + alias = "liquidity_limit", + alias = "liquidityLimitEnabled", + alias = "liquidity_limit_enabled" + )] pub liquidity_limit: Option, + #[serde(default, alias = "inactive_limit")] + pub inactive_limit: Option, + #[serde(default, alias = "same_day_buy_close_mark_at_fill")] + pub same_day_buy_close_mark_at_fill: Option, #[serde(default, alias = "volume_percent")] pub volume_percent: Option, #[serde(default, alias = "risk_policy")] @@ -213,9 +235,9 @@ pub struct StrategyEngineConfig { pub rebalance_cash_mode: Option, #[serde(default, alias = "sell_then_buy_delay_slippage_rate")] pub sell_then_buy_delay_slippage_rate: Option, - #[serde(default)] + #[serde(default, alias = "dividend_reinvestment")] pub dividend_reinvestment: Option, - #[serde(default)] + #[serde(default, alias = "weak_market_shrink_overweight_threshold")] pub weak_market_shrink_overweight_threshold: Option, #[serde( default, @@ -225,9 +247,9 @@ pub struct StrategyEngineConfig { alias = "maxHoldingDays" )] pub max_holding_days: Option, - #[serde(default)] + #[serde(default, alias = "rebalance_schedule")] pub rebalance_schedule: Option, - #[serde(default)] + #[serde(default, alias = "skip_windows")] pub skip_windows: Vec, } @@ -584,54 +606,54 @@ fn normalize_risk_policy_aliases_in_value(value: &mut Value) -> Result<(), Strin #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct DynamicRangeConfig { - #[serde(default)] + #[serde(default, alias = "base_index_level")] pub base_index_level: Option, - #[serde(default)] + #[serde(default, alias = "base_cap_floor")] pub base_cap_floor: Option, - #[serde(default)] + #[serde(default, alias = "cap_span")] pub cap_span: Option, #[serde(default)] pub xs: Option, /// Padding ratio to expand the market cap range (e.g., 0.5 means 50% of span) - #[serde(default)] + #[serde(default, alias = "padding_ratio")] pub padding_ratio: Option, /// Minimum padding in billion yuan - #[serde(default)] + #[serde(default, alias = "min_padding")] pub min_padding: Option, /// Maximum padding in billion yuan - #[serde(default)] + #[serde(default, alias = "max_padding")] pub max_padding: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct MovingAverageFilterConfig { - #[serde(default)] + #[serde(default, alias = "short_days")] pub short_days: Option, - #[serde(default)] + #[serde(default, alias = "mid_days")] pub mid_days: Option, - #[serde(default)] + #[serde(default, alias = "long_days")] pub long_days: Option, - #[serde(default)] + #[serde(default, alias = "volume_short_days", alias = "volumeShort")] pub volume_short_days: Option, - #[serde(default)] + #[serde(default, alias = "volume_long_days", alias = "volumeLong")] pub volume_long_days: Option, - #[serde(default)] + #[serde(default, alias = "rsi_rate")] pub rsi_rate: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct IndexThrottleConfig { - #[serde(default)] + #[serde(default, alias = "short_days")] pub short_days: Option, - #[serde(default)] + #[serde(default, alias = "long_days")] pub long_days: Option, - #[serde(default)] + #[serde(default, alias = "rsi_rate")] pub rsi_rate: Option, - #[serde(default)] + #[serde(default, alias = "defensive_exposure")] pub defensive_exposure: Option, - #[serde(default)] + #[serde(default, alias = "full_exposure")] pub full_exposure: Option, } From 422e5f1021643c3a5dd9ed0a3003363f14d6f6e5 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 23:24:20 +0800 Subject: [PATCH 18/26] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=9F=BA=E5=87=86?= =?UTF-8?q?=E4=B8=8E=E8=B0=83=E4=BB=93=E8=A7=84=E6=A0=BC=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/platform_strategy_spec.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 35ffa41..cf0947b 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -45,6 +45,8 @@ pub struct StrategyRuntimeSpec { pub struct StrategyBenchmarkSpec { #[serde(default)] pub instrument_id: Option, + #[serde(default, alias = "fallback_instrument_id")] + pub fallback_instrument_id: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -62,6 +64,8 @@ pub struct StrategyRebalanceSpec { #[serde(default)] pub dates: Vec, #[serde(default)] + pub schedule: Option, + #[serde(default)] pub trade_times: Vec, } From 71b4ffcecf1e2419ae97b1361889dfd207169d83 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 23:27:32 +0800 Subject: [PATCH 19/26] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=9F=BA=E5=87=86=E8=B0=83=E4=BB=93=E8=A7=84=E6=A0=BC=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 26fdd16..9cdd5da 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -75,8 +75,8 @@ pub use platform_strategy_spec::{ StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig, StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig, StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig, - StrategyRiskPolicySpec, StrategyRuntimeEnvironment, StrategyRuntimeExpressions, - StrategyRuntimeSpec, + StrategyRebalanceSpec, StrategyRiskPolicySpec, StrategyRuntimeEnvironment, + StrategyRuntimeExpressions, StrategyRuntimeSpec, StrategyUniverseSpec, platform_expr_config_from_spec, platform_expr_config_from_value, }; pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position}; From 32b3122457453e0e7c321351d340223244509e9a Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 23:37:42 +0800 Subject: [PATCH 20/26] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E8=A7=84=E6=A0=BC=E5=85=83=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/platform_strategy_spec.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index cf0947b..633be0f 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -36,6 +36,14 @@ pub struct StrategyRuntimeSpec { pub engine_config: Option, #[serde(default, alias = "runtime_expressions")] pub runtime_expressions: Option, + #[serde(default, alias = "factor_refs")] + pub factor_refs: Vec, + #[serde(default)] + pub metadata: Option, + #[serde(default, alias = "factor_value_bindings")] + pub factor_value_bindings: Vec, + #[serde(default)] + pub parameters: Option, #[serde(default)] pub environment: Option, } From 5c300f8181500b1ca2e2eee2843e634f302d1e97 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 27 Aug 2026 00:56:48 +0800 Subject: [PATCH 21/26] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=B7=A8=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E6=92=AE=E5=90=88=E6=B5=81=E5=8A=A8=E6=80=A7=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=B6=88=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 490 ++++++++++++++---- crates/fidc-core/tests/explicit_order_flow.rs | 356 +++++++++++++ 2 files changed, 754 insertions(+), 92 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index a1a9e0c..654ae3c 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1,5 +1,6 @@ use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, BTreeSet}; +use std::ops::{Deref, DerefMut}; use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime}; @@ -51,9 +52,143 @@ struct ExecutionFill { quantity: u32, next_cursor: NaiveDateTime, legs: Vec, + liquidity_consumption: Vec, unfilled_reason: Option<&'static str>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QuoteBookSide { + Bid, + Ask, +} + +impl QuoteBookSide { + fn for_order_side(side: OrderSide) -> Self { + match side { + OrderSide::Buy => Self::Ask, + OrderSide::Sell => Self::Bid, + } + } +} + +#[derive(Debug, Clone, Copy)] +struct QuoteDepthConsumption { + price_bits: u64, + displayed_quantity: u32, + consumed_quantity: u32, +} + +#[derive(Debug, Clone)] +struct QuoteLiquidityConsumption { + symbol: String, + timestamp: NaiveDateTime, + book_side: QuoteBookSide, + depth_price_bits: u64, + displayed_quantity: u32, + consume_depth: bool, + consume_volume: bool, + quantity: u32, +} + +#[derive(Debug, Default)] +struct IntradayExecutionLedger { + cursors: BTreeMap, + depth_consumption: BTreeMap; 2]>, + volume_consumption: BTreeMap>, +} + +impl IntradayExecutionLedger { + fn depth_slot(side: QuoteBookSide) -> usize { + match side { + QuoteBookSide::Bid => 0, + QuoteBookSide::Ask => 1, + } + } + + fn depth_consumed( + &self, + symbol: &str, + side: QuoteBookSide, + price_bits: u64, + displayed_quantity: u32, + ) -> u32 { + self.depth_consumption + .get(symbol) + .and_then(|sides| sides[Self::depth_slot(side)]) + .filter(|state| { + state.price_bits == price_bits && state.displayed_quantity == displayed_quantity + }) + .map(|state| state.consumed_quantity.min(displayed_quantity)) + .unwrap_or(0) + } + + fn volume_consumed(&self, symbol: &str, timestamp: NaiveDateTime) -> u32 { + self.volume_consumption + .get(symbol) + .and_then(|quotes| quotes.get(×tamp)) + .copied() + .unwrap_or(0) + } + + fn apply_liquidity_consumption(&mut self, consumptions: &[QuoteLiquidityConsumption]) { + for consumption in consumptions { + if consumption.quantity == 0 { + continue; + } + if consumption.consume_depth { + let sides = self + .depth_consumption + .entry(consumption.symbol.clone()) + .or_insert([None, None]); + let slot = &mut sides[Self::depth_slot(consumption.book_side)]; + match slot { + Some(state) + if state.price_bits == consumption.depth_price_bits + && state.displayed_quantity == consumption.displayed_quantity => + { + state.consumed_quantity = state + .consumed_quantity + .saturating_add(consumption.quantity) + .min(state.displayed_quantity); + } + _ => { + *slot = Some(QuoteDepthConsumption { + price_bits: consumption.depth_price_bits, + displayed_quantity: consumption.displayed_quantity, + consumed_quantity: consumption + .quantity + .min(consumption.displayed_quantity), + }); + } + } + } + if consumption.consume_volume { + let consumed = self + .volume_consumption + .entry(consumption.symbol.clone()) + .or_default() + .entry(consumption.timestamp) + .or_default(); + *consumed = consumed.saturating_add(consumption.quantity); + } + } + } +} + +impl Deref for IntradayExecutionLedger { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.cursors + } +} + +impl DerefMut for IntradayExecutionLedger { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cursors + } +} + #[derive(Debug, Clone)] struct OpenOrder { order_id: u64, @@ -71,6 +206,26 @@ struct OpenOrder { reason: String, } +#[derive(Debug, Default)] +struct BrokerExecutionSession { + date: Option, + intraday_turnover: BTreeMap, + execution_cursors: IntradayExecutionLedger, + global_execution_cursor: Option, + commission_state: BTreeMap, +} + +impl BrokerExecutionSession { + fn activate(&mut self, date: NaiveDate) { + if self.date != Some(date) { + *self = Self { + date: Some(date), + ..Self::default() + }; + } + } +} + #[derive(Debug, Clone)] struct TargetConstraint { symbol: String, @@ -220,6 +375,7 @@ pub struct BrokerSimulator { runtime_time_in_force: Cell>, next_order_id: Cell, open_orders: RefCell>, + execution_session: RefCell, } impl BrokerSimulator { @@ -251,6 +407,7 @@ impl BrokerSimulator { runtime_time_in_force: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), + execution_session: RefCell::new(BrokerExecutionSession::default()), } } @@ -286,6 +443,7 @@ impl BrokerSimulator { runtime_time_in_force: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), + execution_session: RefCell::new(BrokerExecutionSession::default()), } } @@ -1033,20 +1191,31 @@ where portfolio: &mut PortfolioState, data: &DataSet, decision: &StrategyDecision, + ) -> Result { + let mut session = std::mem::take(&mut *self.execution_session.borrow_mut()); + session.activate(date); + let result = self.execute_with_daily_session(date, portfolio, data, decision, &mut session); + *self.execution_session.borrow_mut() = session; + result + } + + fn execute_with_daily_session( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + decision: &StrategyDecision, + session: &mut BrokerExecutionSession, ) -> Result { let mut report = BrokerExecutionReport::default(); - let mut intraday_turnover = BTreeMap::::new(); - let mut execution_cursors = BTreeMap::::new(); - let mut global_execution_cursor = None::; - let mut commission_state = BTreeMap::::new(); self.process_open_orders( date, portfolio, data, - &mut intraday_turnover, - &mut execution_cursors, - &mut global_execution_cursor, - &mut commission_state, + &mut session.intraday_turnover, + &mut session.execution_cursors, + &mut session.global_execution_cursor, + &mut session.commission_state, &mut report, )?; if !decision.order_intents.is_empty() { @@ -1076,10 +1245,10 @@ where portfolio, data, intent, - &mut intraday_turnover, - &mut execution_cursors, - &mut global_execution_cursor, - &mut commission_state, + &mut session.intraday_turnover, + &mut session.execution_cursors, + &mut session.global_execution_cursor, + &mut session.commission_state, &mut report, ); if let Err(error) = result { @@ -1136,10 +1305,10 @@ where requested_qty, self.reserve_order_id(), sell_reason(decision, &symbol), - &mut intraday_turnover, - &mut execution_cursors, - &mut global_execution_cursor, - &mut commission_state, + &mut session.intraday_turnover, + &mut session.execution_cursors, + &mut session.global_execution_cursor, + &mut session.commission_state, None, false, true, @@ -1191,10 +1360,10 @@ where requested_qty, self.reserve_order_id(), "rebalance_buy", - &mut intraday_turnover, - &mut execution_cursors, - &mut global_execution_cursor, - &mut commission_state, + &mut session.intraday_turnover, + &mut session.execution_cursors, + &mut session.global_execution_cursor, + &mut session.commission_state, None, None, false, @@ -1287,7 +1456,7 @@ where data: &DataSet, intent: &OrderIntent, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -1781,7 +1950,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -1816,7 +1985,7 @@ where existing_order_id: Option, emit_creation_events: bool, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -1885,7 +2054,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -2059,7 +2228,7 @@ where portfolio: &mut PortfolioState, data: &DataSet, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -2881,7 +3050,7 @@ where valuation_prices: Option<&BTreeMap>, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -3541,7 +3710,7 @@ where order_id: u64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, limit_price: Option, @@ -3872,10 +4041,17 @@ where algo_request, limit_price, ); - let (filled_qty, execution_legs, next_cursor) = if let Some(fill) = fill { + let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) = + fill + { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); - (fill.quantity, fill.legs, Some(fill.next_cursor)) + ( + fill.quantity, + fill.legs, + Some(fill.next_cursor), + fill.liquidity_consumption, + ) } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(fillable_qty)); @@ -3883,7 +4059,7 @@ where self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price) { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } else if !self.price_satisfies_limit( OrderSide::Sell, execution_price, @@ -3894,7 +4070,7 @@ where partial_fill_reason, Some("limit price not marketable yet"), ); - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } else { match self.execution_price_with_limit_slippage_or_rejection( snapshot, @@ -3910,11 +4086,12 @@ where quantity: fillable_qty, }], None, + Vec::new(), ), Err(reason) => { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } } } @@ -4101,6 +4278,7 @@ where }); } portfolio.prune_flat_positions(); + execution_cursors.apply_liquidity_consumption(&liquidity_consumption); *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let remaining_qty = requested_qty.saturating_sub(filled_qty); @@ -4190,7 +4368,7 @@ where target_value: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4290,7 +4468,7 @@ where end_time: Option, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4373,7 +4551,7 @@ where target_quantity: i32, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4456,7 +4634,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4536,7 +4714,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4618,7 +4796,7 @@ where target_percent: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4649,7 +4827,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4680,7 +4858,7 @@ where value: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4813,7 +4991,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4908,7 +5086,7 @@ where percent: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4939,7 +5117,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4973,7 +5151,7 @@ where end_time: Option, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -5079,7 +5257,7 @@ where end_time: Option, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -5112,7 +5290,7 @@ where quantity: i32, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, algo_request: Option<&AlgoExecutionRequest>, @@ -5177,7 +5355,7 @@ where lots: i32, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -5215,7 +5393,7 @@ where _round_lot: u32, _value_budget: f64, _reason: &str, - _execution_cursors: &BTreeMap, + _execution_cursors: &IntradayExecutionLedger, _global_execution_cursor: Option, ) -> u32 { requested_qty @@ -5238,7 +5416,7 @@ where order_id: u64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, value_budget: Option, @@ -5509,10 +5687,17 @@ where algo_request, limit_price, ); - let (filled_qty, execution_legs, next_cursor) = if let Some(fill) = fill { + let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) = + fill + { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); - (fill.quantity, fill.legs, Some(fill.next_cursor)) + ( + fill.quantity, + fill.legs, + Some(fill.next_cursor), + fill.liquidity_consumption, + ) } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(constrained_qty)); @@ -5520,7 +5705,7 @@ where self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price) { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } else if !self.price_satisfies_limit( OrderSide::Buy, execution_price, @@ -5531,7 +5716,7 @@ where partial_fill_reason, Some("limit price not marketable yet"), ); - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } else { match self.execution_price_with_limit_slippage_or_rejection( snapshot, @@ -5542,7 +5727,7 @@ where Err(reason) => { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } Ok(mut execution_price) => { let mut filled_qty = self.affordable_buy_quantity( @@ -5579,7 +5764,7 @@ where } } if blocked_by_final_price { - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } else { if filled_qty < constrained_qty { partial_fill_reason = merge_partial_fill_reason( @@ -5601,6 +5786,7 @@ where quantity: filled_qty, }], None, + Vec::new(), ) } } @@ -5791,6 +5977,7 @@ where note: format!("buy {symbol} {reason}"), }); } + execution_cursors.apply_liquidity_consumption(&liquidity_consumption); *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let remaining_qty = requested_qty.saturating_sub(filled_qty); @@ -6427,7 +6614,7 @@ where minimum_order_quantity: u32, order_step_size: u32, allow_odd_lot_sell: bool, - _execution_cursors: &mut BTreeMap, + execution_ledger: &mut IntradayExecutionLedger, _global_execution_cursor: Option, cash_limit: Option, gross_limit: Option, @@ -6454,7 +6641,8 @@ where .map(|end_time| date.and_time(end_time)); let quotes = data.execution_quotes_on(date, symbol); - if let Some(fill) = self.select_execution_fill( + if let Some(fill) = self.select_execution_fill_with_ledger( + symbol, snapshot, quotes, side, @@ -6469,13 +6657,19 @@ where cash_limit, gross_limit, limit_price, + execution_ledger, ) { return Some(fill); } - if algo_request.is_some() || self.intraday_execution_start_time.is_some() { + if algo_request.is_some() + || runtime_start_time.is_some() + || runtime_end_time.is_some() + || self.intraday_execution_start_time.is_some() + { let next_cursor = algo_request .and_then(|request| request.start_time) + .or(runtime_start_time) .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time) + Duration::seconds(1)) .unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight")); @@ -6483,6 +6677,7 @@ where quantity: 0, next_cursor, legs: Vec::new(), + liquidity_consumption: Vec::new(), unfilled_reason: Some(self.empty_intraday_quote_reason( quotes, start_cursor, @@ -6521,6 +6716,7 @@ where } } + #[cfg(test)] fn select_execution_fill( &self, snapshot: &crate::data::DailyMarketSnapshot, @@ -6537,6 +6733,46 @@ where cash_limit: Option, gross_limit: Option, limit_price: Option, + ) -> Option { + self.select_execution_fill_with_ledger( + &snapshot.symbol, + snapshot, + quotes, + side, + matching_type, + start_cursor, + end_cursor, + requested_qty, + round_lot, + minimum_order_quantity, + order_step_size, + allow_odd_lot_sell, + cash_limit, + gross_limit, + limit_price, + &IntradayExecutionLedger::default(), + ) + } + + #[allow(clippy::too_many_arguments)] + fn select_execution_fill_with_ledger( + &self, + symbol: &str, + snapshot: &crate::data::DailyMarketSnapshot, + quotes: &[IntradayExecutionQuote], + side: OrderSide, + matching_type: MatchingType, + start_cursor: Option, + end_cursor: Option, + requested_qty: u32, + round_lot: u32, + minimum_order_quantity: u32, + order_step_size: u32, + allow_odd_lot_sell: bool, + cash_limit: Option, + gross_limit: Option, + limit_price: Option, + execution_ledger: &IntradayExecutionLedger, ) -> Option { if requested_qty == 0 { return None; @@ -6583,6 +6819,13 @@ where let mut execution_block_timestamp = None; let mut saw_non_blocked_execution_price = false; let saw_quote_after_cursor = !eligible_quotes.is_empty(); + let book_side = QuoteBookSide::for_order_side(side); + let mut depth_state = execution_ledger + .depth_consumption + .get(symbol) + .and_then(|sides| sides[IntradayExecutionLedger::depth_slot(book_side)]); + let mut pending_volume_consumption = BTreeMap::::new(); + let mut liquidity_consumption = Vec::new(); for (quote_index, quote) in eligible_quotes.iter().enumerate() { // Approximate platform-native market-order fills with the evolving L1 book after @@ -6599,14 +6842,39 @@ where break; } let missing_level1_depth = Self::quote_lacks_level1_depth(quote); - let mut available_qty = if quote_quantity_limited && !missing_level1_depth { - let top_level_liquidity = match side { - OrderSide::Buy => quote.ask1_volume, - OrderSide::Sell => quote.bid1_volume, - }; - top_level_liquidity - .saturating_mul(lot as u64) - .min(u32::MAX as u64) as u32 + let consume_depth = quote_quantity_limited && !missing_level1_depth; + let top_level_price = match side { + OrderSide::Buy => quote.ask1, + OrderSide::Sell => quote.bid1, + }; + let displayed_quantity = match side { + OrderSide::Buy => quote.ask1_volume, + OrderSide::Sell => quote.bid1_volume, + } + .saturating_mul(lot as u64) + .min(u32::MAX as u64) as u32; + let depth_price_bits = top_level_price.to_bits(); + let mut available_qty = if consume_depth { + let consumed = depth_state + .filter(|state| { + state.price_bits == depth_price_bits + && state.displayed_quantity == displayed_quantity + }) + .map(|state| state.consumed_quantity.min(displayed_quantity)) + .unwrap_or_else(|| { + execution_ledger.depth_consumed( + symbol, + book_side, + depth_price_bits, + displayed_quantity, + ) + }); + depth_state = Some(QuoteDepthConsumption { + price_bits: depth_price_bits, + displayed_quantity, + consumed_quantity: consumed, + }); + displayed_quantity.saturating_sub(consumed) } else { remaining_qty }; @@ -6617,7 +6885,15 @@ where } else { self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size) }; - available_qty = available_qty.min(volume_limited); + let consumed = execution_ledger + .volume_consumed(symbol, quote.timestamp) + .saturating_add( + pending_volume_consumption + .get("e.timestamp) + .copied() + .unwrap_or(0), + ); + available_qty = available_qty.min(volume_limited.saturating_sub(consumed)); } if available_qty == 0 { continue; @@ -6730,6 +7006,33 @@ where mark_price, quantity: take_qty, }); + if consume_depth { + let state = depth_state + .as_mut() + .expect("depth state must exist when depth consumption is enabled"); + state.consumed_quantity = state + .consumed_quantity + .saturating_add(take_qty) + .min(state.displayed_quantity); + } + if self.volume_limit { + let consumed = pending_volume_consumption + .entry(quote.timestamp) + .or_default(); + *consumed = consumed.saturating_add(take_qty); + } + if consume_depth || self.volume_limit { + liquidity_consumption.push(QuoteLiquidityConsumption { + symbol: symbol.to_string(), + timestamp: quote.timestamp, + book_side, + depth_price_bits, + displayed_quantity, + consume_depth, + consume_volume: self.volume_limit, + quantity: take_qty, + }); + } if filled_qty >= requested_qty { break; @@ -6746,6 +7049,7 @@ where .expect("blocked execution quote timestamp") + Duration::seconds(1), legs: Vec::new(), + liquidity_consumption: Vec::new(), unfilled_reason: Some(reason), }); } @@ -6764,6 +7068,7 @@ where } else { legs }, + liquidity_consumption, unfilled_reason: if filled_qty < requested_qty { budget_block_reason.or(if saw_quote_after_cursor { Some("intraday quote liquidity exhausted") @@ -6917,7 +7222,8 @@ mod tests { use std::collections::BTreeMap; use super::{ - BrokerExecutionReport, BrokerSimulator, MatchingType, RebalanceCashMode, SlippageModel, + BrokerExecutionReport, BrokerSimulator, IntradayExecutionLedger, MatchingType, + RebalanceCashMode, SlippageModel, }; use crate::cost::ChinaAShareCostModel; use crate::data::{ @@ -8002,7 +8308,7 @@ mod tests { 5_500.0, "next_open_target_value", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8060,7 +8366,7 @@ mod tests { 20_000.0, "target_value_20_percent", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8118,7 +8424,7 @@ mod tests { 10_000.0, "unchanged_target_value", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8136,7 +8442,7 @@ mod tests { None, "unchanged timed target value", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8151,7 +8457,7 @@ mod tests { 1_000, "unchanged target shares", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8208,7 +8514,7 @@ mod tests { 9_995.0, "sub_lot_target_adjust", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8234,7 +8540,7 @@ mod tests { 995.0, "sub_lot_target_open", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut buy_report, @@ -8295,7 +8601,7 @@ mod tests { 0.2, "target_percent_20_percent", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8344,7 +8650,7 @@ mod tests { None, "date_conditioned_target_weights", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8447,7 +8753,7 @@ mod tests { None, "target_weights_test", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8537,7 +8843,7 @@ mod tests { None, "aiquant_deferred_buy_risk", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8627,7 +8933,7 @@ mod tests { .buy(prev_date, 1_000, 10.0); let mut report = BrokerExecutionReport::default(); let mut intraday_turnover = BTreeMap::new(); - let mut execution_cursors = BTreeMap::new(); + let mut execution_cursors = IntradayExecutionLedger::default(); let mut global_execution_cursor = None; let mut commission_state = BTreeMap::new(); @@ -8844,7 +9150,7 @@ mod tests { None, "rebalance_cash_mode_test", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut sell_then_buy_report, @@ -8885,7 +9191,7 @@ mod tests { None, "rebalance_cash_mode_test", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut pre_open_cash_report, @@ -9276,7 +9582,7 @@ mod tests { Some(&valuation_prices), "custom_valuation_market_order_test", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9343,7 +9649,7 @@ mod tests { 0.0, "target_rebalance_exit", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9400,7 +9706,7 @@ mod tests { None, "target_portfolio_rebalance", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9477,7 +9783,7 @@ mod tests { Some(date.and_hms_opt(9, 31, 0).unwrap().time()), "risk_forced_exit", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9604,7 +9910,7 @@ mod tests { 9_996_284.62 * 0.5 / 40.0, "daily_position_target_adjust", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9715,7 +10021,7 @@ mod tests { 125_000.0, "periodic_rebalance_buy", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9776,7 +10082,7 @@ mod tests { 125_000.0, "periodic_rebalance_buy", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9843,7 +10149,7 @@ mod tests { value_budget, "periodic_rebalance_buy", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -10073,7 +10379,7 @@ mod tests { 0.0, "stop_loss_exit", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -10162,7 +10468,7 @@ mod tests { 0.0, "stop_loss_exit", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 3796cb3..19f4f15 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -77,6 +77,117 @@ fn order_value_rounding_data(date: NaiveDate, symbol: &str, price: f64) -> DataS .expect("dataset") } +fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet { + DataSet::from_components_with_actions_and_quotes( + vec![Instrument { + symbol: symbol.to_string(), + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some(format!("{date} 10:19:00")), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.0, + prev_close: 10.0, + volume: 100_000, + minute_volume: 1_000, + bid1_volume: 5, + ask1_volume: 5, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 15.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_star_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + Vec::new(), + vec![ + IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(10, 18, 0).unwrap(), + last_price: 10.0, + bid1: 9.99, + ask1: 10.0, + bid1_volume: 4, + ask1_volume: 4, + volume_delta: 1_000, + amount_delta: 10_000.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(10, 19, 0).unwrap(), + last_price: 10.0, + bid1: 9.99, + ask1: 10.0, + bid1_volume: 4, + ask1_volume: 4, + volume_delta: 1_000, + amount_delta: 10_000.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(10, 20, 0).unwrap(), + last_price: 10.0, + bid1: 9.99, + ask1: 10.0, + bid1_volume: 5, + ask1_volume: 5, + volume_delta: 1_000, + amount_delta: 10_000.0, + trading_phase: Some("continuous".to_string()), + }, + ], + ) + .expect("dataset") +} + fn execute_single_value_order( date: NaiveDate, data: &DataSet, @@ -4830,6 +4941,251 @@ fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() { assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100); } +#[test] +fn broker_persists_daily_volume_consumption_across_execute_calls() { + let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); + let data = two_day_limit_order_data(10.0, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_volume_limit(true) + .with_volume_percent(0.001) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + let decision = || StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: "000002.SZ".to_string(), + quantity: 100, + reason: "daily_volume_session_buy".to_string(), + }], + ..StrategyDecision::default() + }; + + let first = broker + .execute(day1, &mut portfolio, &data, &decision()) + .expect("first same-day execution"); + assert_eq!(first.fill_events.len(), 1); + assert_eq!(first.fill_events[0].quantity, 100); + + let second = broker + .execute(day1, &mut portfolio, &data, &decision()) + .expect("second same-day execution"); + assert!(second.fill_events.is_empty()); + assert_eq!(second.order_events.len(), 1); + assert_eq!(second.order_events[0].status, OrderStatus::Canceled); + assert_eq!(second.order_events[0].filled_quantity, 0); + assert!(second.order_events[0].reason.contains("daily volume limit")); + assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100); + + let next_day = broker + .execute(day2, &mut portfolio, &data, &decision()) + .expect("next-day execution resets daily liquidity"); + assert_eq!(next_day.fill_events.len(), 1); + assert_eq!(next_day.fill_events[0].quantity, 100); + assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 200); +} + +#[test] +fn broker_persists_quote_depth_until_fresh_level_data_arrives() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let symbol = "000002.SZ"; + let data = intraday_liquidity_data(date, symbol); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ) + .with_matching_type(MatchingType::MinuteLast) + .with_volume_limit(false) + .with_liquidity_limit(true); + let mut portfolio = PortfolioState::new(1_000_000.0); + let decision = |quantity| StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: symbol.to_string(), + quantity, + reason: "quote_depth_session_buy".to_string(), + }], + ..StrategyDecision::default() + }; + let at_1018 = NaiveTime::from_hms_opt(10, 18, 0).unwrap(); + let at_1019 = NaiveTime::from_hms_opt(10, 19, 0).unwrap(); + let at_1020 = NaiveTime::from_hms_opt(10, 20, 0).unwrap(); + + let atomic_reject = broker + .execute_between( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::Shares { + symbol: symbol.to_string(), + quantity: 500, + reason: "quote_depth_fok_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Fok), + ], + ..StrategyDecision::default() + }, + Some(at_1018), + Some(at_1018), + ) + .expect("FOK rejection must not consume quote depth"); + assert!(atomic_reject.fill_events.is_empty()); + assert_eq!(atomic_reject.order_events[0].status, OrderStatus::Canceled); + assert!(portfolio.position(symbol).is_none()); + + let first = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(300), + Some(at_1018), + Some(at_1018), + ) + .expect("first quote-depth execution"); + assert_eq!(first.fill_events.len(), 1); + assert_eq!(first.fill_events[0].quantity, 300); + + let second = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(200), + Some(at_1018), + Some(at_1018), + ) + .expect("second quote-depth execution"); + assert_eq!(second.fill_events.len(), 1); + assert_eq!(second.fill_events[0].quantity, 100); + assert_eq!(second.order_events[0].status, OrderStatus::Canceled); + assert_eq!(second.order_events[0].filled_quantity, 100); + assert!( + second.order_events[0] + .reason + .contains("intraday quote liquidity exhausted") + ); + + let unchanged_level = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(100), + Some(at_1019), + Some(at_1019), + ) + .expect("unchanged level must remain depleted"); + assert!(unchanged_level.fill_events.is_empty()); + assert_eq!( + unchanged_level.order_events[0].status, + OrderStatus::Canceled + ); + assert!( + unchanged_level.order_events[0] + .reason + .contains("intraday quote liquidity exhausted") + ); + + let fresh_level = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(200), + Some(at_1020), + Some(at_1020), + ) + .expect("fresh quote level resets depth consumption"); + assert_eq!(fresh_level.fill_events.len(), 1); + assert_eq!(fresh_level.fill_events[0].quantity, 200); + assert_eq!(fresh_level.order_events[0].status, OrderStatus::Filled); + assert_eq!(portfolio.position(symbol).unwrap().quantity, 600); +} + +#[test] +fn broker_persists_quote_volume_participation_until_next_quote() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let symbol = "000002.SZ"; + let data = intraday_liquidity_data(date, symbol); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ) + .with_matching_type(MatchingType::MinuteLast) + .with_volume_limit(true) + .with_volume_percent(0.25) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + let decision = |quantity| StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: symbol.to_string(), + quantity, + reason: "quote_volume_session_buy".to_string(), + }], + ..StrategyDecision::default() + }; + let at_1018 = NaiveTime::from_hms_opt(10, 18, 0).unwrap(); + let at_1019 = NaiveTime::from_hms_opt(10, 19, 0).unwrap(); + + let first = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(100), + Some(at_1018), + Some(at_1018), + ) + .expect("first quote-volume execution"); + assert_eq!(first.fill_events[0].quantity, 100); + + let second = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(200), + Some(at_1018), + Some(at_1018), + ) + .expect("second quote-volume execution"); + assert_eq!(second.fill_events[0].quantity, 100); + assert_eq!(second.order_events[0].status, OrderStatus::Canceled); + + let exhausted = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(100), + Some(at_1018), + Some(at_1018), + ) + .expect("quote volume must remain exhausted"); + assert!(exhausted.fill_events.is_empty()); + assert_eq!(exhausted.order_events[0].status, OrderStatus::Canceled); + + let next_quote = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(200), + Some(at_1019), + Some(at_1019), + ) + .expect("next quote receives a fresh participation bucket"); + assert_eq!(next_quote.fill_events[0].quantity, 200); + assert_eq!(portfolio.position(symbol).unwrap().quantity, 400); +} + #[test] fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); From 21cfa65af2e50320f2d7e5df021236e6e61fbee2 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 27 Aug 2026 01:37:18 +0800 Subject: [PATCH 22/26] =?UTF-8?q?=E5=85=B1=E4=BA=AB=E5=9B=9E=E6=B5=8B?= =?UTF-8?q?=E5=88=86=E9=92=9F=E6=8A=A5=E4=BB=B7=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index c9c4a1d..0545d4d 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -1173,7 +1173,7 @@ pub struct DataSet { candidate_by_date: Arc>>, candidate_symbol_ids_by_date: Arc>>, corporate_actions_by_date: Arc>>, - execution_quotes_by_date: HashMap>>, + execution_quotes_by_date: Arc>>>, order_book_depth_index: Arc>>, benchmark_by_date: Arc>, market_series_by_symbol: Arc>>, @@ -1463,7 +1463,7 @@ impl DataSet { candidate_by_date: Arc::new(candidate_by_date), candidate_symbol_ids_by_date: Arc::new(candidate_symbol_ids_by_date), corporate_actions_by_date: Arc::new(corporate_actions_by_date), - execution_quotes_by_date, + execution_quotes_by_date: Arc::new(execution_quotes_by_date), order_book_depth_index: Arc::new(order_book_depth_index), benchmark_by_date: Arc::new(benchmark_by_date), market_series_by_symbol: Arc::new(market_series_by_symbol), @@ -1648,8 +1648,9 @@ impl DataSet { .push(quote); } let mut added = 0usize; + let execution_quotes_by_date = Arc::make_mut(&mut self.execution_quotes_by_date); for (date, rows_by_symbol) in grouped { - let target_by_symbol = self.execution_quotes_by_date.entry(date).or_default(); + let target_by_symbol = execution_quotes_by_date.entry(date).or_default(); for (symbol, mut incoming) in rows_by_symbol { incoming.sort_by_key(|quote| quote.timestamp); incoming.dedup_by(|left, right| left.timestamp == right.timestamp); @@ -1743,7 +1744,7 @@ impl DataSet { } pub fn remove_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize { - self.execution_quotes_by_date + Arc::make_mut(&mut self.execution_quotes_by_date) .remove(&date) .map(|rows_by_symbol| rows_by_symbol.into_values().map(|rows| rows.len()).sum()) .unwrap_or_default() @@ -3859,6 +3860,10 @@ mod tests { &data.benchmark_by_date, &run_data.benchmark_by_date )); + assert!(Arc::ptr_eq( + &data.execution_quotes_by_date, + &run_data.execution_quotes_by_date + )); run_data.add_execution_quotes(vec![IntradayExecutionQuote { date, @@ -3877,6 +3882,10 @@ mod tests { assert_eq!(data.execution_quote_count(), 0); assert_eq!(run_data.execution_quote_count(), 1); + assert!(!Arc::ptr_eq( + &data.execution_quotes_by_date, + &run_data.execution_quotes_by_date + )); } #[test] From cdbd8a67deebce67fc18308e8b7d362c4a8d7982 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 27 Aug 2026 02:46:38 +0800 Subject: [PATCH 23/26] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=88=86=E9=92=9F?= =?UTF-8?q?=E6=88=90=E4=BA=A4=E7=B2=BE=E7=A1=AE=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 21 +++ crates/fidc-core/src/engine.rs | 2 + crates/fidc-core/src/events.rs | 142 +++++++++++++++++- crates/fidc-core/src/futures.rs | 2 + crates/fidc-core/tests/explicit_order_flow.rs | 16 ++ 5 files changed, 180 insertions(+), 3 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 654ae3c..8e1fc59 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -36,6 +36,9 @@ impl BrokerExecutionReport { for event in &self.order_events { event.validate().map_err(BacktestError::Execution)?; } + for event in &self.fill_events { + event.validate().map_err(BacktestError::Execution)?; + } Ok(()) } } @@ -45,6 +48,8 @@ struct ExecutionLeg { price: f64, mark_price: f64, quantity: u32, + execution_start_timestamp: Option, + execution_timestamp: Option, } #[derive(Debug, Clone)] @@ -4084,6 +4089,8 @@ where price: execution_price, mark_price: self.snapshot_mark_price(snapshot, OrderSide::Sell), quantity: fillable_qty, + execution_start_timestamp: None, + execution_timestamp: None, }], None, Vec::new(), @@ -4227,6 +4234,8 @@ where decision_date: None, order_created_date: None, execution_date: None, + execution_start_timestamp: leg.execution_start_timestamp, + execution_timestamp: leg.execution_timestamp, order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Sell, @@ -5784,6 +5793,8 @@ where price: execution_price, mark_price: self.snapshot_mark_price(snapshot, OrderSide::Buy), quantity: filled_qty, + execution_start_timestamp: None, + execution_timestamp: None, }], None, Vec::new(), @@ -5928,6 +5939,8 @@ where decision_date: None, order_created_date: None, execution_date: None, + execution_start_timestamp: leg.execution_start_timestamp, + execution_timestamp: leg.execution_timestamp, order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Buy, @@ -6812,6 +6825,7 @@ where let mut filled_qty = 0_u32; let mut gross_amount = 0.0_f64; let mut mark_amount = 0.0_f64; + let mut first_timestamp = None; let mut last_timestamp = None; let mut legs = Vec::new(); let mut budget_block_reason = None; @@ -7000,11 +7014,14 @@ where gross_amount += quote_price * take_qty as f64; mark_amount += mark_price * take_qty as f64; filled_qty += take_qty; + first_timestamp.get_or_insert(quote.timestamp); last_timestamp = Some(quote.timestamp); legs.push(ExecutionLeg { price: quote_price, mark_price, quantity: take_qty, + execution_start_timestamp: Some(quote.timestamp), + execution_timestamp: Some(quote.timestamp), }); if consume_depth { let state = depth_state @@ -7064,6 +7081,8 @@ where price: gross_amount / filled_qty as f64, mark_price: mark_amount / filled_qty as f64, quantity: filled_qty, + execution_start_timestamp: first_timestamp, + execution_timestamp: last_timestamp, }] } else { legs @@ -9966,6 +9985,8 @@ mod tests { assert_eq!(fill.quantity, 200); assert_eq!(fill.legs.len(), 1); assert_eq!(fill.legs[0].price, 10.8); + assert_eq!(fill.legs[0].execution_timestamp, Some(quote_timestamp)); + assert!(fill.legs[0].execution_timestamp.unwrap() <= decision_time); assert_eq!( fill.next_cursor, quote_timestamp + chrono::Duration::seconds(1) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 5f57246..2de624c 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -3494,6 +3494,8 @@ where decision_date: None, order_created_date: None, execution_date: None, + execution_start_timestamp: None, + execution_timestamp: None, order_id: None, symbol: receivable.symbol.clone(), side: OrderSide::Buy, diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 6eddd15..539b77a 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -1,4 +1,4 @@ -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveDateTime}; use serde::{Deserialize, Serialize}; mod date_format { @@ -50,6 +50,35 @@ mod optional_date_format { } } +mod optional_datetime_format { + use chrono::NaiveDateTime; + use serde::{self, Deserialize, Deserializer, Serializer}; + + const FORMAT: &str = "%Y-%m-%d %H:%M:%S%.f"; + + pub fn serialize(datetime: &Option, serializer: S) -> Result + where + S: Serializer, + { + match datetime { + Some(datetime) => serializer.serialize_some(&datetime.format(FORMAT).to_string()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let value = Option::::deserialize(deserializer)?; + value + .map(|text| { + NaiveDateTime::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom) + }) + .transpose() + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum OrderSide { Buy, @@ -162,6 +191,18 @@ pub struct FillEvent { pub order_created_date: Option, #[serde(default, with = "optional_date_format")] pub execution_date: Option, + #[serde( + default, + with = "optional_datetime_format", + skip_serializing_if = "Option::is_none" + )] + pub execution_start_timestamp: Option, + #[serde( + default, + with = "optional_datetime_format", + skip_serializing_if = "Option::is_none" + )] + pub execution_timestamp: Option, #[serde(default)] pub order_id: Option, pub symbol: String, @@ -176,6 +217,42 @@ pub struct FillEvent { pub reason: String, } +impl FillEvent { + pub fn validate(&self) -> Result<(), String> { + if self.symbol.trim().is_empty() + || self.quantity == 0 + || !self.price.is_finite() + || self.price <= 0.0 + { + return Err(format!( + "invalid fill identity/quantity/price order_id={:?} symbol={} quantity={} price={}", + self.order_id, self.symbol, self.quantity, self.price + )); + } + if let (Some(start), Some(end)) = (self.execution_start_timestamp, self.execution_timestamp) + { + if start > end { + return Err(format!( + "fill execution timestamp order is invalid order_id={:?} start={} end={}", + self.order_id, start, end + )); + } + if start.date() != self.date || end.date() != self.date { + return Err(format!( + "fill execution timestamp date mismatch order_id={:?} fill_date={} start={} end={}", + self.order_id, self.date, start, end + )); + } + } else if self.execution_start_timestamp.is_some() || self.execution_timestamp.is_some() { + return Err(format!( + "fill execution timestamp range is incomplete order_id={:?}", + self.order_id + )); + } + Ok(()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PositionEvent { #[serde(with = "date_format")] @@ -299,9 +376,9 @@ pub struct ProcessEvent { #[cfg(test)] mod tests { - use chrono::NaiveDate; + use chrono::{NaiveDate, NaiveDateTime}; - use super::{OrderEvent, OrderSide, OrderStatus}; + use super::{FillEvent, OrderEvent, OrderSide, OrderStatus}; fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent { OrderEvent { @@ -342,4 +419,63 @@ mod tests { assert!(order_event(OrderStatus::Rejected, 1).validate().is_err()); assert!(order_event(OrderStatus::Expired, 100).validate().is_err()); } + + fn fill_event(start: Option, end: Option) -> FillEvent { + FillEvent { + date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), + decision_date: None, + order_created_date: None, + execution_date: None, + execution_start_timestamp: start, + execution_timestamp: end, + order_id: Some(1), + symbol: "600000.SH".to_string(), + side: OrderSide::Buy, + quantity: 100, + price: 10.0, + gross_amount: 1_000.0, + commission: 5.0, + stamp_tax: 0.0, + transfer_fee: 0.0, + net_cash_flow: -1_005.0, + reason: "test".to_string(), + } + } + + #[test] + fn fill_execution_timestamp_range_is_explicit_and_backward_compatible() { + let start = NaiveDate::from_ymd_opt(2025, 1, 2) + .unwrap() + .and_hms_opt(10, 18, 0) + .unwrap(); + let end = start + chrono::Duration::seconds(3); + assert!(fill_event(Some(start), Some(end)).validate().is_ok()); + assert!(fill_event(Some(end), Some(start)).validate().is_err()); + assert!(fill_event(Some(start), None).validate().is_err()); + + let next_day = start + chrono::Duration::days(1); + assert!( + fill_event(Some(next_day), Some(next_day)) + .validate() + .is_err() + ); + + let legacy = fill_event(None, None); + let legacy_json = serde_json::to_value(&legacy).unwrap(); + assert!(legacy_json.get("execution_start_timestamp").is_none()); + assert!(legacy_json.get("execution_timestamp").is_none()); + let decoded: FillEvent = serde_json::from_value(legacy_json).unwrap(); + assert_eq!(decoded.execution_start_timestamp, None); + assert_eq!(decoded.execution_timestamp, None); + + let timestamped_json = serde_json::to_value(fill_event(Some(start), Some(end))).unwrap(); + assert_eq!( + timestamped_json["execution_start_timestamp"], + "2025-01-02 10:18:00" + ); + assert_eq!( + timestamped_json["execution_timestamp"], + "2025-01-02 10:18:03" + ); + } } diff --git a/crates/fidc-core/src/futures.rs b/crates/fidc-core/src/futures.rs index 5b412b7..07b4cc6 100644 --- a/crates/fidc-core/src/futures.rs +++ b/crates/fidc-core/src/futures.rs @@ -1052,6 +1052,8 @@ impl FuturesAccountState { decision_date: None, order_created_date: None, execution_date: None, + execution_start_timestamp: None, + execution_timestamp: None, order_id, symbol: intent.symbol.clone(), side, diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 19f4f15..870d9f0 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -2537,6 +2537,14 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() { assert_eq!(report.fill_events[1].quantity, 100); assert!((report.fill_events[0].price - 10.01).abs() < 1e-9); assert!((report.fill_events[1].price - 10.03).abs() < 1e-9); + assert_eq!( + report.fill_events[0].execution_timestamp, + date.and_hms_opt(10, 18, 3) + ); + assert_eq!( + report.fill_events[1].execution_timestamp, + date.and_hms_opt(10, 18, 6) + ); assert!((report.fill_events[0].commission - 5.0).abs() < 1e-9); assert_eq!(report.fill_events[1].commission, 0.0); assert_eq!(report.account_events.len(), 2); @@ -2699,6 +2707,14 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() { assert_eq!(report.fill_events.len(), 1); assert_eq!(report.fill_events[0].quantity, 200); assert!((report.fill_events[0].price - 10.02).abs() < 1e-9); + assert_eq!( + report.fill_events[0].execution_start_timestamp, + date.and_hms_opt(10, 18, 3) + ); + assert_eq!( + report.fill_events[0].execution_timestamp, + date.and_hms_opt(10, 18, 6) + ); assert!((report.fill_events[0].commission - 5.0).abs() < 1e-9); assert_eq!(report.account_events.len(), 1); assert_eq!( From 5a765766e376d3b1c3dbf7dd9db11ee34fbd55de Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 27 Aug 2026 08:07:07 +0800 Subject: [PATCH 24/26] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=8C=96=E5=BC=80=E6=94=BE=E8=AE=A2=E5=8D=95=E6=94=B9=E5=8D=95?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 339 +++++++++++++++++- crates/fidc-core/src/engine.rs | 16 + crates/fidc-core/src/events.rs | 6 + .../fidc-core/src/platform_expr_strategy.rs | 115 +++++- .../fidc-core/src/platform_strategy_spec.rs | 36 ++ crates/fidc-core/src/strategy.rs | 9 +- crates/fidc-core/src/strategy_ai.rs | 6 +- crates/fidc-core/tests/explicit_order_flow.rs | 326 +++++++++++++++++ 8 files changed, 846 insertions(+), 7 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 8e1fc59..ab3e188 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1844,6 +1844,24 @@ where self.cancel_open_order(date, *order_id, reason, report); Ok(()) } + OrderIntent::ModifyOrder { + order_id, + new_total_quantity, + new_limit_price, + reason, + } => { + self.modify_open_order( + date, + portfolio, + data, + *order_id, + *new_total_quantity, + *new_limit_price, + reason, + report, + ); + Ok(()) + } OrderIntent::CancelSymbol { symbol, reason } => { self.cancel_open_orders_for_symbol(date, symbol, reason, report); Ok(()) @@ -2095,8 +2113,14 @@ where fn upsert_open_order(&self, open_order: OpenOrder) { let mut open_orders = self.open_orders.borrow_mut(); - open_orders.retain(|existing| existing.order_id != open_order.order_id); - open_orders.push(open_order); + if let Some(existing) = open_orders + .iter_mut() + .find(|existing| existing.order_id == open_order.order_id) + { + *existing = open_order; + } else { + open_orders.push(open_order); + } } fn current_decision_date(&self, date: NaiveDate) -> NaiveDate { @@ -2400,6 +2424,272 @@ where } } + #[allow(clippy::too_many_arguments)] + fn modify_open_order( + &self, + date: NaiveDate, + portfolio: &PortfolioState, + data: &DataSet, + order_id: u64, + new_total_quantity: Option, + new_limit_price: Option, + reason: &str, + report: &mut BrokerExecutionReport, + ) { + let Some(existing) = self + .open_orders + .borrow() + .iter() + .find(|order| order.order_id == order_id) + .cloned() + else { + Self::emit_open_order_update_rejected( + report, + date, + order_id, + None, + None, + reason, + "not_found", + ); + return; + }; + + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderPendingUpdate, + order_id, + &existing.symbol, + existing.side, + format!("reason={reason}"), + ); + + let target_total_quantity = new_total_quantity.unwrap_or(existing.requested_quantity); + let target_limit_price = new_limit_price.unwrap_or(existing.limit_price); + if target_total_quantity == existing.requested_quantity + && target_limit_price.to_bits() == existing.limit_price.to_bits() + { + Self::emit_open_order_update_rejected( + report, + date, + order_id, + Some(&existing.symbol), + Some(existing.side), + reason, + "no_fields_changed", + ); + return; + } + if target_total_quantity <= existing.filled_quantity { + Self::emit_open_order_update_rejected( + report, + date, + order_id, + Some(&existing.symbol), + Some(existing.side), + reason, + &format!( + "new_total_quantity_must_exceed_filled_quantity new_total={} filled={}", + target_total_quantity, existing.filled_quantity + ), + ); + return; + } + if !target_limit_price.is_finite() || target_limit_price <= 0.0 { + Self::emit_open_order_update_rejected( + report, + date, + order_id, + Some(&existing.symbol), + Some(existing.side), + reason, + "limit_price_must_be_positive", + ); + return; + } + let Some(snapshot) = data.market(date, &existing.symbol) else { + Self::emit_open_order_update_rejected( + report, + date, + order_id, + Some(&existing.symbol), + Some(existing.side), + reason, + "market_snapshot_missing_for_update_validation", + ); + return; + }; + let price_tick = snapshot.effective_price_tick().max(1e-9); + let tick_aligned_price = (target_limit_price / price_tick).round() * price_tick; + if (target_limit_price - tick_aligned_price).abs() > price_tick * 1e-6 { + Self::emit_open_order_update_rejected( + report, + date, + order_id, + Some(&existing.symbol), + Some(existing.side), + reason, + &format!( + "limit_price_not_tick_aligned price={} tick={}", + target_limit_price, price_tick + ), + ); + return; + } + if (snapshot.lower_limit.is_finite() + && snapshot.lower_limit > 0.0 + && target_limit_price + price_tick * 1e-6 < snapshot.lower_limit) + || (snapshot.upper_limit.is_finite() + && snapshot.upper_limit > 0.0 + && target_limit_price > snapshot.upper_limit + price_tick * 1e-6) + { + Self::emit_open_order_update_rejected( + report, + date, + order_id, + Some(&existing.symbol), + Some(existing.side), + reason, + &format!( + "limit_price_outside_daily_range price={} lower={} upper={}", + target_limit_price, snapshot.lower_limit, snapshot.upper_limit + ), + ); + return; + } + + let target_remaining_quantity = + target_total_quantity.saturating_sub(existing.filled_quantity); + if existing.side == OrderSide::Buy { + let minimum_order_quantity = self.minimum_order_quantity(data, &existing.symbol); + let order_step_size = self.order_step_size(data, &existing.symbol); + if self.round_buy_quantity( + target_remaining_quantity, + minimum_order_quantity, + order_step_size, + ) != target_remaining_quantity + { + Self::emit_open_order_update_rejected( + report, + date, + order_id, + Some(&existing.symbol), + Some(existing.side), + reason, + &format!( + "remaining_quantity_not_lot_aligned remaining={} minimum={} step={}", + target_remaining_quantity, minimum_order_quantity, order_step_size + ), + ); + return; + } + } else { + let position_quantity = portfolio + .position(&existing.symbol) + .map(|position| position.quantity) + .unwrap_or(0); + let reserved_by_other_orders = + self.reserved_open_sell_quantity(&existing.symbol, Some(order_id)); + let available_quantity = position_quantity.saturating_sub(reserved_by_other_orders); + if target_remaining_quantity > available_quantity { + Self::emit_open_order_update_rejected( + report, + date, + order_id, + Some(&existing.symbol), + Some(existing.side), + reason, + &format!( + "sell_quantity_exceeds_available remaining={} available={} other_reserved={}", + target_remaining_quantity, available_quantity, reserved_by_other_orders + ), + ); + return; + } + } + + let resets_queue_priority = target_limit_price.to_bits() != existing.limit_price.to_bits() + || target_total_quantity > existing.requested_quantity; + { + let mut open_orders = self.open_orders.borrow_mut(); + let index = open_orders + .iter() + .position(|order| order.order_id == order_id) + .expect("open order disappeared during synchronous update"); + let order = &mut open_orders[index]; + order.requested_quantity = target_total_quantity; + order.remaining_quantity = target_remaining_quantity; + order.limit_price = target_limit_price; + if resets_queue_priority { + let amended = open_orders.remove(index); + open_orders.push(amended); + } + } + report.order_events.push(OrderEvent { + date, + decision_date: existing.decision_date, + order_created_date: existing.order_created_date, + execution_date: None, + order_id: Some(order_id), + symbol: existing.symbol.clone(), + side: existing.side, + requested_quantity: target_total_quantity, + filled_quantity: existing.filled_quantity, + status: if existing.filled_quantity == 0 { + OrderStatus::Pending + } else { + OrderStatus::PartiallyFilled + }, + reason: format!( + "{reason}: order updated old_total={} new_total={} old_limit={} new_limit={} queue_priority_reset={}", + existing.requested_quantity, + target_total_quantity, + existing.limit_price, + target_limit_price, + resets_queue_priority + ), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUpdatePass, + order_id, + &existing.symbol, + existing.side, + format!( + "old_total={} new_total={} filled={} remaining={} old_limit={} new_limit={} queue_priority_reset={}", + existing.requested_quantity, + target_total_quantity, + existing.filled_quantity, + target_remaining_quantity, + existing.limit_price, + target_limit_price, + resets_queue_priority + ), + ); + } + + #[allow(clippy::too_many_arguments)] + fn emit_open_order_update_rejected( + report: &mut BrokerExecutionReport, + date: NaiveDate, + order_id: u64, + symbol: Option<&str>, + side: Option, + reason: &str, + detail: &str, + ) { + report.process_events.push(ProcessEvent { + date, + kind: ProcessEventKind::OrderUpdateReject, + order_id: Some(order_id), + symbol: symbol.map(ToString::to_string), + side, + detail: format!("reason={reason} status={detail}"), + }); + } + fn cancel_open_orders_for_symbol( &self, date: NaiveDate, @@ -7241,7 +7531,7 @@ mod tests { use std::collections::BTreeMap; use super::{ - BrokerExecutionReport, BrokerSimulator, IntradayExecutionLedger, MatchingType, + BrokerExecutionReport, BrokerSimulator, IntradayExecutionLedger, MatchingType, OpenOrder, RebalanceCashMode, SlippageModel, }; use crate::cost::ChinaAShareCostModel; @@ -7255,7 +7545,48 @@ mod tests { use crate::portfolio::PortfolioState; use crate::risk_control::FidcRiskControlConfig; use crate::rules::ChinaEquityRuleHooks; - use crate::strategy::{AlgoOrderStyle, OrderIntent, StrategyDecision}; + use crate::strategy::{AlgoOrderStyle, OrderIntent, OrderTimeInForce, StrategyDecision}; + + fn test_open_order(order_id: u64) -> OpenOrder { + OpenOrder { + order_id, + decision_date: None, + order_created_date: None, + symbol: "000001.SZ".to_string(), + side: OrderSide::Buy, + requested_quantity: 200, + filled_quantity: 0, + remaining_quantity: 200, + limit_price: 10.0, + time_in_force: OrderTimeInForce::Gtc, + commission_remaining: None, + execution_cursor: None, + reason: format!("order_{order_id}"), + } + } + + #[test] + fn open_order_upsert_replaces_in_place_and_preserves_queue_position() { + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks); + broker.upsert_open_order(test_open_order(1)); + broker.upsert_open_order(test_open_order(2)); + + let mut amended = test_open_order(1); + amended.filled_quantity = 100; + amended.remaining_quantity = 100; + broker.upsert_open_order(amended); + + assert_eq!( + broker + .open_orders + .borrow() + .iter() + .map(|order| order.order_id) + .collect::>(), + vec![1, 2] + ); + assert_eq!(broker.open_order_views()[0].filled_quantity, 100); + } fn limit_test_snapshot() -> DailyMarketSnapshot { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 2de624c..6f61bda 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1098,6 +1098,17 @@ where merge_futures_report(directive_report, report); } } + crate::strategy::OrderIntent::ModifyOrder { + order_id, + new_total_quantity, + new_limit_price, + reason, + } => retained.push(crate::strategy::OrderIntent::ModifyOrder { + order_id, + new_total_quantity, + new_limit_price, + reason, + }), crate::strategy::OrderIntent::CancelSymbol { symbol, reason } => { let report = self.cancel_futures_open_orders_for_symbol( execution_date, @@ -3882,6 +3893,11 @@ fn execution_quote_symbols_for_decision( OrderIntent::CancelAll { .. } => { symbols.extend(open_orders.iter().map(|order| order.symbol.clone())); } + OrderIntent::ModifyOrder { order_id, .. } => { + if let Some(order) = open_orders.iter().find(|order| order.order_id == *order_id) { + symbols.insert(order.symbol.clone()); + } + } OrderIntent::WithTimeInForce { .. } => unreachable!("intent is unwrapped"), OrderIntent::UpdateUniverse { .. } | OrderIntent::Subscribe { .. } diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 539b77a..c53d6c2 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -306,6 +306,9 @@ pub enum ProcessEventKind { OrderPendingCancel, OrderCancellationPass, OrderCancellationReject, + OrderPendingUpdate, + OrderUpdatePass, + OrderUpdateReject, OrderUnsolicitedUpdate, Trade, UniverseUpdated, @@ -348,6 +351,9 @@ impl ProcessEventKind { Self::OrderPendingCancel => "order_pending_cancel", Self::OrderCancellationPass => "order_cancellation_pass", Self::OrderCancellationReject => "order_cancellation_reject", + Self::OrderPendingUpdate => "order_pending_update", + Self::OrderUpdatePass => "order_update_pass", + Self::OrderUpdateReject => "order_update_reject", Self::OrderUnsolicitedUpdate => "order_unsolicited_update", Self::Trade => "trade", Self::UniverseUpdated => "universe_updated", diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 2e27992..0e4272d 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -342,6 +342,14 @@ pub enum PlatformTradeAction { when_expr: Option, reason: String, }, + Modify { + symbol: Option, + order_id_expr: String, + new_total_quantity_expr: Option, + new_limit_price_expr: Option, + when_expr: Option, + reason: String, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1486,6 +1494,31 @@ impl PlatformExprStrategy { } } } + PlatformTradeAction::Modify { + order_id_expr, + new_total_quantity_expr, + new_limit_price_expr, + when_expr, + .. + } => { + expressions.push(( + format!("explicit_actions[{index}].order_id_expr"), + order_id_expr, + )); + for (name, expression) in [ + ( + "new_total_quantity_expr", + new_total_quantity_expr.as_deref(), + ), + ("new_limit_price_expr", new_limit_price_expr.as_deref()), + ("when_expr", when_expr.as_deref()), + ] { + if let Some(expression) = expression { + expressions + .push((format!("explicit_actions[{index}].{name}"), expression)); + } + } + } } } @@ -8365,6 +8398,64 @@ impl PlatformExprStrategy { } } } + PlatformTradeAction::Modify { + symbol, + order_id_expr, + new_total_quantity_expr, + new_limit_price_expr, + when_expr, + reason, + } => { + let stock_state = self.action_stock_state(ctx, date, symbol.as_deref())?; + if !self.action_when_matches( + ctx, + day, + stock_state.as_deref(), + when_expr.as_deref(), + )? { + continue; + } + let order_id = + self.eval_u64(ctx, order_id_expr, day, stock_state.as_deref(), None)?; + if order_id == 0 { + return Err(BacktestError::Execution( + "modify_order order_id must be positive".to_string(), + )); + } + let new_total_quantity = new_total_quantity_expr + .as_deref() + .map(|expr| { + self.eval_u64(ctx, expr, day, stock_state.as_deref(), None) + .and_then(|value| { + u32::try_from(value).map_err(|_| { + BacktestError::Execution(format!( + "modify_order total quantity exceeds u32 order_id={order_id} quantity={value}" + )) + }) + }) + }) + .transpose()?; + if new_total_quantity == Some(0) { + return Err(BacktestError::Execution(format!( + "modify_order total quantity must be positive order_id={order_id}" + ))); + } + let new_limit_price = new_limit_price_expr + .as_deref() + .map(|expr| self.eval_float(ctx, expr, day, stock_state.as_deref(), None)) + .transpose()?; + if new_limit_price.is_some_and(|value| !value.is_finite() || value <= 0.0) { + return Err(BacktestError::Execution(format!( + "modify_order limit price must be positive order_id={order_id}" + ))); + } + intents.push(OrderIntent::ModifyOrder { + order_id, + new_total_quantity, + new_limit_price, + reason: reason.clone(), + }); + } PlatformTradeAction::Universe { kind, symbols_expr, @@ -9571,6 +9662,7 @@ impl PlatformExprStrategy { action, PlatformTradeAction::Order { .. } | PlatformTradeAction::TargetPortfolioSmart { .. } + | PlatformTradeAction::Modify { .. } ) }) } @@ -31770,11 +31862,32 @@ mod tests { "has_open_orders && open_order_count == 1 && open_sell_qty == 200 && symbol_open_sell_qty == 200 && symbol_open_order_count == 1 && latest_open_order_status == \"pending\" && latest_open_order_unfilled_qty == 200 && latest_symbol_open_order_status == \"pending\" && latest_symbol_open_order_unfilled_qty == 200".to_string(), ), reason: "open_order_aware_entry".to_string(), + }, PlatformTradeAction::Modify { + symbol: Some("000001.SZ".to_string()), + order_id_expr: "latest_open_order_id".to_string(), + new_total_quantity_expr: Some("latest_open_order_unfilled_qty + 100".to_string()), + new_limit_price_expr: Some("10.3".to_string()), + when_expr: Some("latest_symbol_open_order_id == 42".to_string()), + reason: "reprice_open_order".to_string(), }]; let mut strategy = PlatformExprStrategy::new(cfg); let decision = strategy.on_day(&ctx).expect("platform decision"); - assert_eq!(decision.order_intents.len(), 1); + assert_eq!(decision.order_intents.len(), 2); + match &decision.order_intents[1] { + crate::strategy::OrderIntent::ModifyOrder { + order_id, + new_total_quantity, + new_limit_price, + reason, + } => { + assert_eq!(*order_id, 42); + assert_eq!(*new_total_quantity, Some(300)); + assert_eq!(*new_limit_price, Some(10.3)); + assert_eq!(reason, "reprice_open_order"); + } + other => panic!("unexpected modify intent: {other:?}"), + } } #[test] diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 633be0f..9c3d7bc 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -2455,6 +2455,42 @@ fn parse_platform_trade_action( when_expr, reason, }), + "modify_order" => { + let order_id_expr = action + .order_id_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + let new_total_quantity_expr = action + .quantity_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let new_limit_price_expr = action + .limit_price_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + if new_total_quantity_expr.is_none() && new_limit_price_expr.is_none() { + return None; + } + Some(PlatformTradeAction::Modify { + symbol: action + .symbol + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + order_id_expr, + new_total_quantity_expr, + new_limit_price_expr, + when_expr, + reason, + }) + } "update_universe" => Some(PlatformTradeAction::Universe { kind: PlatformUniverseActionKind::UpdateUniverse, symbols_expr: action diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 538a814..74cb752 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -89,7 +89,7 @@ pub trait Strategy { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct OpenOrderView { pub order_id: u64, pub symbol: String, @@ -1170,6 +1170,12 @@ pub enum OrderIntent { order_id: u64, reason: String, }, + ModifyOrder { + order_id: u64, + new_total_quantity: Option, + new_limit_price: Option, + reason: String, + }, CancelSymbol { symbol: String, reason: String, @@ -1257,6 +1263,7 @@ impl OrderIntent { if matches!( intent, Self::CancelOrder { .. } + | Self::ModifyOrder { .. } | Self::CancelSymbol { .. } | Self::CancelAll { .. } | Self::UpdateUniverse { .. } diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 7ea2dad..553fea5 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -265,13 +265,17 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓。".to_string(), }, ManualSection { - title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(), + title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(), detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForce:DAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), }, ManualSection { title: "order.time_in_force target runtime scope".to_string(), detail: "回测支持 DAY/IOC/FOK/GTC;paper/live 当前只支持 DAY/IOC/FOK。GTC 需要持久化跨交易日 parent/child 重挂账本和券商适配器能力,在该合同实现前只允许回测,paper/live 必须明确拒绝并禁止降级为 DAY。生成策略前必须按目标运行模式选择能力。".to_string(), }, + ManualSection { + title: "order.modify".to_string(), + detail: "回测中可用 order.modify(order_id, total_quantity=?, limit_price=?) 原位修改仍未完成的限价单。total_quantity 是新的总委托量而不是增量,不能低于已成交量;改价或增量会重置盘口队列优先级,减少总量且不改价保留优先级,同时保留 order_id、有效期、累计成交和费用状态。paper/live 在适配器提供持久且确认的 cancel-replace 合同前必须拒绝该动作,不得静默转换为撤单加新订单。".to_string(), + }, ManualSection { title: "when / unless / else".to_string(), detail: "条件块支持按日期、指数、仓位等动态切换规则。".to_string(), diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 870d9f0..6a76874 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -5443,6 +5443,332 @@ fn broker_gtc_partial_fills_preserve_cumulative_order_and_commission_state() { assert_eq!(day2_report.fill_events[0].commission, 0.0); } +#[test] +fn broker_modifies_gtc_limit_order_without_changing_order_identity() { + let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); + let data = two_day_limit_order_data(10.0, 9.7); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let created = broker + .execute( + day1, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000002.SZ".to_string(), + quantity: 300, + limit_price: 9.8, + reason: "gtc_modify_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Gtc), + ], + ..StrategyDecision::default() + }, + ) + .expect("create GTC order"); + let order_id = created.order_events[0].order_id.expect("order id"); + + let modified = broker + .execute( + day1, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![OrderIntent::ModifyOrder { + order_id, + new_total_quantity: Some(400), + new_limit_price: Some(9.9), + reason: "raise_gtc_order".to_string(), + }], + ..StrategyDecision::default() + }, + ) + .expect("modify GTC order"); + assert!(modified.fill_events.is_empty()); + assert!(modified.process_events.iter().any(|event| { + event.kind == ProcessEventKind::OrderPendingUpdate && event.order_id == Some(order_id) + })); + assert!(modified.process_events.iter().any(|event| { + event.kind == ProcessEventKind::OrderUpdatePass + && event.order_id == Some(order_id) + && event.detail.contains("queue_priority_reset=true") + })); + let update_event = modified + .order_events + .iter() + .find(|event| event.reason.contains("order updated")) + .expect("persistent update event"); + assert_eq!(update_event.order_id, Some(order_id)); + assert_eq!(update_event.requested_quantity, 400); + assert_eq!(update_event.filled_quantity, 0); + assert_eq!(update_event.status, OrderStatus::Pending); + + let amended = broker.open_order_views().pop().expect("amended order"); + assert_eq!(amended.order_id, order_id); + assert_eq!(amended.requested_quantity, 400); + assert_eq!(amended.remaining_quantity, 400); + assert_eq!(amended.limit_price, 9.9); + + let filled = broker + .execute(day2, &mut portfolio, &data, &StrategyDecision::default()) + .expect("fill amended GTC order"); + assert_eq!(filled.fill_events.len(), 1); + assert_eq!(filled.fill_events[0].order_id, Some(order_id)); + assert_eq!(filled.fill_events[0].quantity, 400); + assert_eq!(filled.order_events[0].requested_quantity, 400); + assert_eq!(filled.order_events[0].filled_quantity, 400); + assert_eq!(filled.order_events[0].status, OrderStatus::Filled); +} + +#[test] +fn broker_modifies_partially_filled_gtc_total_and_preserves_commission_state() { + let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); + let data = two_day_limit_order_data(10.0, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_volume_limit(true) + .with_volume_percent(0.001) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let first = broker + .execute( + day1, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000002.SZ".to_string(), + quantity: 300, + limit_price: 10.1, + reason: "partial_then_modify".to_string(), + } + .with_time_in_force(OrderTimeInForce::Gtc), + ], + ..StrategyDecision::default() + }, + ) + .expect("partial GTC fill"); + assert_eq!(first.fill_events[0].quantity, 100); + assert_eq!(first.fill_events[0].commission, 5.0); + let order_id = first.order_events[0].order_id.expect("order id"); + + let modified = broker + .execute( + day1, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![OrderIntent::ModifyOrder { + order_id, + new_total_quantity: Some(200), + new_limit_price: None, + reason: "reduce_total_after_partial_fill".to_string(), + }], + ..StrategyDecision::default() + }, + ) + .expect("reduce partially filled order total"); + let update_event = modified + .order_events + .iter() + .find(|event| event.reason.contains("order updated")) + .expect("update event"); + assert_eq!(update_event.requested_quantity, 200); + assert_eq!(update_event.filled_quantity, 100); + assert_eq!(update_event.status, OrderStatus::PartiallyFilled); + let amended = broker.open_order_views().pop().expect("amended remainder"); + assert_eq!(amended.requested_quantity, 200); + assert_eq!(amended.filled_quantity, 100); + assert_eq!(amended.remaining_quantity, 100); + + let final_fill = broker + .execute(day2, &mut portfolio, &data, &StrategyDecision::default()) + .expect("complete amended order"); + assert_eq!(final_fill.fill_events.len(), 1); + assert_eq!(final_fill.fill_events[0].quantity, 100); + assert_eq!(final_fill.fill_events[0].commission, 0.0); + assert_eq!(final_fill.order_events[0].requested_quantity, 200); + assert_eq!(final_fill.order_events[0].filled_quantity, 200); + assert_eq!(final_fill.order_events[0].status, OrderStatus::Filled); +} + +#[test] +fn broker_rejected_modify_has_zero_side_effects() { + let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); + let data = two_day_limit_order_data(10.0, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_volume_limit(true) + .with_volume_percent(0.001) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + let created = broker + .execute( + day1, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000002.SZ".to_string(), + quantity: 200, + limit_price: 10.1, + reason: "reject_modify_source".to_string(), + } + .with_time_in_force(OrderTimeInForce::Gtc), + ], + ..StrategyDecision::default() + }, + ) + .expect("create partially filled GTC order"); + let order_id = created.order_events[0].order_id.expect("order id"); + let before = broker.open_order_views(); + let cash_before = portfolio.cash(); + + let rejected = broker + .execute( + day1, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![OrderIntent::ModifyOrder { + order_id, + new_total_quantity: Some(100), + new_limit_price: Some(10.105), + reason: "invalid_modify".to_string(), + }], + ..StrategyDecision::default() + }, + ) + .expect("invalid modify is a business rejection"); + assert!(rejected.fill_events.is_empty()); + assert!(rejected.process_events.iter().any(|event| { + event.kind == ProcessEventKind::OrderUpdateReject + && event.order_id == Some(order_id) + && event.detail.contains("must_exceed_filled_quantity") + })); + assert_eq!(broker.open_order_views(), before); + assert_eq!(portfolio.cash(), cash_before); + + let final_fill = broker + .execute(day2, &mut portfolio, &data, &StrategyDecision::default()) + .expect("original order remains executable"); + assert_eq!(final_fill.fill_events[0].order_id, Some(order_id)); + assert_eq!(final_fill.fill_events[0].commission, 0.0); +} + +#[test] +fn broker_accepted_modify_resets_queue_priority_but_reduction_preserves_it() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = two_day_limit_order_data(10.0, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut portfolio = PortfolioState::new(1_000_000.0); + let create = |reason: &str| StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000002.SZ".to_string(), + quantity: 300, + limit_price: 9.8, + reason: reason.to_string(), + } + .with_time_in_force(OrderTimeInForce::Gtc), + ], + ..StrategyDecision::default() + }; + broker + .execute(date, &mut portfolio, &data, &create("first")) + .unwrap(); + broker + .execute(date, &mut portfolio, &data, &create("second")) + .unwrap(); + let initial_ids = broker + .open_order_views() + .iter() + .map(|order| order.order_id) + .collect::>(); + assert_eq!(initial_ids.len(), 2); + + let reduced = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![OrderIntent::ModifyOrder { + order_id: initial_ids[0], + new_total_quantity: Some(200), + new_limit_price: None, + reason: "reduce_without_requeue".to_string(), + }], + ..StrategyDecision::default() + }, + ) + .unwrap(); + assert!(reduced.process_events.iter().any(|event| { + event.kind == ProcessEventKind::OrderUpdatePass + && event.detail.contains("queue_priority_reset=false") + })); + assert_eq!( + broker + .open_order_views() + .iter() + .map(|order| order.order_id) + .collect::>(), + initial_ids + ); + + let repriced = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![OrderIntent::ModifyOrder { + order_id: initial_ids[0], + new_total_quantity: None, + new_limit_price: Some(9.9), + reason: "reprice_and_requeue".to_string(), + }], + ..StrategyDecision::default() + }, + ) + .unwrap(); + assert!(repriced.process_events.iter().any(|event| { + event.kind == ProcessEventKind::OrderUpdatePass + && event.detail.contains("queue_priority_reset=true") + })); + assert_eq!( + broker + .open_order_views() + .iter() + .map(|order| order.order_id) + .collect::>(), + vec![initial_ids[1], initial_ids[0]] + ); +} + #[test] fn broker_rejects_gtc_for_market_order() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); From 00ec7a6d5573d14ee57303373867702cc898727c Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 27 Aug 2026 08:37:12 +0800 Subject: [PATCH 25/26] =?UTF-8?q?=E8=AE=A9=E6=98=BE=E5=BC=8F=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E7=BB=A7=E6=89=BF=E8=BF=90=E8=A1=8C=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_strategy_spec.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 9c3d7bc..4d42d5c 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -1980,6 +1980,12 @@ pub fn platform_expr_config_from_spec( explicit_actions.push(parsed); } cfg.explicit_actions = explicit_actions; + // An explicit action follows the strategy's declared schedule when + // it does not have a separate trading schedule. Otherwise the + // action can be parsed successfully but never be dispatched. + if cfg.explicit_action_schedule.is_none() && !cfg.explicit_actions.is_empty() { + cfg.explicit_action_schedule = cfg.rebalance_schedule.clone(); + } } } else if let Some(engine) = spec.engine_config.as_ref() { if let Some(dynamic_range) = engine.dynamic_range.as_ref() { @@ -2765,6 +2771,38 @@ mod tests { assert_eq!(cfg.explicit_actions.len(), 1); } + #[test] + fn explicit_actions_inherit_top_level_runtime_schedule() { + let spec = serde_json::json!({ + "runtimeExpressions": { + "schedule": {"frequency": "daily", "time": "15:00"}, + "trading": { + "rotationEnabled": false, + "actions": [{ + "kind": "modify_order", + "orderIdExpr": "42", + "quantityExpr": "200", + "limitPriceExpr": "10.25", + "symbol": "000001.SZ", + "reason": "modify_test" + }] + } + } + }); + + let cfg = platform_expr_config_from_value("modify", "000300.SH", &spec) + .expect("explicit action config"); + + assert_eq!(cfg.explicit_actions.len(), 1); + assert_eq!( + cfg.explicit_action_schedule, + Some(PlatformRebalanceSchedule { + frequency: PlatformScheduleFrequency::Daily, + time_rule: Some(ScheduleTimeRule::physical_time(15, 0)), + }) + ); + } + #[test] fn parses_typed_time_in_force_for_explicit_orders() { let spec = serde_json::json!({ From 9db2a9f79cfd3cec9fd66e98831f76cffc0ce9b7 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 27 Aug 2026 09:04:48 +0800 Subject: [PATCH 26/26] =?UTF-8?q?=E5=88=86=E7=A6=BB=E8=BF=87=E7=A8=8B?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=88=86=E5=8F=91=E4=B8=8E=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E4=BF=9D=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/engine.rs | 46 ++++++++++++++++++++++++-- crates/fidc-core/src/events.rs | 42 ++++++++++++++++++++++- crates/fidc-core/src/lib.rs | 1 + crates/fidc-core/tests/engine_hooks.rs | 41 +++++++++++++++++++++-- 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 6f61bda..935df29 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -71,6 +71,23 @@ impl Default for FuturesValidationConfig { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessEventRetention { + /// Retain every phase and business event in the returned result. + All, + /// Retain only lifecycle events useful for a durable business audit. + Business, + /// Dispatch events to listeners and the strategy, but do not retain them + /// in the returned result. + None, +} + +impl Default for ProcessEventRetention { + fn default() -> Self { + Self::All + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DailyEquityPoint { #[serde(with = "date_format")] @@ -425,6 +442,7 @@ pub struct BacktestEngine { cash_dividends_enabled: bool, cash_dividend_adjusts_cost_basis: bool, process_event_bus: ProcessEventBus, + process_event_retention: ProcessEventRetention, dynamic_universe: Option>, subscriptions: BTreeSet, futures_account: Option, @@ -455,6 +473,7 @@ impl BacktestEngine { cash_dividends_enabled: true, cash_dividend_adjusts_cost_basis: true, process_event_bus: ProcessEventBus::new(), + process_event_retention: ProcessEventRetention::All, dynamic_universe: None, subscriptions: BTreeSet::new(), futures_account: None, @@ -488,6 +507,11 @@ impl BacktestEngine { self } + pub fn with_process_event_retention(mut self, retention: ProcessEventRetention) -> Self { + self.process_event_retention = retention; + self + } + pub fn with_cash_dividends(mut self, enabled: bool) -> Self { self.cash_dividends_enabled = enabled; self @@ -2118,7 +2142,7 @@ where let holding_count = holdings_for_day.len(); result.daily_holdings.extend(holdings_for_day); let progress_process_start = result.process_events.len(); - result.process_events.append(&mut process_events); + self.retain_process_events(&mut result.process_events, &mut process_events); let aggregate_cash = self.aggregate_cash(&portfolio); let aggregate_market_value = self.aggregate_market_value(&portfolio); let aggregate_total_equity = self.aggregate_total_equity(&portfolio); @@ -3193,7 +3217,7 @@ where let holding_count = holdings_for_day.len(); result.daily_holdings.extend(holdings_for_day); let progress_process_start = result.process_events.len(); - result.process_events.append(&mut process_events); + self.retain_process_events(&mut result.process_events, &mut process_events); let aggregate_cash = self.aggregate_cash(&portfolio); let aggregate_market_value = self.aggregate_market_value(&portfolio); let aggregate_total_equity = self.aggregate_total_equity(&portfolio); @@ -3280,7 +3304,23 @@ where result.fills.append(&mut report.fill_events); result.position_events.append(&mut report.position_events); result.account_events.append(&mut report.account_events); - result.process_events.append(&mut report.process_events); + self.retain_process_events(&mut result.process_events, &mut report.process_events); + } + + fn retain_process_events( + &self, + target: &mut Vec, + incoming: &mut Vec, + ) { + match self.process_event_retention { + ProcessEventRetention::All => target.append(incoming), + ProcessEventRetention::Business => target.extend( + incoming + .drain(..) + .filter(|event| event.kind.is_business_lifecycle()), + ), + ProcessEventRetention::None => incoming.clear(), + } } fn apply_corporate_actions( diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index c53d6c2..8a82765 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -364,6 +364,38 @@ impl ProcessEventKind { Self::AccountManagementFee => "account_management_fee", } } + + /// Returns whether the event is part of the durable business lifecycle + /// audit. Phase boundary events are useful during interactive debugging, + /// but retaining every minute phase marker for a long run is unnecessary. + pub fn is_business_lifecycle(&self) -> bool { + matches!( + *self, + Self::PreScheduled + | Self::PostScheduled + | Self::PreOnDay + | Self::OnDay + | Self::PostOnDay + | Self::OrderPendingNew + | Self::OrderCreationPass + | Self::OrderCreationReject + | Self::OrderPendingCancel + | Self::OrderCancellationPass + | Self::OrderCancellationReject + | Self::OrderPendingUpdate + | Self::OrderUpdatePass + | Self::OrderUpdateReject + | Self::OrderUnsolicitedUpdate + | Self::Trade + | Self::UniverseUpdated + | Self::UniverseSubscribed + | Self::UniverseUnsubscribed + | Self::AccountDepositWithdraw + | Self::AccountFinanceRepay + | Self::AccountManagementFee + | Self::Settlement + ) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -384,7 +416,7 @@ pub struct ProcessEvent { mod tests { use chrono::{NaiveDate, NaiveDateTime}; - use super::{FillEvent, OrderEvent, OrderSide, OrderStatus}; + use super::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEventKind}; fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent { OrderEvent { @@ -484,4 +516,12 @@ mod tests { "2025-01-02 10:18:03" ); } + + #[test] + fn process_event_business_lifecycle_filter_keeps_audit_events_only() { + assert!(ProcessEventKind::OrderUpdateReject.is_business_lifecycle()); + assert!(ProcessEventKind::Settlement.is_business_lifecycle()); + assert!(!ProcessEventKind::PreMinute.is_business_lifecycle()); + assert!(!ProcessEventKind::PostBar.is_business_lifecycle()); + } } diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 9cdd5da..1f82d36 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -39,6 +39,7 @@ pub use engine::{ AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder, BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig, + ProcessEventRetention, }; pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus}; pub use events::{ diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 5e1d104..520da17 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -14,8 +14,8 @@ use fidc_core::{ IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, NumericFactorMap, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformTradeAction, PortfolioState, PriceField, ProcessEvent, - ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, - StrategyContext, StrategyDecision, + ProcessEventBus, ProcessEventKind, ProcessEventRetention, ScheduleRule, ScheduleStage, + ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -1199,6 +1199,7 @@ fn engine_runs_strategy_hooks_in_daily_order() { ) .expect("dataset"); + let compact_data = data.clone(); let log = Rc::new(RefCell::new(Vec::new())); let strategy = HookProbeStrategy { log: log.clone() }; let broker = BrokerSimulator::new_with_execution_price( @@ -1238,6 +1239,42 @@ fn engine_runs_strategy_hooks_in_daily_order() { ] ); assert_eq!(result.process_events.len(), 36); + + let compact_strategy = HookProbeStrategy { + log: Rc::new(RefCell::new(Vec::new())), + }; + let compact_broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut compact_engine = BacktestEngine::new( + compact_data, + compact_strategy, + compact_broker, + BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date1), + end_date: Some(date2), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ) + .with_process_event_retention(ProcessEventRetention::Business); + let compact_result = compact_engine.run().expect("compact backtest succeeds"); + assert!(compact_result + .process_events + .iter() + .all(|event| event.kind.is_business_lifecycle())); + assert!(compact_result + .process_events + .iter() + .any(|event| event.kind == ProcessEventKind::OnDay)); + assert!(!compact_result + .process_events + .iter() + .any(|event| event.kind == ProcessEventKind::PreBeforeTrading)); assert_eq!( result.process_events[..18] .iter()