diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 7e83f37..191f4ba 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -292,9 +292,9 @@ 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, } } @@ -302,16 +302,24 @@ impl DynamicSlippageConfig { &self, calibration: &HistoricalSlippageCalibration, order_value: Option, - ) -> f64 { + ) -> 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 => { value / calibration.turnover_proxy } _ => 0.0, }; - let ratio = impact_ratio * self.impact_coefficient + 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; - ratio.clamp(0.0, self.max_ratio) + Ok(ratio.clamp(0.0, self.max_ratio)) } } @@ -1264,7 +1272,7 @@ where .ok_or_else(|| BacktestError::Execution(format!( "historical_slippage_calibration_required symbol={} execution_date={}", snapshot.symbol, snapshot.date, )))?; - let ratio = config.ratio(calibration, order_value); + let ratio = config.ratio(calibration, order_value)?; match side { OrderSide::Buy => raw_price * (1.0 + ratio), OrderSide::Sell => raw_price * (1.0 - ratio), @@ -8187,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 { diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 20599e2..b1b0b78 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3038,7 +3038,7 @@ impl PlatformExprStrategy { let calibration = crate::broker::HistoricalSlippageCalibration::for_execution( ctx.data, market.date, &market.symbol, )?; - let ratio = config.ratio(&calibration, order_value); + let ratio = config.ratio(&calibration, order_value)?; match side { OrderSide::Buy => raw_price * (1.0 + ratio), OrderSide::Sell => raw_price * (1.0 - ratio), @@ -16043,7 +16043,7 @@ mod tests { symbol, 3_410.0, &mut execution_state, - ), + ).unwrap(), Some(200) ); assert_eq!(projected.position(symbol).unwrap().quantity, 300); @@ -17723,7 +17723,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"); @@ -17857,7 +17857,7 @@ mod tests { symbol, 125_000.0, &mut execution_state, - ); + ).unwrap(); assert_eq!(result.filled_quantity, 24_400); } @@ -18089,7 +18089,7 @@ mod tests { symbol, target_value, &mut execution_state, - ); + ).unwrap(); assert_eq!(filled, Some(500)); assert_eq!(projected.position(symbol).unwrap().quantity, 19_100); @@ -24603,7 +24603,7 @@ mod tests { symbol, target_value, &mut execution_state, - ) + ).unwrap() .expect("target adjustment should buy"); assert_eq!(filled, 200); @@ -33047,7 +33047,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!( @@ -33191,7 +33191,7 @@ mod tests { symbol, 10_000.0, &mut execution_state, - ); + ).unwrap(); assert_eq!(result.filled_quantity, 0); assert_eq!( @@ -33341,7 +33341,7 @@ mod tests { symbol, 10_000.0, &mut execution_state, - ); + ).unwrap(); assert!(result.filled_quantity > 0); assert_eq!( @@ -33461,7 +33461,7 @@ mod tests { decision_date, symbol, &mut execution_state, - ); + ).unwrap(); assert_eq!(filled, Some(1_000)); assert!(projected.position(symbol).is_none()); @@ -33565,7 +33565,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()); @@ -33671,7 +33671,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/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, )));