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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
)));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user