From 4d3a9e0e5b1e521a4a0d327a1ec5a56aa2ea2af5 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 12 Sep 2026 06:32:10 +0800 Subject: [PATCH 1/5] fix: separate historical session capacity audits from execution sizing --- crates/fidc-core/src/broker.rs | 94 ++++++++----------- crates/fidc-core/src/engine.rs | 14 +++ crates/fidc-core/src/events.rs | 3 + crates/fidc-core/src/execution_capacity.rs | 35 +++++++ .../fidc-core/src/platform_expr_strategy.rs | 9 +- .../fidc-core/src/platform_strategy_spec.rs | 16 ++++ 6 files changed, 112 insertions(+), 59 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 82ff94b..33b1cd0 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -7,7 +7,7 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime}; use crate::cost::CostModel; use crate::data::{DataSet, IntradayExecutionQuote, PriceField}; use crate::engine::BacktestError; -use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeObservation, VolumeObservationKind}; +use crate::execution_capacity::{CapacityAuditSummary, CapacityError, ParticipationRate, SessionCapacityAudit, VolumeCapacityMode, VolumeObservation, VolumeObservationKind}; use crate::execution_schedule::TwapSchedule; use crate::events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, @@ -423,6 +423,7 @@ pub struct BrokerSimulator { volume_percent: f64, volume_rate: Result, volume_limit: bool, + volume_capacity_mode: VolumeCapacityMode, inactive_limit: bool, liquidity_limit: bool, strict_value_budget: bool, @@ -459,6 +460,7 @@ impl BrokerSimulator { volume_percent: 0.25, volume_rate: ParticipationRate::new(0.25), volume_limit: true, + volume_capacity_mode: VolumeCapacityMode::ExecutionObservation, inactive_limit: true, liquidity_limit: true, strict_value_budget: true, @@ -499,6 +501,7 @@ impl BrokerSimulator { volume_percent: 0.25, volume_rate: ParticipationRate::new(0.25), volume_limit: true, + volume_capacity_mode: VolumeCapacityMode::ExecutionObservation, inactive_limit: true, liquidity_limit: true, strict_value_budget: true, @@ -529,6 +532,29 @@ impl BrokerSimulator { self } + pub fn with_volume_capacity_mode(mut self, mode: VolumeCapacityMode) -> Self { + self.volume_capacity_mode = mode; + self + } + + pub fn capacity_audit_summary(&self) -> CapacityAuditSummary { + CapacityAuditSummary { mode: self.volume_capacity_mode, enabled: self.volume_limit, + participation_rate: self.volume_percent, ..Default::default() } + } + + pub fn audit_completed_session_capacity(&self, date: NaiveDate, data: &DataSet) -> Result, BacktestError> { + if !self.volume_limit || self.volume_capacity_mode != VolumeCapacityMode::SessionCapacityAudit { + return Ok(Vec::new()); + } + let session = self.execution_session.borrow(); + if session.date != Some(date) { return Ok(Vec::new()); } + let rate = self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?; + session.intraday_turnover.iter().filter(|(_, quantity)| **quantity > 0).map(|(symbol, quantity)| { + let market = data.market(date, symbol).ok_or_else(|| BacktestError::MissingPrice { date, symbol: symbol.clone(), field: "session capacity audit" })?; + Ok(SessionCapacityAudit::new(date, symbol.clone(), u64::from(*quantity), market.volume, rate)) + }).collect() + } + pub fn with_inactive_limit(mut self, enabled: bool) -> Self { self.inactive_limit = enabled; self @@ -1458,6 +1484,8 @@ where ) -> Result { if self.volume_limit { self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?; + self.volume_capacity_mode.validate(true, self.matching_type_uses_intraday_quotes()) + .map_err(|error| BacktestError::Execution(error.to_string()))?; } let mut session = std::mem::take(&mut *self.execution_session.borrow_mut()); session.activate(date); @@ -7269,57 +7297,13 @@ where return Ok(0); } - let uses_intraday_quantity = self.matching_type_uses_intraday_quotes(); - let available_market_volume = if uses_intraday_quantity { - snapshot.minute_volume - } else { - snapshot.volume - }; - let no_volume_reason = if uses_intraday_quantity { - "minute no volume" - } else { - "daily no volume" - }; - let volume_limit_reason = if uses_intraday_quantity { - "minute volume limit" - } else { - "daily volume limit" - }; - - let mut max_fill = requested_qty; - - if self.inactive_limit - && (snapshot.paused || (!uses_intraday_quantity && available_market_volume == 0)) - { - return Err(if snapshot.paused { - "paused".to_string() - } else { - no_volume_reason.to_string() - }); - } - - if uses_intraday_quantity { - return Ok(max_fill); - } - - if self.volume_limit { - let raw_limit = self.volume_rate.map_err(|error| error.to_string())? - .remaining(available_market_volume, u64::from(consumed_turnover), requested_qty); - if raw_limit == 0 { - return Err(volume_limit_reason.to_string()); - } - let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell { - raw_limit - } else { - self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size) - }; - if volume_limited == 0 { - return Err(volume_limit_reason.to_string()); - } - max_fill = max_fill.min(volume_limited); - } - - Ok(max_fill) + let _ = (side, minimum_order_quantity, order_step_size, consumed_turnover, allow_odd_lot_sell); + if self.inactive_limit && snapshot.paused { return Err("paused".into()); } + self.volume_capacity_mode.validate(self.volume_limit, self.matching_type_uses_intraday_quotes()) + .map_err(|error| error.to_string())?; + // Per-observation limits are applied to each actual quote below. The + // session-audit model must never size this order from the day's total. + Ok(requested_qty) } fn price_satisfies_limit( @@ -7723,7 +7707,7 @@ where } else { remaining_qty }; - if self.volume_limit { + if self.volume_limit && self.volume_capacity_mode.limits_execution_quantity() { let consumed = execution_ledger .volume_consumed(symbol, quote.timestamp) .saturating_add( @@ -7871,7 +7855,7 @@ where .saturating_add(take_qty) .min(state.displayed_quantity); } - if self.volume_limit { + if self.volume_limit && self.volume_capacity_mode.limits_execution_quantity() { let consumed = pending_volume_consumption .entry(quote.timestamp) .or_default(); @@ -7885,7 +7869,7 @@ where depth_price_bits, displayed_quantity, consume_depth, - consume_volume: self.volume_limit, + consume_volume: self.volume_limit && self.volume_capacity_mode.limits_execution_quantity(), quantity: take_qty, }); } diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 843d0a7..4f60b97 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -122,6 +122,7 @@ impl DailyEquityPoint { #[derive(Debug, Clone)] pub struct BacktestResult { + pub capacity_audit: crate::execution_capacity::CapacityAuditSummary, pub strategy_name: String, pub equity_curve: Vec, pub benchmark_series: Vec, @@ -280,6 +281,7 @@ pub struct AnalyzerRiskSummary { #[derive(Debug, Clone, Serialize)] pub struct AnalyzerReport { + pub capacity_audit: crate::execution_capacity::CapacityAuditSummary, pub strategy_name: String, pub trades: Vec, pub positions: Vec, @@ -294,6 +296,7 @@ pub struct AnalyzerReport { impl BacktestResult { pub fn analyzer_report(&self) -> AnalyzerReport { AnalyzerReport { + capacity_audit: self.capacity_audit.clone(), strategy_name: self.strategy_name.clone(), trades: self .fills @@ -2102,6 +2105,7 @@ where .map(|(execution_date, _)| *execution_date) .collect::>(); let mut result = BacktestResult { + capacity_audit: self.broker.capacity_audit_summary(), strategy_name: self.strategy.name().to_string(), benchmark_series: self .data @@ -3415,6 +3419,16 @@ where execution_date, ); let daily_fill_count = result.fills.len() - day_fill_start; + for audit in self.broker.audit_completed_session_capacity(execution_date, &self.data)? { + result.capacity_audit.observe(&audit); + // Keep every audit in the durable event store, independent of + // debug phase retention. It never changes earlier executions. + result.process_events.push(ProcessEvent { + date: execution_date, kind: ProcessEventKind::SessionCapacityAudit, + order_id: None, symbol: Some(audit.symbol.clone()), side: None, + detail: serde_json::to_string(&audit).map_err(|error| BacktestError::Execution(error.to_string()))?, + }); + } let daily_order_count = result.order_events.len() - day_order_start; let execution_risk_decisions = risk_decisions_from_order_events(&result.order_events[day_order_start..]); diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 9b5978a..eab1cff 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -317,6 +317,7 @@ pub enum ProcessEventKind { AccountDepositWithdraw, AccountFinanceRepay, AccountManagementFee, + SessionCapacityAudit, } impl ProcessEventKind { @@ -362,6 +363,7 @@ impl ProcessEventKind { Self::AccountDepositWithdraw => "account_deposit_withdraw", Self::AccountFinanceRepay => "account_finance_repay", Self::AccountManagementFee => "account_management_fee", + Self::SessionCapacityAudit => "session_capacity_audit", } } @@ -393,6 +395,7 @@ impl ProcessEventKind { | Self::AccountDepositWithdraw | Self::AccountFinanceRepay | Self::AccountManagementFee + | Self::SessionCapacityAudit | Self::Settlement ) } diff --git a/crates/fidc-core/src/execution_capacity.rs b/crates/fidc-core/src/execution_capacity.rs index aa0c955..b273a7c 100644 --- a/crates/fidc-core/src/execution_capacity.rs +++ b/crates/fidc-core/src/execution_capacity.rs @@ -12,6 +12,19 @@ pub enum VolumeCapacityMode { SessionCapacityAudit, } +impl VolumeCapacityMode { + pub fn validate(self, enabled: bool, has_execution_observations: bool) -> Result<(), CapacityError> { + if !enabled { return Ok(()); } + match self { + Self::ExecutionObservation if !has_execution_observations => Err(CapacityError::MissingObservation), + Self::CompletedBar => Err(CapacityError::MissingCompletedBar), + _ => Ok(()), + } + } + + pub fn limits_execution_quantity(self) -> bool { self != Self::SessionCapacityAudit } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] pub enum CapacityError { #[error("execution capacity ratio must be finite and in (0, 1]")] @@ -26,6 +39,28 @@ pub enum CapacityError { WrongSession, #[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")] MissingObservation, + #[error("completed_bar capacity requires declared bar end and availability; an undated daily total is not a completed observation")] + MissingCompletedBar, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CapacityAuditSummary { + pub mode: VolumeCapacityMode, + pub enabled: bool, + pub participation_rate: f64, + pub audited_symbol_sessions: usize, + pub failed_symbol_sessions: usize, + pub audit_passed: Option, + pub execution_time_capacity_proven: bool, +} + +impl CapacityAuditSummary { + pub fn observe(&mut self, audit: &SessionCapacityAudit) { + self.audited_symbol_sessions += 1; + self.failed_symbol_sessions += usize::from(!audit.passed); + self.audit_passed = Some(self.failed_symbol_sessions == 0); + } } /// Decimal semantics of the frozen JSON rate, evaluated without a float product. diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index c2ed853..8bec4bb 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -17,7 +17,7 @@ use crate::data::{ decision_market_cap_bn, }; use crate::engine::BacktestError; -use crate::execution_capacity::{CapacityError, ParticipationRate}; +use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeCapacityMode}; use crate::events::{OrderSide, ProcessEvent, ProcessEventKind}; use crate::fixed_point::FixedMoney; use crate::futures::{ @@ -689,6 +689,7 @@ pub struct PlatformExprStrategyConfig { pub rebalance_cash_mode: RebalanceCashMode, pub sell_then_buy_delay_slippage_rate: f64, pub risk_config: FidcRiskControlConfig, + pub volume_capacity_mode: VolumeCapacityMode, pub slippage_model: SlippageModel, pub matching_type: MatchingType, pub quote_quantity_limit: bool, @@ -777,6 +778,7 @@ impl PlatformExprStrategyConfig { rebalance_cash_mode: RebalanceCashMode::default(), sell_then_buy_delay_slippage_rate: 0.0, risk_config: FidcRiskControlConfig::default(), + volume_capacity_mode: VolumeCapacityMode::ExecutionObservation, slippage_model: SlippageModel::None, matching_type: MatchingType::CurrentBarClose, quote_quantity_limit: true, @@ -3201,11 +3203,10 @@ impl PlatformExprStrategy { } } - if constraints.volume_limit_enabled { + if constraints.volume_limit_enabled && self.config.volume_capacity_mode.limits_execution_quantity() { let volume_basis = match quote { Some(quote) => quote.volume_delta, - None if market.minute_volume > 0 => market.minute_volume, - None => market.volume, + None => return Err(BacktestError::Execution(CapacityError::MissingObservation.to_string())), }; if volume_basis == 0 { return Ok(None); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 733bb4a..ae1b060 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -91,6 +91,8 @@ pub struct StrategyRebalanceSpec { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyExecutionSpec { + #[serde(default, alias = "volume_capacity_mode")] + pub volume_capacity_mode: Option, #[serde(default)] pub frequency: Option, #[serde(default, alias = "matching_type")] @@ -164,9 +166,22 @@ pub struct StrategyExecutionSpec { pub sell_then_buy_delay_slippage_rate: Option, } +impl StrategyRuntimeSpec { + pub fn volume_capacity_mode(&self) -> Result { + let engine = self.engine_config.as_ref().and_then(|config| config.volume_capacity_mode); + let execution = self.execution.as_ref().and_then(|config| config.volume_capacity_mode); + if engine.zip(execution).is_some_and(|(a, b)| a != b) { + return Err("conflicting engine/execution volumeCapacityMode".into()); + } + Ok(execution.or(engine).unwrap_or_default()) + } +} + #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyEngineConfig { + #[serde(default, alias = "volume_capacity_mode")] + pub volume_capacity_mode: Option, #[serde(default)] pub frequency: Option, #[serde(default, alias = "template_id")] @@ -1822,6 +1837,7 @@ pub fn platform_expr_config_from_spec( strategy_spec: Option<&StrategyRuntimeSpec>, ) -> Result { let mut cfg = PlatformExprStrategyConfig::generic(); + cfg.volume_capacity_mode = strategy_spec.map(StrategyRuntimeSpec::volume_capacity_mode).transpose()?.unwrap_or_default(); cfg.strategy_name = strategy_id.to_string(); if !signal_symbol.trim().is_empty() { cfg.signal_symbol = signal_symbol.trim().to_string(); From 053f880e3432b885c6d94c1b609b55e3f1010fc9 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 12 Sep 2026 06:39:34 +0800 Subject: [PATCH 2/5] test: declare historical audit assumptions in non-liquidity fixtures --- crates/fidc-core/src/broker.rs | 96 +++++++++++-------- .../fidc-core/src/platform_expr_strategy.rs | 26 +++++ .../tests/automatic_trade_protection.rs | 4 +- crates/fidc-core/tests/corporate_actions.rs | 4 +- .../fidc-core/tests/decision_quote_preload.rs | 10 +- crates/fidc-core/tests/delisting.rs | 4 +- crates/fidc-core/tests/engine_hooks.rs | 58 +++++------ 7 files changed, 124 insertions(+), 78 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 33b1cd0..1fab28a 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1484,8 +1484,6 @@ where ) -> Result { if self.volume_limit { self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?; - self.volume_capacity_mode.validate(true, self.matching_type_uses_intraday_quotes()) - .map_err(|error| BacktestError::Execution(error.to_string()))?; } let mut session = std::mem::take(&mut *self.execution_session.borrow_mut()); session.activate(date); @@ -4252,12 +4250,8 @@ where .saturating_sub(self.reserved_open_sell_quantity(symbol, None)); match self.market_fillable_quantity( snapshot, - OrderSide::Sell, sellable.min(current_qty), - minimum_order_quantity, - order_step_size, - 0, - sellable >= current_qty, + false, ) { Ok(quantity) => { let quantity = quantity.min(sellable).min(current_qty); @@ -4300,11 +4294,7 @@ where } match self.market_fillable_quantity( snapshot, - OrderSide::Buy, u32::MAX, - minimum_order_quantity, - order_step_size, - 0, false, ) { Ok(quantity) => { @@ -4573,14 +4563,12 @@ where } else { None }; + self.volume_capacity_mode.validate(self.volume_limit, algo_request.is_some() || self.matching_type_uses_intraday_quotes()) + .map_err(|error| BacktestError::Execution(error.to_string()))?; let market_limited_qty = self.market_fillable_quantity( snapshot, - OrderSide::Sell, requested_qty.min(sellable), - self.minimum_order_quantity(data, symbol), - self.order_step_size(data, symbol), - *intraday_turnover.get(symbol).unwrap_or(&0), - requested_qty >= position.quantity && sellable >= position.quantity, + algo_request.is_some(), ); let fillable_qty = match market_limited_qty { Ok(quantity) => { @@ -6382,14 +6370,12 @@ where } let mut partial_fill_reason = None; + self.volume_capacity_mode.validate(self.volume_limit, algo_request.is_some() || self.matching_type_uses_intraday_quotes()) + .map_err(|error| BacktestError::Execution(error.to_string()))?; let market_limited_qty = self.market_fillable_quantity( snapshot, - OrderSide::Buy, requested_qty, - self.minimum_order_quantity(data, symbol), - self.order_step_size(data, symbol), - *intraday_turnover.get(symbol).unwrap_or(&0), - false, + algo_request.is_some(), ); let constrained_qty = match market_limited_qty { Ok(quantity) => { @@ -7286,20 +7272,15 @@ where fn market_fillable_quantity( &self, snapshot: &crate::data::DailyMarketSnapshot, - side: OrderSide, requested_qty: u32, - minimum_order_quantity: u32, - order_step_size: u32, - consumed_turnover: u32, - allow_odd_lot_sell: bool, + algorithmic_order: bool, ) -> Result { if requested_qty == 0 { return Ok(0); } - let _ = (side, minimum_order_quantity, order_step_size, consumed_turnover, allow_odd_lot_sell); if self.inactive_limit && snapshot.paused { return Err("paused".into()); } - self.volume_capacity_mode.validate(self.volume_limit, self.matching_type_uses_intraday_quotes()) + self.volume_capacity_mode.validate(self.volume_limit, algorithmic_order || self.matching_type_uses_intraday_quotes()) .map_err(|error| error.to_string())?; // Per-observation limits are applied to each actual quote below. The // session-audit model must never size this order from the day's total. @@ -9412,7 +9393,42 @@ mod tests { } #[test] - fn current_bar_close_volume_limit_uses_daily_volume_when_minute_volume_missing() { + fn daily_session_volume_changes_only_audit_not_opening_fills() { + use crate::execution_capacity::VolumeCapacityMode; + let run = |volume: u64, mode: VolumeCapacityMode| { + let mut market = limit_test_snapshot(); + market.volume = volume; + let date = market.date; + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], vec![market], vec![], + vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()], vec![], vec![], + ).unwrap(); + let mut portfolio = PortfolioState::new(100_000.0); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_capacity_mode(mode).with_liquidity_limit(false); + let decision = StrategyDecision { order_intents: vec![OrderIntent::Shares { + symbol: "000001.SZ".into(), quantity: 1_000, reason: "capacity_test".into(), + }], ..StrategyDecision::default() }; + let before = portfolio.cash(); + let outcome = broker.execute(date, &mut portfolio, &data, &decision); + if outcome.is_err() { assert_eq!(portfolio.cash(), before); } + let audit = broker.audit_completed_session_capacity(date, &data).unwrap(); + (outcome, portfolio.cash(), audit) + }; + let (strict, _, _) = run(1_000_000, VolumeCapacityMode::ExecutionObservation); + assert!(strict.unwrap_err().to_string().contains("execution-time capacity is missing")); + let (a, cash_a, audit_a) = run(100, VolumeCapacityMode::SessionCapacityAudit); + let (b, cash_b, audit_b) = run(1_000_000, VolumeCapacityMode::SessionCapacityAudit); + let a = a.unwrap(); let b = b.unwrap(); + assert_eq!(a.fill_events.len(), 1); + assert_eq!(serde_json::to_value(&a.fill_events).unwrap(), serde_json::to_value(&b.fill_events).unwrap()); + assert_eq!(cash_a, cash_b); + assert_eq!(audit_a[0].filled_shares, 1_000); + assert!(!audit_a[0].passed); assert!(audit_b[0].passed); + } + + #[test] + fn daily_capacity_requires_a_timed_observation_instead_of_falling_back_to_total_volume() { let mut snapshot = limit_test_snapshot(); snapshot.minute_volume = 0; snapshot.volume = 1_000_000; @@ -9428,13 +9444,13 @@ mod tests { .with_liquidity_limit(true); let fillable = - broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); + broker.market_fillable_quantity(&snapshot, 5_000, false); - assert_eq!(fillable, Ok(5_000)); + assert!(fillable.unwrap_err().contains("daily session volume cannot size an earlier fill")); } #[test] - fn volume_limit_uses_floor_for_odd_lot_sell() { + fn session_capacity_audit_never_caps_an_early_odd_lot_sell() { let mut snapshot = limit_test_snapshot(); snapshot.minute_volume = 0; snapshot.volume = 3; @@ -9444,18 +9460,19 @@ mod tests { PriceField::Close, ) .with_matching_type(MatchingType::CurrentBarClose) + .with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_volume_limit(true) .with_volume_percent(0.5) .with_liquidity_limit(false); let fillable = - broker.market_fillable_quantity(&snapshot, OrderSide::Sell, 10, 100, 100, 0, true); + broker.market_fillable_quantity(&snapshot, 10, false); - assert_eq!(fillable, Ok(1)); + assert_eq!(fillable, Ok(10)); } #[test] - fn current_bar_close_volume_limit_rejects_daily_zero_volume() { + fn session_audit_does_not_infer_an_opening_suspension_from_future_zero_volume() { let mut snapshot = limit_test_snapshot(); snapshot.minute_volume = 0; snapshot.volume = 0; @@ -9465,13 +9482,16 @@ mod tests { PriceField::Close, ) .with_matching_type(MatchingType::CurrentBarClose) + .with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_volume_limit(true) .with_liquidity_limit(false); let fillable = - broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); + broker.market_fillable_quantity(&snapshot, 5_000, false); - assert_eq!(fillable, Err("daily no volume".to_string())); + assert_eq!(fillable, Ok(5_000)); + snapshot.paused = true; + assert_eq!(broker.market_fillable_quantity(&snapshot, 5_000, false), Err("paused".into())); } #[test] @@ -9492,7 +9512,7 @@ mod tests { .with_liquidity_limit(false); let fillable = - broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); + broker.market_fillable_quantity(&snapshot, 5_000, false); assert_eq!(fillable, Ok(5_000)); } diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 8bec4bb..06c520f 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -14527,6 +14527,7 @@ mod tests { active_datetime: None, order_events: &[], fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = symbol.to_string(); cfg.max_positions = 1; cfg.refresh_rate = 1; @@ -14560,6 +14561,7 @@ mod tests { active_datetime: None, order_events: &[], fills: &[], }; let mut cfg = PlatformExprStrategyConfig::generic(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = symbol.into(); cfg.stock_filter_expr = "close > 0".into(); cfg.hold_until_exit_enabled = true; @@ -15175,6 +15177,7 @@ mod tests { } let data = DataSet::from_components(parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks).unwrap(); let mut config = PlatformExprStrategyConfig::generic(); + config.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; config.universe_include = Some(BTreeSet::from(["000001.SZ".to_owned()])); config.signal_symbol = "000001.SZ".to_owned(); config.benchmark_symbol = "000852.SH".to_owned(); @@ -15419,6 +15422,7 @@ mod tests { fills: &[], }; let mut config = PlatformExprStrategyConfig::microcap_rotation(); + config.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; config.signal_symbol = symbol.to_string(); config.refresh_rate = 1; config.max_positions = 1; @@ -15670,6 +15674,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.refresh_rate = 1; cfg.max_positions = 3; @@ -22375,6 +22380,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = symbol.to_string(); cfg.exposure_expr = "1.0".to_string(); cfg.selection_limit_expr = "40".to_string(); @@ -22749,6 +22755,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.rotation_enabled = false; cfg.daily_top_up_enabled = false; cfg.signal_symbol = symbol.to_string(); @@ -23705,6 +23712,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = symbol.to_string(); cfg.exposure_expr = "0.5".to_string(); cfg.selection_limit_expr = "40".to_string(); @@ -24961,6 +24969,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap()); cfg.signal_symbol = signal.to_string(); cfg.max_positions = 1; @@ -27428,6 +27437,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.refresh_rate = 99; cfg.max_positions = 2; @@ -27569,6 +27579,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.refresh_rate = 99; cfg.max_positions = 2; @@ -28359,6 +28370,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.refresh_rate = 99; cfg.max_positions = 2; @@ -28678,6 +28690,7 @@ mod tests { .expect("dataset"); let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.refresh_rate = 20; cfg.max_positions = 2; @@ -28726,6 +28739,7 @@ mod tests { ); let mut dynamic_cfg = PlatformExprStrategyConfig::microcap_rotation(); + dynamic_cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; dynamic_cfg.signal_symbol = "000001.SZ".to_string(); dynamic_cfg.refresh_rate = 20; dynamic_cfg.refresh_rate_expr = "year >= 2024 ? 5 : 20".to_string(); @@ -28751,6 +28765,7 @@ mod tests { ); let mut signal_dates_cfg = PlatformExprStrategyConfig::microcap_rotation(); + signal_dates_cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; signal_dates_cfg.signal_symbol = "000001.SZ".to_string(); signal_dates_cfg.refresh_rate = 20; signal_dates_cfg.max_positions = 2; @@ -28786,6 +28801,7 @@ mod tests { ); let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation(); + no_retry_cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; no_retry_cfg.signal_symbol = "000001.SZ".to_string(); no_retry_cfg.refresh_rate = 15; no_retry_cfg.max_positions = 2; @@ -28953,6 +28969,7 @@ mod tests { .expect("dataset"); let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.refresh_rate = 10; cfg.max_positions = 2; @@ -29130,6 +29147,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.refresh_rate = 20; cfg.max_positions = 2; @@ -30343,6 +30361,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.rotation_enabled = false; cfg.hold_until_exit_enabled = true; cfg.signal_symbol = symbol.to_string(); @@ -31605,6 +31624,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.refresh_rate = 99; cfg.max_positions = 2; @@ -31758,6 +31778,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.refresh_rate = 99; cfg.max_positions = 2; @@ -31922,6 +31943,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.refresh_rate = 99; cfg.max_positions = 2; @@ -33943,6 +33965,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.refresh_rate = 99; cfg.max_positions = 1; @@ -34120,6 +34143,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.refresh_rate = 99; cfg.max_positions = 1; @@ -34162,6 +34186,7 @@ mod tests { ); let mut filtered_cfg = PlatformExprStrategyConfig::microcap_rotation(); + filtered_cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; filtered_cfg.signal_symbol = "000001.SZ".to_string(); filtered_cfg.refresh_rate = 99; filtered_cfg.max_positions = 1; @@ -36485,6 +36510,7 @@ mod tests { fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.volume_capacity_mode = crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; cfg.signal_symbol = "000001.SZ".to_string(); cfg.rotation_enabled = false; cfg.benchmark_short_ma_days = 1; diff --git a/crates/fidc-core/tests/automatic_trade_protection.rs b/crates/fidc-core/tests/automatic_trade_protection.rs index 20c5af5..eb85ee8 100644 --- a/crates/fidc-core/tests/automatic_trade_protection.rs +++ b/crates/fidc-core/tests/automatic_trade_protection.rs @@ -119,7 +119,7 @@ fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult { action("-100", "decision_date >= \"2026-09-14\""), ]; config.matching_type = MatchingType::CurrentBarClose; - let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::CurrentBarClose); BacktestEngine::new( data(), @@ -294,7 +294,7 @@ fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() { }], ..Default::default() }; - let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::CurrentBarClose); let result = BacktestEngine::new( dataset, diff --git a/crates/fidc-core/tests/corporate_actions.rs b/crates/fidc-core/tests/corporate_actions.rs index 27ed34a..0d5d37d 100644 --- a/crates/fidc-core/tests/corporate_actions.rs +++ b/crates/fidc-core/tests/corporate_actions.rs @@ -394,7 +394,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() { .with_minimum_commission(0.0), ChinaEquityRuleHooks::default(), PriceField::Open, - ), + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit), BacktestConfig { initial_cash: 11_008.0, benchmark_code: "000300.SH".to_string(), @@ -475,7 +475,7 @@ fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ), + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit), BacktestConfig { initial_cash: 11_008.0, benchmark_code: "000300.SH".to_string(), diff --git a/crates/fidc-core/tests/decision_quote_preload.rs b/crates/fidc-core/tests/decision_quote_preload.rs index e08db24..07802bf 100644 --- a/crates/fidc-core/tests/decision_quote_preload.rs +++ b/crates/fidc-core/tests/decision_quote_preload.rs @@ -170,7 +170,7 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close, - ) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::CurrentBarClose); let config = BacktestConfig { initial_cash: 10_000.0, @@ -235,7 +235,7 @@ fn engine_skips_decision_quote_symbol_plan_without_loader() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close, - ) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::CurrentBarClose); let config = BacktestConfig { initial_cash: 10_000.0, @@ -391,7 +391,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, - ) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::MinuteLast) .with_intraday_execution_start_time(t(10, 40, 0)); let config = BacktestConfig { @@ -590,7 +590,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, - ) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::MinuteLast) .with_intraday_execution_start_time(t(10, 40, 0)); let config = BacktestConfig { @@ -796,7 +796,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, - ) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::MinuteLast) .with_intraday_execution_start_time(t(10, 40, 0)); let config = BacktestConfig { diff --git a/crates/fidc-core/tests/delisting.rs b/crates/fidc-core/tests/delisting.rs index 96d9bc1..55fcaf0 100644 --- a/crates/fidc-core/tests/delisting.rs +++ b/crates/fidc-core/tests/delisting.rs @@ -295,7 +295,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, BuyThenHoldStrategy, @@ -548,7 +548,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, BuyThenHoldStrategy, diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 3b1e925..07bb37c 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -1219,7 +1219,7 @@ fn engine_runs_strategy_hooks_in_daily_order() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, strategy, @@ -1260,7 +1260,7 @@ fn engine_runs_strategy_hooks_in_daily_order() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut compact_engine = BacktestEngine::new( compact_data, compact_strategy, @@ -1401,7 +1401,7 @@ fn engine_executes_open_auction_decisions_before_on_day() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::DayOpen, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, strategy, @@ -1497,7 +1497,7 @@ fn engine_executes_futures_order_intents_against_future_account() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, FuturesOrderStrategy, @@ -1569,7 +1569,7 @@ fn platform_runtime_actions_execute_generic_futures_open_and_close() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( two_day_futures_data(), PlatformExprStrategy::new(cfg), @@ -1609,7 +1609,7 @@ fn engine_settles_configured_futures_expiration_at_settlement() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( single_day_anchor_data(date), FuturesOrderStrategy, @@ -1657,7 +1657,7 @@ fn engine_aggregates_futures_account_into_nav_and_metrics() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( single_day_anchor_data(date), FuturesOrderStrategy, @@ -1700,7 +1700,7 @@ fn engine_matches_pending_futures_limit_order_with_data_driven_costs() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( two_day_futures_data(), FuturesLimitOrderStrategy, @@ -1753,7 +1753,7 @@ fn engine_reports_pending_futures_order_at_backtest_boundary() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( two_day_futures_data(), FuturesLimitOrderStrategy, @@ -1805,7 +1805,7 @@ fn engine_rejects_futures_limit_orders_not_aligned_to_tick() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( two_day_futures_data(), FuturesInvalidTickLimitStrategy, @@ -1836,7 +1836,7 @@ fn engine_allows_disabling_futures_limit_tick_validation() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( two_day_futures_data(), FuturesInvalidTickLimitStrategy, @@ -1883,7 +1883,7 @@ fn engine_rejects_futures_limit_orders_outside_price_limits() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( two_day_futures_data(), FuturesAboveUpperLimitStrategy, @@ -1958,7 +1958,7 @@ fn engine_rejects_futures_orders_when_trading_phase_is_closed() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, FuturesClosedPhaseOrderStrategy, @@ -2066,7 +2066,7 @@ fn engine_sweeps_futures_order_book_depth_when_available() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Last, - ) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::MinuteBestCounterparty); let mut engine = BacktestEngine::new( data, @@ -2111,7 +2111,7 @@ fn strategy_context_exposes_advanced_data_helpers() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( two_day_futures_data(), AdvancedDataApiProbeStrategy { @@ -2265,7 +2265,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Last, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let loader_requests = Arc::new(Mutex::new(Vec::::new())); let loader_requests_for_callback = Arc::clone(&loader_requests); let mut engine = BacktestEngine::new( @@ -2381,7 +2381,7 @@ fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Last, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, strategy, @@ -2579,7 +2579,7 @@ fn strategy_context_exposes_engine_native_data_helpers() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, strategy, @@ -2683,7 +2683,7 @@ fn strategy_context_exposes_final_order_runtime_view() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Close, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, strategy, @@ -2959,7 +2959,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Close, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, AccountFlowStrategy, @@ -3157,7 +3157,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let strategy = LimitCarryStrategy { issued: false }; let mut engine = BacktestEngine::new( data, @@ -3394,7 +3394,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::DayOpen, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, strategy, @@ -3649,7 +3649,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::DayOpen, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, strategy, @@ -3741,7 +3741,7 @@ fn engine_installs_process_mods_on_event_bus() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::DayOpen, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, HookProbeStrategy { @@ -3778,7 +3778,7 @@ fn engine_installs_enabled_process_mods_from_loader() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::DayOpen, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, HookProbeStrategy { @@ -3981,7 +3981,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, strategy, @@ -4105,7 +4105,7 @@ fn engine_exposes_current_process_context_to_strategies() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Last, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, strategy, @@ -4215,7 +4215,7 @@ fn engine_rejects_an_unexplained_missing_holding_close() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, BuyMissingRowThenHoldStrategy, @@ -4314,7 +4314,7 @@ fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut engine = BacktestEngine::new( data, PlatformExprStrategy::new(config), From 24cb4805a728b7e27d545bd324eaba669a218460 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 12 Sep 2026 06:46:49 +0800 Subject: [PATCH 3/5] fix: keep decision estimates independent and use timed capacity in fill fixtures --- crates/fidc-core/src/broker.rs | 5 +- crates/fidc-core/src/engine.rs | 9 +- .../fidc-core/src/platform_expr_strategy.rs | 13 +++ .../tests/automatic_trade_protection.rs | 2 + crates/fidc-core/tests/engine_hooks.rs | 1 + crates/fidc-core/tests/explicit_order_flow.rs | 102 +++++++++--------- .../tests/stock_pool_execution_contract.rs | 10 +- 7 files changed, 89 insertions(+), 53 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 1fab28a..fe3a1eb 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -7725,7 +7725,7 @@ where } else { remaining_qty.min(available_qty) }; - if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { + if !(side == OrderSide::Sell && allow_odd_lot_sell) { take_qty = self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size); } @@ -8397,6 +8397,7 @@ mod tests { vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)], ).unwrap(); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::CurrentBarClose); let mut portfolio = PortfolioState::new(100_000.0); broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap(); @@ -8423,6 +8424,7 @@ mod tests { let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()], Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap(); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::CurrentBarClose); broker.upsert_open_order(test_open_order(99)); let mut decision = StrategyDecision::default(); @@ -8450,6 +8452,7 @@ mod tests { dated_limit_test_candidate(second, false, false, true, true)], vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)]).unwrap(); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::NextBarOpen); let mut portfolio = PortfolioState::new(1_000_000.0); let mut initial = StrategyDecision::default(); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 4f60b97..333f666 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -7028,6 +7028,7 @@ mod tests { let third = d(2025, 1, 6); let fourth = d(2025, 1, 7); let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()) + .with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_volume_limit(true) .with_volume_percent(0.25); let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( @@ -7055,12 +7056,13 @@ mod tests { } #[test] - fn next_bar_open_sell_volume_limit_rejects_execution_day_zero_volume() { + fn next_bar_open_session_audit_flags_zero_volume_without_rewriting_fills() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let third = d(2025, 1, 6); let fourth = d(2025, 1, 7); let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()) + .with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_volume_limit(true) .with_volume_percent(0.25); let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( @@ -7081,7 +7083,10 @@ mod tests { broker, ); - assert_round_trip_sell_canceled_with_reason(&result, "daily volume limit"); + assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Sell && fill.date == fourth)); + assert_eq!(result.capacity_audit.audit_passed, Some(false)); + assert_eq!(result.capacity_audit.failed_symbol_sessions, 1); + assert!(result.process_events.iter().any(|event| event.kind == ProcessEventKind::SessionCapacityAudit)); } #[test] diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 06c520f..6a31080 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3157,10 +3157,16 @@ impl PlatformExprStrategy { allow_odd_lot_sell: bool, current_fill_quantity: u32, execution_state: &ProjectedExecutionState, + future_execution: bool, ) -> Result, BacktestError> { if requested_qty == 0 { return Ok(Some(0)); } + if future_execution { + // A decision-day estimate cannot use tomorrow's liquidity to + // change the orders created today. + return Ok(Some(requested_qty)); + } let constraints = self.config.risk_config.trading_constraints; let mut max_fill = requested_qty; @@ -3333,6 +3339,7 @@ impl PlatformExprStrategy { allow_odd_lot_sell, filled_qty, execution_state, + Self::defer_projection_execution_risk(ctx, date), )? .unwrap_or(0); if available_qty == 0 { @@ -3521,6 +3528,7 @@ impl PlatformExprStrategy { sellable_qty >= current_qty, 0, execution_state, + Self::defer_projection_execution_risk(ctx, date), )?.filter(|quantity| *quantity > 0) { fill = Some(ProjectedExecutionFill { @@ -4153,6 +4161,7 @@ impl PlatformExprStrategy { false, 0, execution_state, + Self::defer_projection_execution_risk(ctx, date), )?.filter(|quantity| *quantity > 0) { fill = Some(ProjectedExecutionFill { @@ -15072,6 +15081,7 @@ mod tests { order_events:&[],fills:&[], }; let mut cfg=PlatformExprStrategyConfig::generic(); + cfg.risk_config.trading_constraints.volume_limit_enabled=false; cfg.signal_symbol=symbol.into(); cfg.rotation_enabled=false; cfg.signal_book=Some(book); @@ -15190,6 +15200,7 @@ mod tests { let rows = Arc::new(Mutex::new(Vec::new())); let strategy = Capture { inner: PlatformExprStrategy::new(config), first, rows: Arc::clone(&rows) }; let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_capacity_mode(crate::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::CurrentBarClose); let mut engine = BacktestEngine::new(data, strategy, broker, BacktestConfig { initial_cash: 10_000.0, benchmark_code: "000852.SH".to_owned(), start_date: Some(first), @@ -18046,6 +18057,7 @@ mod tests { false, 0, &execution_state, + false, ).expect("valid volume capacity"), Some(2_500) ); @@ -18066,6 +18078,7 @@ mod tests { false, 0, &execution_state, + false, ).expect("valid remaining volume capacity"), Some(100) ); diff --git a/crates/fidc-core/tests/automatic_trade_protection.rs b/crates/fidc-core/tests/automatic_trade_protection.rs index eb85ee8..6ed5012 100644 --- a/crates/fidc-core/tests/automatic_trade_protection.rs +++ b/crates/fidc-core/tests/automatic_trade_protection.rs @@ -107,6 +107,7 @@ fn action(quantity: &str, when: &str) -> PlatformTradeAction { } fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult { let mut config = PlatformExprStrategyConfig::generic(); + config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; config.signal_symbol = "000001.SZ".into(); config.benchmark_symbol = "000300.SH".into(); config.rotation_enabled = false; @@ -276,6 +277,7 @@ fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() { ) .unwrap(); let mut config = PlatformExprStrategyConfig::generic(); + config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; config.signal_symbol = "000001.SZ".into(); config.benchmark_symbol = "000300.SH".into(); config.strategy_name = "protection_test".into(); diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 07bb37c..5baf98e 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -4290,6 +4290,7 @@ fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() { ) .expect("dataset"); let mut config = PlatformExprStrategyConfig::microcap_rotation(); + config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; config.strategy_name = "missing-row-platform-risk".to_string(); config.benchmark_symbol = "000300.SH".to_string(); config.signal_symbol = "000001.SZ".to_string(); diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 3f65dd6..2f48b3b 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -202,6 +202,7 @@ fn execute_single_value_order( ChinaEquityRuleHooks::default(), PriceField::Open, ) + .with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_strict_value_budget(true); let report = broker .execute( @@ -392,7 +393,7 @@ fn broker_executes_explicit_order_value_buy() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let report = broker .execute( @@ -682,7 +683,7 @@ fn broker_executes_order_shares_and_order_lots() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let report = broker .execute( @@ -802,7 +803,7 @@ fn broker_executes_target_shares_like_order_to() { let broker = BrokerSimulator::new( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let report = broker .execute( @@ -978,7 +979,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() { let broker = BrokerSimulator::new( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let report = broker .execute( @@ -1252,7 +1253,7 @@ fn broker_executes_order_percent_and_target_percent() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut percent_portfolio = PortfolioState::new(1_000_000.0); let percent_report = broker @@ -1380,7 +1381,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::DayOpen, - ) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_slippage_model(SlippageModel::PriceRatio(0.05)); let report = broker @@ -1414,7 +1415,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() { } #[test] -fn broker_open_auction_uses_auction_volume_without_quote_liquidity() { +fn broker_rejects_unproven_auction_capacity_in_a_daily_snapshot() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let data = DataSet::from_components( vec![Instrument { @@ -1511,11 +1512,10 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() { risk_decisions: Vec::new(), }, ) - .expect("broker execution"); + .expect_err("a timestamped daily total is not proof of auction volume"); - assert_eq!(report.fill_events.len(), 1); - assert_eq!(report.fill_events[0].quantity, 200); - assert_eq!(report.fill_events[0].price, 9.8); + assert!(report.to_string().contains("execution-time capacity is missing")); + assert_eq!(portfolio.cash(), 1_000_000.0); } #[test] @@ -1710,7 +1710,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_slippage_model(SlippageModel::PriceRatio(0.01)); let report = broker @@ -2337,7 +2337,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted } #[test] -fn broker_cancels_market_buy_when_minute_has_no_volume() { +fn broker_rejects_missing_execution_capacity_instead_of_declaring_suspension() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let data = DataSet::from_components( vec![Instrument { @@ -2433,15 +2433,10 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() { risk_decisions: Vec::new(), }, ) - .expect("broker execution"); + .expect_err("missing capacity is a contract error, not a normal no-volume cancellation"); - assert_eq!(report.fill_events.len(), 0); - assert_eq!(report.order_events.len(), 1); - assert_eq!( - report.order_events[0].status, - fidc_core::OrderStatus::Canceled - ); - assert!(report.order_events[0].reason.contains("daily no volume")); + assert!(report.to_string().contains("execution-time capacity is missing")); + assert_eq!(portfolio.cash(), 1_000_000.0); } #[test] @@ -3679,7 +3674,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::DayOpen, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let report = broker .execute( @@ -3864,7 +3859,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let report = broker .execute( @@ -4049,7 +4044,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); broker .execute( @@ -4163,7 +4158,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_risk_config(risk_config); let report = broker @@ -4269,7 +4264,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_risk_config(risk_config); let report = broker @@ -4377,7 +4372,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let report = broker .execute( @@ -4511,7 +4506,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); broker .execute( @@ -4655,7 +4650,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_risk_config(risk_config); broker @@ -4708,7 +4703,7 @@ fn broker_configured_policy_can_allow_upper_limit_buy() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_risk_config(risk_config); let mut portfolio = PortfolioState::new(1_000_000.0); @@ -4752,7 +4747,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) .with_risk_config(risk_config); let mut portfolio = PortfolioState::new(1_000_000.0); portfolio @@ -4791,7 +4786,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() { fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet { let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); - DataSet::from_components( + DataSet::from_components_with_actions_and_quotes( vec![Instrument { symbol: "000002.SZ".to_string(), name: "Test".to_string(), @@ -4919,6 +4914,13 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet { volume: 1_000_000, }, ], + Vec::new(), + [(day1, day1_open), (day2, day2_open)].into_iter().map(|(date, price)| IntradayExecutionQuote { + date, symbol: "000002.SZ".into(), timestamp: date.and_hms_opt(9, 30, 0).unwrap(), + last_price: price, bid1: price, ask1: price, bid1_volume: 0, ask1_volume: 0, + volume_delta: 100_000, amount_delta: 100_000.0 * price, + trading_phase: Some("synthetic_observation_fixture".into()), + }).collect(), ) .expect("dataset") } @@ -4932,7 +4934,7 @@ fn broker_expires_day_limit_buy_at_market_close() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()); let mut portfolio = PortfolioState::new(1_000_000.0); let day1_report = broker @@ -5006,7 +5008,7 @@ fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) .with_volume_limit(true) .with_volume_percent(0.001) .with_liquidity_limit(false); @@ -5042,7 +5044,7 @@ fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() { } #[test] -fn broker_persists_daily_volume_consumption_across_execute_calls() { +fn broker_persists_observed_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); @@ -5051,6 +5053,8 @@ fn broker_persists_daily_volume_consumption_across_execute_calls() { ChinaEquityRuleHooks::default(), PriceField::Open, ) + .with_matching_type(MatchingType::MinuteLast) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) .with_volume_limit(true) .with_volume_percent(0.001) .with_liquidity_limit(false); @@ -5077,7 +5081,7 @@ fn broker_persists_daily_volume_consumption_across_execute_calls() { 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!(second.order_events[0].reason.contains("intraday quote liquidity exhausted")); assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100); let next_day = broker @@ -5294,7 +5298,7 @@ fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) .with_volume_limit(true) .with_volume_percent(0.001) .with_liquidity_limit(false); @@ -5334,7 +5338,7 @@ fn broker_fok_order_is_atomic_when_liquidity_is_insufficient() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) .with_volume_limit(true) .with_volume_percent(0.001) .with_liquidity_limit(false); @@ -5421,7 +5425,7 @@ fn broker_gtc_limit_order_survives_close_and_fills_next_day() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()); let mut portfolio = PortfolioState::new(1_000_000.0); let day1_report = broker @@ -5469,7 +5473,7 @@ fn broker_gtc_partial_fills_preserve_cumulative_order_and_commission_state() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) .with_volume_limit(true) .with_volume_percent(0.001) .with_liquidity_limit(false); @@ -5536,7 +5540,7 @@ fn broker_modifies_gtc_limit_order_without_changing_order_identity() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()); let mut portfolio = PortfolioState::new(1_000_000.0); let created = broker @@ -5621,7 +5625,7 @@ fn broker_modifies_partially_filled_gtc_total_and_preserves_commission_state() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) .with_volume_limit(true) .with_volume_percent(0.001) .with_liquidity_limit(false); @@ -5699,7 +5703,7 @@ fn broker_rejected_modify_has_zero_side_effects() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) .with_volume_limit(true) .with_volume_percent(0.001) .with_liquidity_limit(false); @@ -5767,7 +5771,7 @@ fn broker_accepted_modify_resets_queue_priority_but_reduction_preserves_it() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()); let mut portfolio = PortfolioState::new(1_000_000.0); let create = |reason: &str| StrategyDecision { order_intents: vec![ @@ -5899,7 +5903,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) .with_slippage_model(SlippageModel::LimitPrice); let mut portfolio = PortfolioState::new(1_000_000.0); @@ -5938,7 +5942,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ) + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) .with_slippage_model(SlippageModel::LimitPrice); let mut portfolio = PortfolioState::new(1_000_000.0); @@ -5984,7 +5988,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()); let mut value_portfolio = PortfolioState::new(1_000_000.0); let value_report = broker @@ -6047,7 +6051,7 @@ fn broker_cancels_open_order_by_order_id() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()); let mut portfolio = PortfolioState::new(1_000_000.0); let day1_report = broker @@ -6225,7 +6229,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ); + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit); let mut portfolio = PortfolioState::new(1_000_000.0); portfolio .position_mut("000002.SZ") diff --git a/crates/fidc-core/tests/stock_pool_execution_contract.rs b/crates/fidc-core/tests/stock_pool_execution_contract.rs index 28b5155..4d6719a 100644 --- a/crates/fidc-core/tests/stock_pool_execution_contract.rs +++ b/crates/fidc-core/tests/stock_pool_execution_contract.rs @@ -142,7 +142,13 @@ fn data_with_fund_rules( }) }) .collect(); - DataSet::from_components(instruments, market, factors, candidates, benchmarks).unwrap() + let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote { + date: row.date, symbol: row.symbol.clone(), timestamp: row.date.and_hms_opt(9, 30, 0).unwrap(), + last_price: row.open, bid1: row.open, ask1: row.open, bid1_volume: 0, ask1_volume: 0, + volume_delta: row.volume, amount_delta: row.open * row.volume as f64, + trading_phase: Some("synthetic_observation_fixture".into()), + }).collect(); + DataSet::from_components_with_actions_and_quotes(instruments, market, factors, candidates, benchmarks, vec![], quotes).unwrap() } fn broker(volume: bool) -> BrokerSimulator { let mut risk = FidcRiskControlConfig::default(); @@ -159,6 +165,7 @@ fn broker(volume: bool) -> BrokerSimulator FrozenStockPoolIntent { @@ -268,6 +275,7 @@ fn mixed_fund_and_stock_round_trip_uses_declared_ticks_and_asset_specific_fees() let mut costs = ChinaAShareCostModel::default(); costs.set_transfer_fee_rate(0.00001); let broker = BrokerSimulator::new(costs, ChinaEquityRuleHooks) + .with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_matching_type(MatchingType::NextBarOpen); let mut account = PortfolioState::new(30_000.); let mut entry = contract(day(2), 1, false); From 5949d4cc6921ff36457f5764412e7aec71974825 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 12 Sep 2026 06:48:07 +0800 Subject: [PATCH 4/5] refactor: remove retired daily quota parameters from target diagnostics --- crates/fidc-core/src/broker.rs | 10 ---------- crates/fidc-core/src/engine.rs | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index fe3a1eb..0c1ef4d 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -3521,8 +3521,6 @@ where data, &symbol, current_qty, - minimum_order_quantity, - order_step_size, ) { diagnostics.push(format!( @@ -3539,8 +3537,6 @@ where data, &symbol, current_qty, - minimum_order_quantity, - order_step_size, ) { diagnostics.push(format!( @@ -3951,8 +3947,6 @@ where data, symbol, current_qty, - minimum_order_quantity, - order_step_size, ) else { continue; }; @@ -4220,8 +4214,6 @@ where data: &DataSet, symbol: &str, current_qty: u32, - minimum_order_quantity: u32, - order_step_size: u32, ) -> Option { if let Some(reason) = self.runtime_auto_sell_denials.borrow().get(symbol) { return Some(reason.clone()); @@ -4272,8 +4264,6 @@ where data: &DataSet, symbol: &str, current_qty: u32, - minimum_order_quantity: u32, - order_step_size: u32, ) -> Option { let snapshot = data.require_market(date, symbol).ok()?; let candidate = data.require_candidate(date, symbol).ok()?; diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 333f666..1d0f6ba 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -7086,7 +7086,7 @@ mod tests { assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Sell && fill.date == fourth)); assert_eq!(result.capacity_audit.audit_passed, Some(false)); assert_eq!(result.capacity_audit.failed_symbol_sessions, 1); - assert!(result.process_events.iter().any(|event| event.kind == ProcessEventKind::SessionCapacityAudit)); + assert!(result.process_events.iter().any(|event| event.kind == crate::ProcessEventKind::SessionCapacityAudit)); } #[test] From 7ac87a90c4fc024a355dfbed8e5b2fa1ce7c8b2a Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 12 Sep 2026 06:51:33 +0800 Subject: [PATCH 5/5] test: keep snapshot price-risk fixtures in their explicit daily model --- crates/fidc-core/tests/explicit_order_flow.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 2f48b3b..790294d 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -4747,7 +4747,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_risk_config(risk_config); let mut portfolio = PortfolioState::new(1_000_000.0); portfolio @@ -5942,7 +5942,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() { ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, - ).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) + ).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit) .with_slippage_model(SlippageModel::LimitPrice); let mut portfolio = PortfolioState::new(1_000_000.0);