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();