test: enforce causal historical slippage and reject missing calibration
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user