diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 685ee93..191f4ba 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -292,42 +292,76 @@ pub struct DynamicSlippageConfig { impl DynamicSlippageConfig { pub fn new(impact_coefficient: f64, volatility_coefficient: f64, max_ratio: f64) -> Self { Self { - impact_coefficient: impact_coefficient.max(0.0), - volatility_coefficient: volatility_coefficient.max(0.0), - max_ratio: max_ratio.max(0.0), + impact_coefficient, + volatility_coefficient, + max_ratio, } } 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); + ) -> Result { + if [self.impact_coefficient, self.volatility_coefficient, self.max_ratio] + .into_iter().any(|value| !value.is_finite() || value < 0.0) + || self.max_ratio >= 1.0 + || order_value.is_some_and(|value| !value.is_finite() || value < 0.0) + { + return Err(BacktestError::Execution("invalid_historical_slippage_parameters_or_order_value".into())); + } 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 + let impact = if self.impact_coefficient == 0.0 { 0.0 } else { impact_ratio * self.impact_coefficient }; + let ratio = impact + + calibration.range_ratio * self.volatility_coefficient; + Ok(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() { - ((snapshot.high - snapshot.low).abs() / volatility_base).max(0.0) - } else { - 0.0 - }; - let ratio = - impact_ratio * self.impact_coefficient + volatility * self.volatility_coefficient; - ratio.clamp(0.0, self.max_ratio) + 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, + }) } } @@ -343,7 +377,7 @@ pub enum SlippageModel { PriceRatio(f64), TickSize(f64), LimitPrice, - Dynamic(DynamicSlippageConfig), + HistoricalVolumeVolatility(DynamicSlippageConfig), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1109,12 +1143,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 +1234,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 +1267,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 +1286,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 +1321,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 +1633,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 +3441,7 @@ where price, minimum_order_quantity, order_step_size, - )) + )?) } else { self.round_buy_quantity( (target_value / price).floor() as u32, @@ -3441,15 +3497,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 +3837,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 +4341,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 +4353,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 +4768,7 @@ where None, algo_request, limit_price, - ); + )?; let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) = fill { @@ -4724,7 +4782,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 +6496,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 +6510,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 +6552,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 +7144,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 +7156,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 +7169,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 +7457,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 +7491,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 +7510,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 +7527,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 +7538,10 @@ where end_cursor, matching_type == MatchingType::MinuteLast && start_cursor.is_some(), )), - }); + })); } - None + Ok(None) } fn empty_intraday_quote_reason( @@ -7542,7 +7605,9 @@ where gross_limit, limit_price, &IntradayExecutionLedger::default(), + None, ) + .expect("test quote selection without historical calibration") } #[allow(clippy::too_many_arguments)] @@ -7564,9 +7629,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 +7780,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 +7800,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 +7852,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 +7910,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 +7918,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 +7947,7 @@ where } else { None }, - }) + })) } fn quote_has_executable_liquidity( @@ -8129,6 +8195,88 @@ mod tests { } } + #[test] + fn historical_slippage_does_not_read_later_daily_fields_for_open_or_minute_fills() { + let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(); + let build_data = |changed: bool| { + let mut prior = dated_limit_test_snapshot(previous); + prior.timestamp = None; + let mut current = dated_limit_test_snapshot(date); + current.timestamp = None; + if changed { + current.high = 10.9; + current.low = 9.1; + current.close = 10.8; + current.volume = 400; + } + let mut quote = limit_test_quote(10.0, 10.0, 10.0); + quote.date = date; + quote.timestamp = date.and_hms_opt(13, 7, 0).unwrap(); + DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], vec![prior, current], Vec::new(), + vec![dated_limit_test_candidate(previous, false, false, true, true), dated_limit_test_candidate(date, false, false, true, true)], + vec![dated_limit_test_benchmark(previous), dated_limit_test_benchmark(date)], + Vec::new(), vec![quote], + ).unwrap() + }; + let decision = StrategyDecision { + order_intents: vec![OrderIntent::Value { + symbol: "000001.SZ".into(), value: 50_000.0, reason: "historical_model_invariance".into(), + }], ..StrategyDecision::default() + }; + for matching in [MatchingType::NextBarOpen, MatchingType::MinuteLast] { + let mut results = Vec::new(); + for changed in [false, true] { + let data = build_data(changed); + let mut broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(matching) + .with_volume_limit(false).with_liquidity_limit(false) + .with_slippage_model(SlippageModel::HistoricalVolumeVolatility(super::DynamicSlippageConfig::new(0.5, 0.3, 0.1))); + if matching == MatchingType::MinuteLast { + broker = broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(13, 7, 0).unwrap()); + } + let mut account = PortfolioState::new(1_000_000.0); + let report = broker.execute_with_event_dates(date, previous, previous, &mut account, &data, &decision).unwrap(); + assert_eq!(report.fill_events.len(), 1, "{report:?}"); + results.push((serde_json::to_value(&report.fill_events).unwrap(), account.cash())); + } + assert_eq!(results[0], results[1], "{matching:?}"); + } + } + + #[test] + fn historical_slippage_rejects_missing_future_or_invalid_calibration_without_raw_price_fallback() { + let snapshot = limit_test_snapshot(); + let date = snapshot.date; + assert!(super::HistoricalSlippageCalibration::from_completed_snapshot(&snapshot, date).is_err()); + let later = date + chrono::Duration::days(1); + let mut bad = snapshot.clone(); + bad.volume = 0; + assert!(super::HistoricalSlippageCalibration::from_completed_snapshot(&bad, later).is_err()); + bad = snapshot.clone(); + bad.high = f64::NAN; + assert!(super::HistoricalSlippageCalibration::from_completed_snapshot(&bad, later).is_err()); + let calibration = super::HistoricalSlippageCalibration::from_completed_snapshot(&snapshot, later).unwrap(); + assert!(super::DynamicSlippageConfig::new(f64::NAN, 0.3, 0.1).ratio(&calibration, Some(100.0)).is_err()); + assert!(super::DynamicSlippageConfig::new(-1.0, 0.3, 0.1).ratio(&calibration, Some(100.0)).is_err()); + assert!(super::DynamicSlippageConfig::new(0.5, 0.3, 1.0).ratio(&calibration, Some(100.0)).is_err()); + + let data = DataSet::from_components(vec![limit_test_instrument()], vec![snapshot], Vec::new(), + vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap(); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_limit(false).with_liquidity_limit(false) + .with_slippage_model(SlippageModel::HistoricalVolumeVolatility(super::DynamicSlippageConfig::default())); + let mut account = PortfolioState::new(1_000_000.0); + let decision = StrategyDecision { order_intents: vec![OrderIntent::Value { + symbol: "000001.SZ".into(), value: 50_000.0, reason: "missing_calibration".into(), + }], ..StrategyDecision::default() }; + let error = broker.execute(date, &mut account, &data, &decision).unwrap_err(); + assert!(error.to_string().contains("historical_slippage_calibration_missing"), "{error}"); + assert_eq!(account.cash(), 1_000_000.0); + assert!(account.positions().is_empty()); + } + fn limit_test_candidate(allow_buy: bool, allow_sell: bool) -> CandidateEligibility { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); CandidateEligibility { @@ -8442,6 +8590,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 +8605,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 +8618,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 7826c49..1455b67 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3011,13 +3011,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 { @@ -3037,8 +3038,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), @@ -3051,7 +3055,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( @@ -3250,7 +3254,7 @@ impl PlatformExprStrategy { cash_limit: Option, gross_limit: Option, execution_state: &ProjectedExecutionState, - ) -> Option { + ) -> Result, BacktestError> { self.projected_select_execution_fill_at_time( ctx, date, @@ -3284,11 +3288,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, @@ -3344,7 +3348,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() @@ -3355,11 +3359,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() @@ -3393,7 +3398,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() @@ -3409,13 +3414,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( @@ -3446,7 +3451,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) } @@ -3458,27 +3463,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, @@ -3493,7 +3498,7 @@ impl PlatformExprStrategy { None, execution_state, execution_time, - ) + )? .or_else(|| { if self.uses_intraday_execution_quotes() && !Self::defer_projection_execution_risk(ctx, date) @@ -3534,13 +3539,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"); @@ -3552,7 +3557,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( @@ -3563,34 +3568,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) @@ -3603,17 +3609,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( @@ -3624,9 +3630,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, @@ -3639,13 +3645,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"); @@ -3657,7 +3663,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 { @@ -3945,7 +3951,7 @@ impl PlatformExprStrategy { symbol, buy_cash, projected_execution_state, - ); + )?; if order_result.was_submitted() { order_intents.push(OrderIntent::Value { symbol: symbol.clone(), @@ -4046,32 +4052,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) @@ -4080,9 +4084,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), @@ -4112,7 +4116,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); @@ -4130,7 +4134,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) @@ -4172,12 +4176,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) @@ -4192,7 +4196,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 { @@ -13032,7 +13036,7 @@ impl PlatformExprStrategy { &symbol, &mut projected_execution_state, Some(delayed_limit_exit_time), - ) + )? .is_some() && Self::projected_position_is_flat(&projected, &symbol) }; @@ -13188,7 +13192,7 @@ impl PlatformExprStrategy { projection_date, &position.symbol, &mut projected_execution_state, - ) + )? .is_some(); if close_submitted { self.refresh_available_cash_after_projected_sell( @@ -13288,7 +13292,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); @@ -13343,7 +13347,7 @@ impl PlatformExprStrategy { projection_date, symbol, &mut projected_execution_state, - ); + )?; } else { let current_value = self.projected_position_value_at_execution_price( ctx, @@ -13360,7 +13364,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) { @@ -13514,7 +13518,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) @@ -13609,7 +13613,7 @@ impl PlatformExprStrategy { &symbol, target_value, &mut projected_execution_state, - ); + )?; let after_qty = projected .position(&symbol) .map(|position| position.quantity) @@ -13660,7 +13664,7 @@ impl PlatformExprStrategy { projection_date, &position.symbol, &mut projected_execution_state, - ) + )? .is_some(); if close_submitted { self.refresh_available_cash_after_projected_sell( @@ -13771,7 +13775,7 @@ impl PlatformExprStrategy { projection_date, &position.symbol, &mut projected_execution_state, - ) + )? .is_some(); if close_submitted { self.refresh_available_cash_after_projected_sell( @@ -13859,7 +13863,7 @@ impl PlatformExprStrategy { projection_date, &position.symbol, &mut projected_execution_state, - ) + )? .is_some(); if close_submitted { self.refresh_available_cash_after_projected_sell( @@ -13941,7 +13945,7 @@ impl PlatformExprStrategy { projection_date, &position.symbol, &mut projected_execution_state, - ) + )? .is_some(); if close_submitted { self.refresh_available_cash_after_projected_sell( @@ -14046,7 +14050,7 @@ impl PlatformExprStrategy { &symbol, target_value, &mut projected_execution_state, - ); + )?; } else { self.project_order_value( ctx, @@ -14055,7 +14059,7 @@ impl PlatformExprStrategy { &symbol, target_value, &mut projected_execution_state, - ); + )?; intraday_attempted_buys.insert(symbol.clone()); self.remember_position_entry_date(symbol, signal_date); } @@ -14122,7 +14126,7 @@ impl PlatformExprStrategy { projection_date, symbol, &mut projected_execution_state, - ) + )? .is_some() && Self::projected_position_is_flat(&projected, symbol) { @@ -14182,7 +14186,7 @@ impl PlatformExprStrategy { symbol, target_value, &mut trial_execution_state, - ); + )?; let after_qty = trial_projected .position(symbol) .map(|position| position.quantity) @@ -14245,7 +14249,7 @@ impl PlatformExprStrategy { symbol, target_value, &mut projected_execution_state, - ); + )?; order_intents.push(OrderIntent::TargetValue { symbol: symbol.clone(), target_value, @@ -15014,6 +15018,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 super::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; @@ -16049,7 +16078,7 @@ mod tests { symbol, 3_410.0, &mut execution_state, - ), + ).unwrap(), Some(200) ); assert_eq!(projected.position(symbol).unwrap().quantity, 300); @@ -17729,7 +17758,7 @@ mod tests { symbol, 125_000.0, &mut execution_state, - ); + ).unwrap(); assert_eq!(result.filled_quantity, 24_400); let position = projected.position(symbol).expect("position"); @@ -17863,7 +17892,7 @@ mod tests { symbol, 125_000.0, &mut execution_state, - ); + ).unwrap(); assert_eq!(result.filled_quantity, 24_400); } @@ -18095,7 +18124,7 @@ mod tests { symbol, target_value, &mut execution_state, - ); + ).unwrap(); assert_eq!(filled, Some(500)); assert_eq!(projected.position(symbol).unwrap().quantity, 19_100); @@ -24609,7 +24638,7 @@ mod tests { symbol, target_value, &mut execution_state, - ) + ).unwrap() .expect("target adjustment should buy"); assert_eq!(filled, 200); @@ -33053,7 +33082,7 @@ mod tests { let mut execution_state = super::ProjectedExecutionState::default(); let filled = - strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state); + strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state).unwrap(); assert_eq!(filled, Some(100)); assert!( @@ -33197,7 +33226,7 @@ mod tests { symbol, 10_000.0, &mut execution_state, - ); + ).unwrap(); assert_eq!(result.filled_quantity, 0); assert_eq!( @@ -33347,7 +33376,7 @@ mod tests { symbol, 10_000.0, &mut execution_state, - ); + ).unwrap(); assert!(result.filled_quantity > 0); assert_eq!( @@ -33467,7 +33496,7 @@ mod tests { decision_date, symbol, &mut execution_state, - ); + ).unwrap(); assert_eq!(filled, Some(1_000)); assert!(projected.position(symbol).is_none()); @@ -33571,7 +33600,7 @@ mod tests { let mut execution_state = super::ProjectedExecutionState::default(); let filled = - strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state); + strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state).unwrap(); assert_eq!(filled, None); assert_eq!(projected.cash(), portfolio.cash()); @@ -33677,7 +33706,7 @@ mod tests { let mut execution_state = super::ProjectedExecutionState::default(); let filled = - strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state); + strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state).unwrap(); assert_eq!(filled, None); assert_eq!(projected.cash(), portfolio.cash()); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 87be193..cd7284b 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -1530,7 +1530,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(), } } @@ -1575,11 +1574,13 @@ fn parse_slippage_model( impact_coefficient: Option, volatility_coefficient: Option, max_value: Option, -) -> Option { - 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); +) -> 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 model = model .map(normalize_slippage_model_name) .filter(|item| !item.is_empty()) @@ -1592,16 +1593,23 @@ 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( - impact_coefficient.unwrap_or(0.5), - volatility_coefficient.unwrap_or(0.3), - max_value.or(value).unwrap_or(0.01), - ))), - _ => None, + "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" => { + 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() + ), + _ => Err(format!("unsupported slippageModel: {model}")), } } @@ -1632,15 +1640,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()); @@ -4366,10 +4372,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 @@ -4380,10 +4386,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!({ diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index f2fb863..4374e15 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -1740,8 +1740,9 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() { } #[test] -fn broker_applies_dynamic_slippage_on_snapshot_fills() { +fn broker_applies_explicit_historical_slippage_on_snapshot_fills() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let previous_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(); let data = DataSet::from_components( vec![Instrument { symbol: "000002.SZ".to_string(), @@ -1752,20 +1753,20 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() { delisted_at: None, status: "active".to_string(), }], - vec![DailyMarketSnapshot { - date, + [previous_date, date].into_iter().map(|day| DailyMarketSnapshot { + date: day, symbol: "000002.SZ".to_string(), - timestamp: Some("2024-01-10 10:18:00".to_string()), + timestamp: Some(format!("{day} 15:00:00")), day_open: 10.0, open: 10.0, - high: 10.1, - low: 9.9, - close: 10.0, + high: if day == previous_date { 10.1 } else { 10.9 }, + low: if day == previous_date { 9.9 } else { 9.1 }, + close: if day == previous_date { 10.0 } else { 10.8 }, last_price: 10.0, bid1: 9.99, ask1: 10.01, prev_close: 10.0, - volume: 100_000, + volume: if day == previous_date { 100_000 } else { 2_000_000 }, minute_volume: 100_000, bid1_volume: 80_000, ask1_volume: 80_000, @@ -1774,7 +1775,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() { upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, - }], + }).collect(), vec![DailyFactorSnapshot { date, symbol: "000002.SZ".to_string(), @@ -1786,8 +1787,8 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() { adjustment_factor_backward1: None, extra_factors: BTreeMap::new(), }], - vec![CandidateEligibility { - date, + [previous_date, date].into_iter().map(|day| CandidateEligibility { + date: day, symbol: "000002.SZ".to_string(), is_st: false, is_star_st: false, @@ -1798,15 +1799,15 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() { is_kcb: false, is_one_yuan: false, risk_level_code: None, - }], - vec![BenchmarkSnapshot { - date, + }).collect(), + [previous_date, date].into_iter().map(|day| BenchmarkSnapshot { + date: day, benchmark: "000300.SH".to_string(), open: 100.0, close: 100.0, prev_close: 99.0, volume: 1_000_000, - }], + }).collect(), ) .expect("dataset"); let mut portfolio = PortfolioState::new(1_000_000.0); @@ -1815,7 +1816,9 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() { ChinaEquityRuleHooks::default(), PriceField::Open, ) - .with_slippage_model(SlippageModel::Dynamic(DynamicSlippageConfig::new( + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_slippage_model(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new( 0.5, 0.3, 0.1, )));