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());
+19 -16
View File
@@ -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,
)));