From 4acecda79d19357d3cf24dddc169bf2460e19f6c Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 11 Sep 2026 15:00:18 +0800 Subject: [PATCH] feat: introduce causal capacity primitives and exact participation quotas --- crates/fidc-core/src/broker.rs | 34 ++-- crates/fidc-core/src/execution_capacity.rs | 170 ++++++++++++++++++ crates/fidc-core/src/lib.rs | 1 + .../fidc-core/src/platform_expr_strategy.rs | 107 +++++------ 4 files changed, 242 insertions(+), 70 deletions(-) create mode 100644 crates/fidc-core/src/execution_capacity.rs diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 191f4ba..72b4fc2 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -7,6 +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}; use crate::events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, @@ -401,6 +402,7 @@ pub struct BrokerSimulator { execution_price_field: PriceField, slippage_model: SlippageModel, volume_percent: f64, + volume_rate: Result, volume_limit: bool, inactive_limit: bool, liquidity_limit: bool, @@ -436,6 +438,7 @@ impl BrokerSimulator { execution_price_field: PriceField::Open, slippage_model: SlippageModel::None, volume_percent: 0.25, + volume_rate: ParticipationRate::new(0.25), volume_limit: true, inactive_limit: true, liquidity_limit: true, @@ -475,6 +478,7 @@ impl BrokerSimulator { execution_price_field, slippage_model: SlippageModel::None, volume_percent: 0.25, + volume_rate: ParticipationRate::new(0.25), volume_limit: true, inactive_limit: true, liquidity_limit: true, @@ -547,6 +551,7 @@ impl BrokerSimulator { pub fn with_risk_config(mut self, config: FidcRiskControlConfig) -> Self { self.volume_limit = config.trading_constraints.volume_limit_enabled; self.volume_percent = config.trading_constraints.volume_percent; + self.volume_rate = ParticipationRate::new(self.volume_percent); self.liquidity_limit = config.trading_constraints.liquidity_limit_enabled; self.risk_config = config; self @@ -558,6 +563,7 @@ impl BrokerSimulator { pub fn with_volume_percent(mut self, volume_percent: f64) -> Self { self.volume_percent = volume_percent; + self.volume_rate = ParticipationRate::new(volume_percent); self } @@ -1482,6 +1488,9 @@ where data: &DataSet, decision: &StrategyDecision, ) -> Result { + if self.volume_limit { + self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?; + } let mut session = std::mem::take(&mut *self.execution_session.borrow_mut()); session.activate(date); let result = self.execute_with_daily_session(date, portfolio, data, decision, &mut session); @@ -7333,15 +7342,15 @@ where } if self.volume_limit { - let raw_limit = ((available_market_volume as f64) * self.volume_percent).floor() as i64 - - consumed_turnover as i64; - if raw_limit <= 0 { + 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 as u32 + raw_limit } else { - self.round_buy_quantity(raw_limit as u32, minimum_order_quantity, order_step_size) + self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size) }; if volume_limited == 0 { return Err(volume_limit_reason.to_string()); @@ -7743,12 +7752,6 @@ where remaining_qty }; if self.volume_limit { - let raw_limit = ((quote.volume_delta as f64) * self.volume_percent).floor() as u32; - 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) - }; let consumed = execution_ledger .volume_consumed(symbol, quote.timestamp) .saturating_add( @@ -7757,7 +7760,14 @@ where .copied() .unwrap_or(0), ); - available_qty = available_qty.min(volume_limited.saturating_sub(consumed)); + let raw_limit = self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))? + .remaining(quote.volume_delta, u64::from(consumed), remaining_qty); + 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) + }; + available_qty = available_qty.min(volume_limited); } if available_qty == 0 { continue; diff --git a/crates/fidc-core/src/execution_capacity.rs b/crates/fidc-core/src/execution_capacity.rs new file mode 100644 index 0000000..aa0c955 --- /dev/null +++ b/crates/fidc-core/src/execution_capacity.rs @@ -0,0 +1,170 @@ +//! Causal volume budgets. Session totals may audit fills, never size earlier orders. +use chrono::{NaiveDate, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VolumeCapacityMode { + #[default] + ExecutionObservation, + CompletedBar, + SessionCapacityAudit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum CapacityError { + #[error("execution capacity ratio must be finite and in (0, 1]")] + InvalidRatio, + #[error("execution capacity decimal cannot be represented exactly")] + InvalidDecimal, + #[error("execution capacity observation has invalid time bounds")] + InvalidWindow, + #[error("execution capacity is not visible: available={available_at}, execution={execution_at}")] + NotVisible { available_at: NaiveDateTime, execution_at: NaiveDateTime }, + #[error("execution capacity observation belongs to another session")] + WrongSession, + #[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")] + MissingObservation, +} + +/// Decimal semantics of the frozen JSON rate, evaluated without a float product. +#[derive(Debug, Clone, Copy)] +pub struct ParticipationRate { + numerator: u128, + denominator: u128, +} + +impl ParticipationRate { + pub fn new(rate: f64) -> Result { + if !rate.is_finite() || rate <= 0.0 || rate > 1.0 { + return Err(CapacityError::InvalidRatio); + } + if rate < 1e-20 { + // Even u64::MAX shares at this rate cannot admit a single share. + return Ok(Self { numerator: 0, denominator: 1 }); + } + if rate == 1.0 { + return Ok(Self { numerator: 1, denominator: 1 }); + } + let text = rate.to_string(); + let digits = text.strip_prefix("0.").ok_or(CapacityError::InvalidDecimal)?; + let digits = digits.trim_end_matches('0'); + let numerator = digits.parse::().map_err(|_| CapacityError::InvalidDecimal)?; + let denominator = 10_u128.checked_pow(digits.len() as u32).ok_or(CapacityError::InvalidDecimal)?; + if numerator > u128::MAX / u128::from(u64::MAX) { + return Err(CapacityError::InvalidDecimal); + } + Ok(Self { numerator, denominator }) + } + + pub fn total_shares(self, market_shares: u64) -> u64 { + let total = u128::from(market_shares) * self.numerator / self.denominator; + u64::try_from(total).expect("participation rate cannot exceed the market shares") + } + + pub fn remaining(self, market_shares: u64, consumed_shares: u64, requested: u32) -> u32 { + self.total_shares(market_shares).saturating_sub(consumed_shares).min(u64::from(requested)) as u32 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VolumeObservationKind { + TradeIncrement, + CompletedBar, + CumulativeSession, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct VolumeObservation { + pub kind: VolumeObservationKind, + pub start: NaiveDateTime, + pub end: NaiveDateTime, + pub available_at: NaiveDateTime, + pub shares: u64, +} + +impl VolumeObservation { + pub fn visible_shares(self, execution_at: NaiveDateTime) -> Result { + if self.start > self.end || self.available_at < self.end { + return Err(CapacityError::InvalidWindow); + } + if self.available_at > execution_at { + return Err(CapacityError::NotVisible { available_at: self.available_at, execution_at }); + } + if self.start.date() != self.end.date() || self.end.date() != execution_at.date() { + return Err(CapacityError::WrongSession); + } + Ok(self.shares) + } + + pub fn remaining(self, execution_at: NaiveDateTime, rate: ParticipationRate, consumed: u64, requested: u32) -> Result { + Ok(rate.remaining(self.visible_shares(execution_at)?, consumed, requested)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SessionCapacityAudit { + pub date: NaiveDate, + pub symbol: String, + pub filled_shares: u64, + pub session_shares: u64, + pub allowed_shares: u64, + pub passed: bool, +} + +impl SessionCapacityAudit { + pub fn new(date: NaiveDate, symbol: String, filled_shares: u64, session_shares: u64, rate: ParticipationRate) -> Self { + let allowed_shares = rate.total_shares(session_shares); + Self { date, symbol, filled_shares, session_shares, allowed_shares, passed: filled_shares <= allowed_shares } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decimal_participation_never_rounds_a_fractional_share_up_or_overflows() { + assert_eq!(ParticipationRate::new(0.58).unwrap().total_shares(50), 29); + assert_eq!(ParticipationRate::new(0.25).unwrap().total_shares(3), 0); + assert_eq!(ParticipationRate::new(0.5).unwrap().total_shares(3), 1); + assert_eq!(ParticipationRate::new(1.).unwrap().total_shares(u64::MAX), u64::MAX); + assert_eq!(ParticipationRate::new(0.25).unwrap().remaining(u64::MAX, 0, u32::MAX), u32::MAX); + assert_eq!(ParticipationRate::new(f64::MIN_POSITIVE).unwrap().total_shares(u64::MAX), 0); + for rate in [0., -1., f64::NAN, f64::INFINITY, 1.001] { + assert!(ParticipationRate::new(rate).is_err()); + } + } + + #[test] + fn completed_volume_cannot_be_used_for_an_earlier_open() { + let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap(); + let opening = day.and_hms_opt(9,30,0).unwrap(); + let closing = day.and_hms_opt(15,0,0).unwrap(); + let observation = VolumeObservation { kind:VolumeObservationKind::CompletedBar, start:opening, end:closing, available_at:closing, shares:10000 }; + assert!(matches!(observation.visible_shares(opening), Err(CapacityError::NotVisible { .. }))); + assert_eq!(observation.remaining(closing, ParticipationRate::new(0.25).unwrap(), 1000, 5000).unwrap(), 1500); + assert!(matches!(observation.visible_shares(closing+chrono::Duration::days(1)), Err(CapacityError::WrongSession))); + } + + #[test] + fn delayed_publication_and_invalid_bounds_are_not_treated_as_zero_volume() { + let at = NaiveDate::from_ymd_opt(2025,1,2).unwrap().and_hms_opt(10,18,0).unwrap(); + let observation = VolumeObservation { kind:VolumeObservationKind::TradeIncrement, start:at, end:at, available_at:at+chrono::Duration::seconds(1), shares:0 }; + assert!(matches!(observation.visible_shares(at), Err(CapacityError::NotVisible { .. }))); + assert_eq!(VolumeObservation { available_at:at-chrono::Duration::seconds(1), ..observation }.visible_shares(at), Err(CapacityError::InvalidWindow)); + assert_eq!(VolumeObservation { available_at:at, ..observation }.visible_shares(at).unwrap(), 0); + } + + #[test] + fn session_audit_changes_verdict_not_executed_quantity() { + let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap(); + let rate = ParticipationRate::new(0.25).unwrap(); + let a = SessionCapacityAudit::new(day,"TEST".into(),1000,3000,rate); + let b = SessionCapacityAudit::new(day,"TEST".into(),1000,5000,rate); + assert!(!a.passed); assert!(b.passed); + assert_eq!(a.filled_shares,b.filled_shares); + } +} diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 8ff5e39..ae21e83 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod daily_patterns; pub mod pattern_context; pub mod session_events; pub mod factor_events; +pub mod execution_capacity; mod factor_event_catalog; pub mod factor_cross_section; pub mod market_event_context; diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 1455b67..ba11257 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -17,6 +17,7 @@ use crate::data::{ decision_market_cap_bn, }; use crate::engine::BacktestError; +use crate::execution_capacity::{CapacityError, ParticipationRate}; use crate::events::{OrderSide, ProcessEvent, ProcessEventKind}; use crate::fixed_point::FixedMoney; use crate::futures::{ @@ -1369,6 +1370,7 @@ pub struct PlatformExprStrategy { pattern_specs: RefCell>, pattern_frame_at:RefCell>, config: PlatformExprStrategyConfig, + volume_rate: Result, engine: Engine, rebalance_day_counter: usize, last_rebalance_date: Option, @@ -1776,6 +1778,7 @@ impl PlatformExprStrategy { .clone() .map(PlatformPortfolioDrawdownController::new); Self { + volume_rate: ParticipationRate::new(config.risk_config.trading_constraints.volume_percent), config, engine, protection_fill_count: 0, @@ -3156,9 +3159,9 @@ impl PlatformExprStrategy { allow_odd_lot_sell: bool, current_fill_quantity: u32, execution_state: &ProjectedExecutionState, - ) -> Option { + ) -> Result, BacktestError> { if requested_qty == 0 { - return Some(0); + return Ok(Some(0)); } let constraints = self.config.risk_config.trading_constraints; @@ -3183,7 +3186,7 @@ impl PlatformExprStrategy { }; if top_level_liquidity == 0 { if quote.is_some() { - return None; + return Ok(None); } } else { let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell { @@ -3196,7 +3199,7 @@ impl PlatformExprStrategy { ) }; if liquidity_limited == 0 { - return None; + return Ok(None); } max_fill = max_fill.min(liquidity_limited); } @@ -3209,7 +3212,7 @@ impl PlatformExprStrategy { None => market.volume, }; if volume_basis == 0 { - return None; + return Ok(None); } let consumed_turnover = execution_state .intraday_turnover @@ -3217,23 +3220,23 @@ impl PlatformExprStrategy { .copied() .unwrap_or(0) .saturating_add(current_fill_quantity); - let raw_limit = ((volume_basis as f64) * constraints.volume_percent).floor() as i64 - - consumed_turnover as i64; - if raw_limit <= 0 { - return None; + let raw_limit = self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))? + .remaining(volume_basis, u64::from(consumed_turnover), requested_qty); + if raw_limit == 0 { + return Ok(None); } let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell { - raw_limit as u32 + raw_limit } else { - self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size) + self.round_lot_quantity(raw_limit, minimum_order_quantity, order_step_size) }; if volume_limited == 0 { - return None; + return Ok(None); } max_fill = max_fill.min(volume_limited); } - Some(max_fill) + Ok(Some(max_fill)) } fn quote_lacks_level1_depth(quote: &crate::data::IntradayExecutionQuote) -> bool { @@ -3333,7 +3336,7 @@ impl PlatformExprStrategy { allow_odd_lot_sell, filled_qty, execution_state, - ) + )? .unwrap_or(0); if available_qty == 0 { break; @@ -3483,7 +3486,7 @@ impl PlatformExprStrategy { let round_lot = self.projected_round_lot(ctx, symbol); let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); let order_step_size = self.projected_order_step_size(ctx, symbol); - let Some(fill) = self + let mut fill = self .projected_select_execution_fill_at_time( ctx, date, @@ -3498,22 +3501,18 @@ impl PlatformExprStrategy { None, execution_state, execution_time, - )? - .or_else(|| { - if self.uses_intraday_execution_quotes() - && !Self::defer_projection_execution_risk(ctx, date) - { - return None; - } - if !self.has_execution_quote_at_or_before_at_time( + )?; + if fill.is_none() + && (!self.uses_intraday_execution_quotes() || Self::defer_projection_execution_risk(ctx, date)) + && !self.has_execution_quote_at_or_before_at_time( ctx, date, symbol, execution_state, execution_time, ) && ctx.data.execution_quotes_on(date, symbol).is_empty() - { - let fallback_quantity = self.projected_market_fillable_quantity( + { + if let Some(fallback_quantity) = self.projected_market_fillable_quantity( market, None, symbol, @@ -3525,21 +3524,18 @@ impl PlatformExprStrategy { sellable_qty >= current_qty, 0, execution_state, - )?; - if fallback_quantity == 0 { - return None; - } - Some(ProjectedExecutionFill { + )?.filter(|quantity| *quantity > 0) + { + fill = Some(ProjectedExecutionFill { price: self.projected_execution_price(market, OrderSide::Sell), quantity: fallback_quantity, next_cursor: date.and_time( execution_time.unwrap_or_else(|| self.intraday_execution_start_time()), ) + Duration::seconds(1), - }) - } else { - None - } - }) else { return Ok(None); }; + }); + } + } + let Some(fill) = fill else { return Ok(None); }; let gross_amount = fill.price * fill.quantity as f64; let net_cash = self.sell_net_cash(date, gross_amount); projected @@ -4120,7 +4116,7 @@ impl PlatformExprStrategy { } let submitted_quantity = quantity; let defer_projection_execution_risk = Self::defer_projection_execution_risk(ctx, date); - let fill = self + let mut fill = self .projected_select_execution_fill( ctx, date, @@ -4134,23 +4130,22 @@ impl PlatformExprStrategy { Some(cash_limit), gross_limit, execution_state, - )? - .or_else(|| { - if !defer_projection_execution_risk + )?; + if fill.is_none() + && !(!defer_projection_execution_risk && ctx.data.has_execution_quotes_on_date(date) - && ctx.data.execution_quotes_on(date, symbol).is_empty() - { - None - } else if !self.has_execution_quote_at_or_before_at_time( + && ctx.data.execution_quotes_on(date, symbol).is_empty()) + && !self.has_execution_quote_at_or_before_at_time( ctx, date, symbol, execution_state, None, ) && ctx.data.execution_quotes_on(date, symbol).is_empty() - { - let fallback_quantity = self.projected_market_fillable_quantity( - ctx.data.market(date, symbol)?, + && let Some(market) = ctx.data.market(date, symbol) + { + if let Some(fallback_quantity) = self.projected_market_fillable_quantity( + market, None, symbol, OrderSide::Buy, @@ -4161,20 +4156,16 @@ impl PlatformExprStrategy { false, 0, execution_state, - )?; - if fallback_quantity == 0 { - return None; - } - Some(ProjectedExecutionFill { + )?.filter(|quantity| *quantity > 0) + { + fill = Some(ProjectedExecutionFill { price: sizing_price, quantity: fallback_quantity, next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1), - }) - } else { - None - } - }); + }); + } + } let Some(fill) = fill else { return Ok(ProjectedOrderValueResult::submitted_without_fill(submitted_quantity)); }; @@ -17958,7 +17949,7 @@ mod tests { false, 0, &execution_state, - ), + ).expect("valid volume capacity"), Some(2_500) ); @@ -17978,7 +17969,7 @@ mod tests { false, 0, &execution_state, - ), + ).expect("valid remaining volume capacity"), Some(100) ); }