diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index b1b0b78..1bfe970 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -15008,6 +15008,31 @@ mod tests { assert_eq!(shared.version_sha256(),shared_version); } + #[test] + fn projected_historical_slippage_does_not_swallow_missing_calibration() { + let date = d(2025, 1, 7); + let symbol = "000001.SZ"; + let data = single_symbol_platform_data(&[date], symbol); + let portfolio = PortfolioState::new(100_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, decision_date: date, decision_index: 0, data: &data, portfolio: &portfolio, + futures_account: None, open_orders: &[], dynamic_universe: None, subscriptions: &subscriptions, + process_events: &[], active_process_event: None, active_datetime: Some(date.and_hms_opt(15, 0, 0).unwrap()), + order_events: &[], fills: &[], + }; + let mut config = PlatformExprStrategyConfig::generic(); + config.signal_symbol = symbol.into(); + config.slippage_model = SlippageModel::HistoricalVolumeVolatility(crate::DynamicSlippageConfig::default()); + let strategy = PlatformExprStrategy::new(config); + let mut projected = portfolio.clone(); + let result = strategy.project_order_value(&ctx, &mut projected, date, symbol, 50_000.0, &mut ProjectedExecutionState::default()); + let error = result.expect_err("calibration failures must reach the strategy caller"); + assert!(error.to_string().contains("historical_slippage_calibration_missing"), "{error}"); + assert_eq!(projected.cash(), portfolio.cash()); + assert!(projected.positions().is_empty()); + } + #[test] fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() { use std::sync::Mutex; diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 528e9ed..e6d4e94 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -1579,10 +1579,6 @@ fn parse_slippage_model( 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); - let max_value = valid_non_negative(max_value); let model = model .map(normalize_slippage_model_name) .filter(|item| !item.is_empty()) @@ -1599,11 +1595,15 @@ fn parse_slippage_model( "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), - ))), + "historical_volume_volatility" => { + let max_ratio = max_value.or(value).unwrap_or(0.01); + if max_ratio >= 1.0 { + return Err("historical slippage maximum must be less than 1".into()); + } + Ok(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new( + impact_coefficient.unwrap_or(0.5), volatility_coefficient.unwrap_or(0.3), max_ratio, + ))) + }, "dynamic" | "dynamic_volume_volatility" => Err( "retired_slippage_model: dynamic used unfinished daily data; explicitly select historical_volume_volatility or another supported model".into() ),