From 98199c02a28e725737092da610f0ba32ce6a510d Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 11 Sep 2026 11:35:58 +0800 Subject: [PATCH] refactor: isolate historical slippage calibration and propagate pricing errors --- crates/fidc-core/src/broker.rs | 211 ++++++++++++------ .../fidc-core/src/platform_expr_strategy.rs | 160 ++++++------- .../fidc-core/src/platform_strategy_spec.rs | 46 ++-- 3 files changed, 250 insertions(+), 167 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 685ee93..7e83f37 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -300,37 +300,63 @@ impl DynamicSlippageConfig { pub(crate) fn ratio( &self, - snapshot: &crate::data::DailyMarketSnapshot, - raw_price: f64, + calibration: &HistoricalSlippageCalibration, order_value: Option, ) -> f64 { - let daily_amount = (snapshot.volume as f64 * raw_price).max(0.0); let impact_ratio = match order_value { - Some(value) if value.is_finite() && value > 0.0 && daily_amount > 0.0 => { - value / daily_amount + Some(value) if value.is_finite() && value > 0.0 => { + value / calibration.turnover_proxy } _ => 0.0, }; - let volatility_base = if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 { - snapshot.prev_close - } else { - raw_price - }; - let volatility = if snapshot.high.is_finite() - && snapshot.low.is_finite() - && volatility_base.is_finite() - && volatility_base > 0.0 - { - ((snapshot.high - snapshot.low).abs() / volatility_base).max(0.0) - } else { - 0.0 - }; - let ratio = - impact_ratio * self.impact_coefficient + volatility * self.volatility_coefficient; + let ratio = impact_ratio * self.impact_coefficient + + calibration.range_ratio * self.volatility_coefficient; ratio.clamp(0.0, self.max_ratio) } } +#[derive(Debug, Clone, Copy)] +pub(crate) struct HistoricalSlippageCalibration { + source_date: NaiveDate, + turnover_proxy: f64, + range_ratio: f64, +} + +impl HistoricalSlippageCalibration { + pub(crate) fn for_execution(data: &DataSet, date: NaiveDate, symbol: &str) -> Result { + let missing = || BacktestError::Execution(format!( + "historical_slippage_calibration_missing symbol={symbol} execution_date={date} policy=previous_completed_session" + )); + let previous_date = data.previous_trading_date(date, 1).ok_or_else(missing)?; + let row = data.market(previous_date, symbol).ok_or_else(missing)?; + Self::from_completed_snapshot(row, date) + } + + fn from_completed_snapshot( + row: &crate::data::DailyMarketSnapshot, + execution_date: NaiveDate, + ) -> Result { + let turnover_proxy = row.volume as f64 * row.close; + let range_ratio = (row.high - row.low) / row.prev_close; + if row.date >= execution_date + || [row.high, row.low, row.close, row.prev_close, turnover_proxy] + .into_iter().any(|value| !value.is_finite() || value <= 0.0) + || row.high < row.low + || !range_ratio.is_finite() + { + return Err(BacktestError::Execution(format!( + "historical_slippage_calibration_invalid symbol={} source_date={} execution_date={} volume={} high={} low={} close={} prev_close={}", + row.symbol, row.date, execution_date, row.volume, row.high, row.low, row.close, row.prev_close, + ))); + } + Ok(Self { + source_date: row.date, + turnover_proxy, + range_ratio, + }) + } +} + impl Default for DynamicSlippageConfig { fn default() -> Self { Self::new(0.5, 0.3, 0.01) @@ -343,7 +369,7 @@ pub enum SlippageModel { PriceRatio(f64), TickSize(f64), LimitPrice, - Dynamic(DynamicSlippageConfig), + HistoricalVolumeVolatility(DynamicSlippageConfig), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1109,12 +1135,28 @@ where fn snapshot_execution_price( &self, + data: &DataSet, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, quantity: Option, - ) -> f64 { + ) -> Result { let raw_price = self.snapshot_raw_execution_price(snapshot, side); - self.apply_slippage(snapshot, side, raw_price, quantity) + let calibration = self.slippage_calibration(data, snapshot)?; + self.apply_slippage(snapshot, side, raw_price, quantity, calibration.as_ref()) + } + + fn slippage_calibration( + &self, + data: &DataSet, + snapshot: &crate::data::DailyMarketSnapshot, + ) -> Result, BacktestError> { + if !matches!(self.slippage_model, SlippageModel::HistoricalVolumeVolatility(_)) + || self.is_open_auction_matching() + || self.is_post_close_fixed_price(snapshot.date) + { + return Ok(None); + } + HistoricalSlippageCalibration::for_execution(data, snapshot.date, &snapshot.symbol).map(Some) } fn snapshot_raw_execution_price( @@ -1184,17 +1226,18 @@ where side: OrderSide, raw_price: f64, quantity: Option, - ) -> f64 { + calibration: Option<&HistoricalSlippageCalibration>, + ) -> Result { if !raw_price.is_finite() || raw_price <= 0.0 { - return raw_price; + return Ok(raw_price); } if self.is_open_auction_matching() { - return self.clamp_execution_price(snapshot, side, raw_price); + return Ok(self.clamp_execution_price(snapshot, side, raw_price)); } if self.is_post_close_fixed_price(snapshot.date) { - return self.clamp_execution_price(snapshot, side, raw_price); + return Ok(self.clamp_execution_price(snapshot, side, raw_price)); } let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64)); @@ -1216,8 +1259,12 @@ where } } SlippageModel::LimitPrice => raw_price, - SlippageModel::Dynamic(config) => { - let ratio = config.ratio(snapshot, raw_price, order_value); + SlippageModel::HistoricalVolumeVolatility(config) => { + let calibration = calibration.filter(|value| value.source_date < snapshot.date) + .ok_or_else(|| BacktestError::Execution(format!( + "historical_slippage_calibration_required symbol={} execution_date={}", snapshot.symbol, snapshot.date, + )))?; + let ratio = config.ratio(calibration, order_value); match side { OrderSide::Buy => raw_price * (1.0 + ratio), OrderSide::Sell => raw_price * (1.0 - ratio), @@ -1231,7 +1278,7 @@ where adjusted *= 1.0 + self.sell_then_buy_delay_slippage_rate; } - self.clamp_execution_price(snapshot, side, adjusted) + Ok(self.clamp_execution_price(snapshot, side, adjusted)) } fn clamp_execution_price( @@ -1266,8 +1313,9 @@ where side: OrderSide, raw_price: f64, quantity: Option, - ) -> f64 { - self.apply_slippage(snapshot, side, raw_price, quantity) + calibration: Option<&HistoricalSlippageCalibration>, + ) -> Result { + self.apply_slippage(snapshot, side, raw_price, quantity, calibration) } fn matching_type_for_algo_request( @@ -1577,7 +1625,7 @@ where .unwrap_or(0); if target_qty > current_qty { let requested_qty = target_qty - current_qty; - if !self.can_afford_minimum_buy(date, portfolio, data, &symbol) { + if !self.can_afford_minimum_buy(date, portfolio, data, &symbol)? { if report.diagnostics.len() < 32 { report.diagnostics.push(format!( "rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells", @@ -3385,7 +3433,7 @@ where price, minimum_order_quantity, order_step_size, - )) + )?) } else { self.round_buy_quantity( (target_value / price).floor() as u32, @@ -3441,15 +3489,17 @@ where let buy_execution_price = data .market(date, &symbol) .map(|snapshot| { - self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(buy_quantity)) + self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(buy_quantity)) }) + .transpose()? .filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0) .unwrap_or(price); let sell_execution_price = data .market(date, &symbol) .map(|snapshot| { - self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(sell_quantity)) + self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(sell_quantity)) }) + .transpose()? .filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0) .unwrap_or(price); if desired_qty < current_qty @@ -3779,7 +3829,7 @@ where continue; } let buy_qty = target_qty - current_qty; - if !self.can_afford_minimum_buy(date, portfolio, data, symbol) { + if !self.can_afford_minimum_buy(date, portfolio, data, symbol)? { if report.diagnostics.len() < 32 { report.diagnostics.push(format!( "rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells", @@ -4283,9 +4333,9 @@ where portfolio: &PortfolioState, data: &DataSet, symbol: &str, - ) -> bool { + ) -> Result { let Some(snapshot) = data.market(date, symbol) else { - return true; + return Ok(true); }; let minimum_order_quantity = self.minimum_order_quantity(data, symbol); let order_step_size = self.order_step_size(data, symbol); @@ -4295,14 +4345,14 @@ where order_step_size, ); if minimum_buy_quantity == 0 { - return false; + return Ok(false); } let minimum_execution_price = - self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(minimum_buy_quantity)); - Self::fixed_cash_fits( + self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(minimum_buy_quantity))?; + Ok(Self::fixed_cash_fits( self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity), portfolio.cash(), - ) + )) } fn process_sell( @@ -4710,7 +4760,7 @@ where None, algo_request, limit_price, - ); + )?; let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) = fill { @@ -4724,7 +4774,7 @@ where ) } else { let execution_price = - self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(fillable_qty)); + self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(fillable_qty))?; if let Some(reason) = self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price) { @@ -6438,7 +6488,7 @@ where value_gross_limit, algo_request, limit_price, - ); + )?; let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) = fill { @@ -6452,7 +6502,7 @@ where ) } else { let execution_price = - self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(constrained_qty)); + self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(constrained_qty))?; if let Some(reason) = self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price) { @@ -6494,10 +6544,11 @@ where let mut blocked_by_final_price = false; if filled_qty > 0 { execution_price = self.snapshot_execution_price( + data, snapshot, OrderSide::Buy, Some(filled_qty), - ); + )?; match self.execution_price_with_limit_slippage_or_rejection( snapshot, OrderSide::Buy, @@ -7085,7 +7136,7 @@ where fallback_price: f64, minimum_order_quantity: u32, order_step_size: u32, - ) -> u32 { + ) -> Result { let snapshot = data.market(date, symbol); let mut quantity = self.value_buy_quantity( date, @@ -7097,8 +7148,9 @@ where for _ in 0..8 { let execution_price = snapshot .map(|snapshot| { - self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity)) + self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(quantity)) }) + .transpose()? .filter(|price| price.is_finite() && *price > 0.0) .unwrap_or(fallback_price); let resolved = self.value_buy_quantity( @@ -7109,27 +7161,28 @@ where order_step_size, ); if resolved == quantity { - return quantity; + return Ok(quantity); } quantity = resolved; } while quantity >= minimum_order_quantity.max(1) { let execution_price = snapshot .map(|snapshot| { - self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity)) + self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(quantity)) }) + .transpose()? .filter(|price| price.is_finite() && *price > 0.0) .unwrap_or(fallback_price); if Self::fixed_cash_fits( self.estimated_buy_cash_out(date, execution_price, quantity), value_budget, ) { - return quantity; + return Ok(quantity); } quantity = self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); } - 0 + Ok(0) } fn decrement_order_quantity( @@ -7396,14 +7449,14 @@ where gross_limit: Option, algo_request: Option<&AlgoExecutionRequest>, limit_price: Option, - ) -> Option { + ) -> Result, BacktestError> { let matching_type = self.matching_type_for_algo_request(algo_request); let post_close_window = self.post_close_execution_window(date); let use_intraday_quotes = post_close_window.is_some() || algo_request.is_some() || self.matching_type_uses_intraday_quotes(); if !use_intraday_quotes { - return None; + return Ok(None); } let runtime_start_time = self.runtime_intraday_start_time.get(); @@ -7430,6 +7483,7 @@ where end_cursor }; let quotes = data.execution_quotes_on(date, symbol); + let calibration = self.slippage_calibration(data, snapshot)?; if let Some(fill) = self.select_execution_fill_with_ledger( symbol, @@ -7448,8 +7502,9 @@ where gross_limit, limit_price, execution_ledger, - ) { - return Some(fill); + calibration.as_ref(), + )? { + return Ok(Some(fill)); } if post_close_window.is_some() @@ -7464,7 +7519,7 @@ where .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time) + Duration::seconds(1)) .unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight")); - return Some(ExecutionFill { + return Ok(Some(ExecutionFill { quantity: 0, next_cursor, legs: Vec::new(), @@ -7475,10 +7530,10 @@ where end_cursor, matching_type == MatchingType::MinuteLast && start_cursor.is_some(), )), - }); + })); } - None + Ok(None) } fn empty_intraday_quote_reason( @@ -7542,7 +7597,9 @@ where gross_limit, limit_price, &IntradayExecutionLedger::default(), + None, ) + .expect("test quote selection without historical calibration") } #[allow(clippy::too_many_arguments)] @@ -7564,9 +7621,10 @@ where gross_limit: Option, limit_price: Option, execution_ledger: &IntradayExecutionLedger, - ) -> Option { + calibration: Option<&HistoricalSlippageCalibration>, + ) -> Result, BacktestError> { if requested_qty == 0 { - return None; + return Ok(None); } let quote_quantity_limited = @@ -7714,7 +7772,7 @@ where } let mut quote_price = - self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); + self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?; if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) { execution_block_reason.get_or_insert(reason); @@ -7734,7 +7792,7 @@ where if let Some(cash) = cash_limit { while take_qty > 0 { quote_price = - self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); + self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?; if !quote_price.is_finite() || quote_price <= 0.0 { budget_block_reason = Some("invalid execution price"); take_qty = 0; @@ -7786,7 +7844,7 @@ where } quote_price = - self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); + self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?; quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) { @@ -7844,7 +7902,7 @@ where if let Some(reason) = execution_block_reason && !saw_non_blocked_execution_price { - return Some(ExecutionFill { + return Ok(Some(ExecutionFill { quantity: 0, next_cursor: execution_block_timestamp .expect("blocked execution quote timestamp") @@ -7852,12 +7910,12 @@ where legs: Vec::new(), liquidity_consumption: Vec::new(), unfilled_reason: Some(reason), - }); + })); } - return None; + return Ok(None); } - Some(ExecutionFill { + Ok(Some(ExecutionFill { quantity: filled_qty, next_cursor: last_timestamp.unwrap() + Duration::seconds(1), legs: if matching_type == MatchingType::Vwap { @@ -7881,7 +7939,7 @@ where } else { None }, - }) + })) } fn quote_has_executable_liquidity( @@ -8442,6 +8500,11 @@ mod tests { let mut snapshot = dated_limit_test_snapshot(date); snapshot.close = 10.0; snapshot.upper_limit = 20.0; + let data = DataSet::from_components( + vec![limit_test_instrument()], vec![snapshot.clone()], Vec::new(), + vec![dated_limit_test_candidate(date, false, false, true, true)], + vec![dated_limit_test_benchmark(date)], + ).unwrap(); for (hour, minute) in [(14, 59), (15, 31)] { broker @@ -8452,7 +8515,7 @@ mod tests { EquityExecutionPhase::ContinuousAuction ); assert_eq!( - broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)), + broker.snapshot_execution_price(&data, &snapshot, OrderSide::Buy, Some(100)).unwrap(), 12.5 ); } @@ -8465,7 +8528,7 @@ mod tests { EquityExecutionPhase::PostCloseFixedPrice ); assert_eq!( - broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)), + broker.snapshot_execution_price(&data, &snapshot, OrderSide::Buy, Some(100)).unwrap(), 10.0 ); } diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 9048139..20599e2 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3007,13 +3007,14 @@ impl PlatformExprStrategy { fn projected_apply_slippage( &self, + ctx: &StrategyContext<'_>, market: &DailyMarketSnapshot, side: OrderSide, raw_price: f64, quantity: Option, - ) -> f64 { + ) -> Result { if !raw_price.is_finite() || raw_price <= 0.0 { - return raw_price; + return Ok(raw_price); } let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64)); let mut adjusted = match self.config.slippage_model { @@ -3033,8 +3034,11 @@ impl PlatformExprStrategy { OrderSide::Sell => raw_price - tick * ticks, } } - SlippageModel::Dynamic(config) => { - let ratio = config.ratio(market, raw_price, order_value); + SlippageModel::HistoricalVolumeVolatility(config) => { + let calibration = crate::broker::HistoricalSlippageCalibration::for_execution( + ctx.data, market.date, &market.symbol, + )?; + let ratio = config.ratio(&calibration, order_value); match side { OrderSide::Buy => raw_price * (1.0 + ratio), OrderSide::Sell => raw_price * (1.0 - ratio), @@ -3047,7 +3051,7 @@ impl PlatformExprStrategy { { adjusted *= 1.0 + self.config.sell_then_buy_delay_slippage_rate; } - Self::projected_clamp_execution_price(market, side, adjusted) + Ok(Self::projected_clamp_execution_price(market, side, adjusted)) } fn projected_clamp_execution_price( @@ -3246,7 +3250,7 @@ impl PlatformExprStrategy { cash_limit: Option, gross_limit: Option, execution_state: &ProjectedExecutionState, - ) -> Option { + ) -> Result, BacktestError> { self.projected_select_execution_fill_at_time( ctx, date, @@ -3280,11 +3284,11 @@ impl PlatformExprStrategy { gross_limit: Option, execution_state: &ProjectedExecutionState, execution_time: Option, - ) -> Option { + ) -> Result, BacktestError> { if requested_qty == 0 { - return None; + return Ok(None); } - let market = ctx.data.market(date, symbol)?; + let Some(market) = ctx.data.market(date, symbol) else { return Ok(None); }; let start_cursor = self.projected_execution_start_cursor_at_time( ctx, @@ -3340,7 +3344,7 @@ impl PlatformExprStrategy { } let mut quote_price = - self.projected_apply_slippage(market, side, raw_quote_price, Some(take_qty)); + self.projected_apply_slippage(ctx, market, side, raw_quote_price, Some(take_qty))?; if self .projected_execution_limit_rejection_reason(market, side, quote_price) .is_some() @@ -3351,11 +3355,12 @@ impl PlatformExprStrategy { if let Some(cash) = cash_limit { while take_qty > 0 { quote_price = self.projected_apply_slippage( + ctx, market, side, raw_quote_price, Some(take_qty), - ); + )?; if self .projected_execution_limit_rejection_reason(market, side, quote_price) .is_some() @@ -3389,7 +3394,7 @@ impl PlatformExprStrategy { } quote_price = - self.projected_apply_slippage(market, side, raw_quote_price, Some(take_qty)); + self.projected_apply_slippage(ctx, market, side, raw_quote_price, Some(take_qty))?; if self .projected_execution_limit_rejection_reason(market, side, quote_price) .is_some() @@ -3405,13 +3410,13 @@ impl PlatformExprStrategy { } if filled_qty == 0 { - return None; + return Ok(None); } - Some(ProjectedExecutionFill { + Ok(Some(ProjectedExecutionFill { price: gross_amount / filled_qty as f64, quantity: filled_qty, next_cursor: last_timestamp.unwrap_or(start_cursor) + Duration::seconds(1), - }) + })) } fn has_execution_quote_at_or_before_at_time( @@ -3442,7 +3447,7 @@ impl PlatformExprStrategy { date: NaiveDate, symbol: &str, execution_state: &mut ProjectedExecutionState, - ) -> Option { + ) -> Result, BacktestError> { self.project_target_zero_at_time(ctx, projected, date, symbol, execution_state, None) } @@ -3454,27 +3459,27 @@ impl PlatformExprStrategy { symbol: &str, execution_state: &mut ProjectedExecutionState, execution_time: Option, - ) -> Option { - let position = projected.position(symbol)?; + ) -> Result, BacktestError> { + let Some(position) = projected.position(symbol) else { return Ok(None); }; let current_qty = position.quantity; let sellable_qty = position.sellable_qty(date); if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) { - return None; + return Ok(None); } let quantity = current_qty.min(sellable_qty); if quantity == 0 { - return None; + return Ok(None); } if !Self::defer_projection_execution_risk(ctx, date) && !self.can_sell_position_at_time(ctx, date, symbol, execution_time) { - return None; + return Ok(None); } - let market = ctx.data.market(date, symbol)?; + let Some(market) = ctx.data.market(date, symbol) else { return Ok(None); }; 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 fill = self + let Some(fill) = self .projected_select_execution_fill_at_time( ctx, date, @@ -3489,7 +3494,7 @@ impl PlatformExprStrategy { None, execution_state, execution_time, - ) + )? .or_else(|| { if self.uses_intraday_execution_quotes() && !Self::defer_projection_execution_risk(ctx, date) @@ -3530,13 +3535,13 @@ impl PlatformExprStrategy { } else { None } - })?; + }) else { return Ok(None); }; let gross_amount = fill.price * fill.quantity as f64; let net_cash = self.sell_net_cash(date, gross_amount); projected .position_mut(symbol) .sell(fill.quantity, fill.price) - .ok()?; + .map_err(BacktestError::Execution)?; projected .apply_cash_delta(net_cash) .expect("projected sell cash must fit fixed-point ledger"); @@ -3548,7 +3553,7 @@ impl PlatformExprStrategy { .execution_cursors .insert(symbol.to_string(), fill.next_cursor); projected.prune_flat_positions(); - Some(fill.quantity) + Ok(Some(fill.quantity)) } fn project_target_value( @@ -3559,34 +3564,35 @@ impl PlatformExprStrategy { symbol: &str, target_value: f64, execution_state: &mut ProjectedExecutionState, - ) -> Option { - let current_qty = projected.position(symbol)?.quantity; + ) -> Result, BacktestError> { + let Some(position) = projected.position(symbol) else { return Ok(None); }; + let current_qty = position.quantity; if current_qty == 0 { - return None; + return Ok(None); } if target_value <= f64::EPSILON { return self.project_target_zero(ctx, projected, date, symbol, execution_state); } - let market = ctx.data.market(date, symbol)?; + let Some(market) = ctx.data.market(date, symbol) else { return Ok(None); }; let current_value = self.projected_target_value_current_position_value(ctx, projected, date, symbol); if !current_value.is_finite() || current_value <= 0.0 { - return None; + return Ok(None); } let cash_delta = target_value.max(0.0) - current_value; if cash_delta.abs() <= f64::EPSILON { - return None; + return Ok(None); } if cash_delta > 0.0 { let result = - self.project_order_value(ctx, projected, date, symbol, cash_delta, execution_state); - return (result.filled_quantity > 0).then_some(result.filled_quantity); + self.project_order_value(ctx, projected, date, symbol, cash_delta, execution_state)?; + return Ok((result.filled_quantity > 0).then_some(result.filled_quantity)); } if !Self::defer_projection_execution_risk(ctx, date) && !self.can_sell_position(ctx, date, symbol) { - return None; + return Ok(None); } let sizing_price = self .scheduled_quote(ctx, date, symbol) @@ -3599,17 +3605,17 @@ impl PlatformExprStrategy { }) .unwrap_or_else(|| self.projected_execution_price(market, OrderSide::Sell)); if !sizing_price.is_finite() || sizing_price <= 0.0 { - return None; + return Ok(None); } 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 sellable_qty = projected.position(symbol)?.sellable_qty(date); + let sellable_qty = position.sellable_qty(date); if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) { - return None; + return Ok(None); } if sellable_qty == 0 { - return None; + return Ok(None); } let requested_qty = self .round_lot_quantity( @@ -3620,9 +3626,9 @@ impl PlatformExprStrategy { .min(current_qty) .min(sellable_qty); if requested_qty == 0 { - return None; + return Ok(None); } - let fill = self.projected_select_execution_fill( + let Some(fill) = self.projected_select_execution_fill( ctx, date, symbol, @@ -3635,13 +3641,13 @@ impl PlatformExprStrategy { None, None, execution_state, - )?; + )? else { return Ok(None); }; let gross_amount = fill.price * fill.quantity as f64; let net_cash = self.sell_net_cash(date, gross_amount); projected .position_mut(symbol) .sell(fill.quantity, fill.price) - .ok()?; + .map_err(BacktestError::Execution)?; projected .apply_cash_delta(net_cash) .expect("projected sell cash must fit fixed-point ledger"); @@ -3653,7 +3659,7 @@ impl PlatformExprStrategy { .execution_cursors .insert(symbol.to_string(), fill.next_cursor); projected.prune_flat_positions(); - Some(fill.quantity) + Ok(Some(fill.quantity)) } fn projected_position_is_flat(projected: &PortfolioState, symbol: &str) -> bool { @@ -3941,7 +3947,7 @@ impl PlatformExprStrategy { symbol, buy_cash, projected_execution_state, - ); + )?; if order_result.was_submitted() { order_intents.push(OrderIntent::Value { symbol: symbol.clone(), @@ -4042,32 +4048,30 @@ impl PlatformExprStrategy { symbol: &str, order_value: f64, execution_state: &mut ProjectedExecutionState, - ) -> ProjectedOrderValueResult { + ) -> Result { if order_value <= 0.0 { - return ProjectedOrderValueResult::not_submitted(); + return Ok(ProjectedOrderValueResult::not_submitted()); } 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 market = match ctx.data.market(date, symbol) { Some(market) => market, - None => return ProjectedOrderValueResult::not_submitted(), + None => return Ok(ProjectedOrderValueResult::not_submitted()), }; let stock = match self.stock_state(ctx, date, symbol) { Ok(stock) => stock, Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { .. })) => { - return ProjectedOrderValueResult::not_submitted(); + return Ok(ProjectedOrderValueResult::not_submitted()); } - Err(_) => return ProjectedOrderValueResult::not_submitted(), + Err(error) => return Err(error), }; if !Self::defer_projection_execution_risk(ctx, date) && self - .buy_rejection_reason(ctx, date, symbol, &stock) - .ok() - .flatten() + .buy_rejection_reason(ctx, date, symbol, &stock)? .is_some() { - return ProjectedOrderValueResult::not_submitted(); + return Ok(ProjectedOrderValueResult::not_submitted()); } let raw_sizing_price = if self.uses_intraday_execution_quotes() { self.scheduled_last_price(ctx, date, symbol) @@ -4076,9 +4080,9 @@ impl PlatformExprStrategy { self.projected_execution_price(market, OrderSide::Buy) }; let sizing_price = - self.projected_apply_slippage(market, OrderSide::Buy, raw_sizing_price, None); + self.projected_apply_slippage(ctx, market, OrderSide::Buy, raw_sizing_price, None)?; if !sizing_price.is_finite() || sizing_price <= 0.0 { - return ProjectedOrderValueResult::not_submitted(); + return Ok(ProjectedOrderValueResult::not_submitted()); } let snapshot_requested_qty = self.value_buy_quantity( projected.cash().min(order_value), @@ -4108,7 +4112,7 @@ impl PlatformExprStrategy { self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); } if quantity == 0 { - return ProjectedOrderValueResult::not_submitted(); + return Ok(ProjectedOrderValueResult::not_submitted()); } let submitted_quantity = quantity; let defer_projection_execution_risk = Self::defer_projection_execution_risk(ctx, date); @@ -4126,7 +4130,7 @@ impl PlatformExprStrategy { Some(cash_limit), gross_limit, execution_state, - ) + )? .or_else(|| { if !defer_projection_execution_risk && ctx.data.has_execution_quotes_on_date(date) @@ -4168,12 +4172,12 @@ impl PlatformExprStrategy { } }); let Some(fill) = fill else { - return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity); + return Ok(ProjectedOrderValueResult::submitted_without_fill(submitted_quantity)); }; let gross_amount = fill.price * fill.quantity as f64; let cash_out = self.buy_cash_out(gross_amount); if !Self::fixed_cash_fits(cash_out, cash_limit) { - return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity); + return Ok(ProjectedOrderValueResult::submitted_without_fill(submitted_quantity)); } projected .apply_cash_delta(-cash_out) @@ -4188,7 +4192,7 @@ impl PlatformExprStrategy { execution_state .execution_cursors .insert(symbol.to_string(), fill.next_cursor); - ProjectedOrderValueResult::submitted_with_fill(submitted_quantity, fill.quantity) + Ok(ProjectedOrderValueResult::submitted_with_fill(submitted_quantity, fill.quantity)) } fn defer_projection_execution_risk(ctx: &StrategyContext<'_>, date: NaiveDate) -> bool { @@ -13022,7 +13026,7 @@ impl PlatformExprStrategy { &symbol, &mut projected_execution_state, Some(delayed_limit_exit_time), - ) + )? .is_some() && Self::projected_position_is_flat(&projected, &symbol) }; @@ -13178,7 +13182,7 @@ impl PlatformExprStrategy { projection_date, &position.symbol, &mut projected_execution_state, - ) + )? .is_some(); if close_submitted { self.refresh_available_cash_after_projected_sell( @@ -13278,7 +13282,7 @@ impl PlatformExprStrategy { &symbol, &mut projected_execution_state, Some(risk_level_forced_exit_time), - ) + )? .is_some(); if close_submitted { self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected); @@ -13333,7 +13337,7 @@ impl PlatformExprStrategy { projection_date, symbol, &mut projected_execution_state, - ); + )?; } else { let current_value = self.projected_position_value_at_execution_price( ctx, @@ -13350,7 +13354,7 @@ impl PlatformExprStrategy { symbol, target_value, &mut projected_execution_state, - ); + )?; } self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected); if Self::projected_position_is_flat(&projected, symbol) { @@ -13504,7 +13508,7 @@ impl PlatformExprStrategy { &position.symbol, target_value, &mut trial_execution_state, - ); + )?; let after_qty = trial_projected .position(&position.symbol) .map(|projected_position| projected_position.quantity) @@ -13599,7 +13603,7 @@ impl PlatformExprStrategy { &symbol, target_value, &mut projected_execution_state, - ); + )?; let after_qty = projected .position(&symbol) .map(|position| position.quantity) @@ -13650,7 +13654,7 @@ impl PlatformExprStrategy { projection_date, &position.symbol, &mut projected_execution_state, - ) + )? .is_some(); if close_submitted { self.refresh_available_cash_after_projected_sell( @@ -13761,7 +13765,7 @@ impl PlatformExprStrategy { projection_date, &position.symbol, &mut projected_execution_state, - ) + )? .is_some(); if close_submitted { self.refresh_available_cash_after_projected_sell( @@ -13849,7 +13853,7 @@ impl PlatformExprStrategy { projection_date, &position.symbol, &mut projected_execution_state, - ) + )? .is_some(); if close_submitted { self.refresh_available_cash_after_projected_sell( @@ -13931,7 +13935,7 @@ impl PlatformExprStrategy { projection_date, &position.symbol, &mut projected_execution_state, - ) + )? .is_some(); if close_submitted { self.refresh_available_cash_after_projected_sell( @@ -14036,7 +14040,7 @@ impl PlatformExprStrategy { &symbol, target_value, &mut projected_execution_state, - ); + )?; } else { self.project_order_value( ctx, @@ -14045,7 +14049,7 @@ impl PlatformExprStrategy { &symbol, target_value, &mut projected_execution_state, - ); + )?; intraday_attempted_buys.insert(symbol.clone()); self.remember_position_entry_date(symbol, signal_date); } @@ -14112,7 +14116,7 @@ impl PlatformExprStrategy { projection_date, symbol, &mut projected_execution_state, - ) + )? .is_some() && Self::projected_position_is_flat(&projected, symbol) { @@ -14172,7 +14176,7 @@ impl PlatformExprStrategy { symbol, target_value, &mut trial_execution_state, - ); + )?; let after_qty = trial_projected .position(symbol) .map(|position| position.quantity) @@ -14235,7 +14239,7 @@ impl PlatformExprStrategy { symbol, target_value, &mut projected_execution_state, - ); + )?; order_intents.push(OrderIntent::TargetValue { symbol: symbol.clone(), target_value, diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index a9616b4..528e9ed 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -1528,7 +1528,6 @@ fn normalize_slippage_model_name(value: &str) -> String { | "price_rate" | "price_ratio_slippage" | "priceratioslippage" => "price_ratio".to_string(), - "dynamic_volume_volatility" => "dynamic".to_string(), other => other.to_string(), } } @@ -1573,7 +1572,13 @@ fn parse_slippage_model( impact_coefficient: Option, volatility_coefficient: Option, max_value: Option, -) -> Option { +) -> Result { + for (name, parameter) in [("slippageValue", value), ("slippageImpactCoefficient", impact_coefficient), + ("slippageVolatilityCoefficient", volatility_coefficient), ("slippageMaxValue", max_value)] { + if parameter.is_some_and(|number| !number.is_finite() || number < 0.0) { + return Err(format!("{name} must be finite and non-negative")); + } + } let value = valid_non_negative(value); let impact_coefficient = valid_non_negative(impact_coefficient); let volatility_coefficient = valid_non_negative(volatility_coefficient); @@ -1590,16 +1595,19 @@ fn parse_slippage_model( }); match model.as_str() { - "none" => Some(SlippageModel::None), - "price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))), - "tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))), - "limit_price" => Some(SlippageModel::LimitPrice), - "dynamic" => Some(SlippageModel::Dynamic(DynamicSlippageConfig::new( + "none" => Ok(SlippageModel::None), + "price_ratio" => Ok(SlippageModel::PriceRatio(value.unwrap_or(0.0))), + "tick_size" => Ok(SlippageModel::TickSize(value.unwrap_or(0.0))), + "limit_price" => Ok(SlippageModel::LimitPrice), + "historical_volume_volatility" => Ok(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new( impact_coefficient.unwrap_or(0.5), volatility_coefficient.unwrap_or(0.3), max_value.or(value).unwrap_or(0.01), ))), - _ => None, + "dynamic" | "dynamic_volume_volatility" => Err( + "retired_slippage_model: dynamic used unfinished daily data; explicitly select historical_volume_volatility or another supported model".into() + ), + _ => Err(format!("unsupported slippageModel: {model}")), } } @@ -1630,15 +1638,13 @@ fn apply_execution_behavior_overrides( || slippage_volatility_coefficient.is_some() || slippage_max_value.is_some() { - if let Some(parsed) = parse_slippage_model( + cfg.slippage_model = parse_slippage_model( slippage_model, slippage_value, slippage_impact_coefficient, slippage_volatility_coefficient, slippage_max_value, - ) { - cfg.slippage_model = parsed; - } + )?; } if strict_value_budget == Some(false) { return Err("strictValueBudget=false is not supported".to_string()); @@ -4337,10 +4343,10 @@ mod tests { } #[test] - fn parses_dynamic_slippage_into_platform_config() { + fn parses_explicit_historical_slippage_into_platform_config() { let spec = serde_json::json!({ "execution": { - "slippageModel": "dynamic", + "slippageModel": "historical_volume_volatility", "slippageImpactCoefficient": 0.6, "slippageVolatilityCoefficient": 0.2, "slippageMaxValue": 0.015 @@ -4351,10 +4357,20 @@ mod tests { assert_eq!( cfg.slippage_model, - SlippageModel::Dynamic(DynamicSlippageConfig::new(0.6, 0.2, 0.015)) + SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(0.6, 0.2, 0.015)) ); } + #[test] + fn retired_or_unknown_slippage_models_do_not_fall_back_to_fixed_or_none() { + for model in ["dynamic", "dynamic_volume_volatility", "dynamic-volume-volatility", "unknown"] { + let spec = serde_json::json!({"execution": {"slippageModel": model, "slippageValue": 0.002}}); + assert!(platform_expr_config_from_value("", "", &spec).is_err(), "{model}"); + } + let spec = serde_json::json!({"execution": {"slippageModel": "historical_volume_volatility", "slippageImpactCoefficient": -1}}); + assert!(platform_expr_config_from_value("", "", &spec).is_err()); + } + #[test] fn engine_stock_ma_filter_generates_price_and_volume_expr() { let spec = serde_json::json!({