test: enforce causal historical slippage and reject missing calibration

This commit is contained in:
boris
2026-09-11 11:42:21 +08:00
parent 98199c02a2
commit 1fc8a3a9e6
3 changed files with 128 additions and 35 deletions
+97 -7
View File
@@ -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>,
) -> f64 {
) -> Result<f64, BacktestError> {
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 {
+12 -12
View File
@@ -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());