test: enforce causal historical slippage and reject missing calibration
This commit is contained in:
@@ -292,9 +292,9 @@ pub struct DynamicSlippageConfig {
|
|||||||
impl DynamicSlippageConfig {
|
impl DynamicSlippageConfig {
|
||||||
pub fn new(impact_coefficient: f64, volatility_coefficient: f64, max_ratio: f64) -> Self {
|
pub fn new(impact_coefficient: f64, volatility_coefficient: f64, max_ratio: f64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
impact_coefficient: impact_coefficient.max(0.0),
|
impact_coefficient,
|
||||||
volatility_coefficient: volatility_coefficient.max(0.0),
|
volatility_coefficient,
|
||||||
max_ratio: max_ratio.max(0.0),
|
max_ratio,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,16 +302,24 @@ impl DynamicSlippageConfig {
|
|||||||
&self,
|
&self,
|
||||||
calibration: &HistoricalSlippageCalibration,
|
calibration: &HistoricalSlippageCalibration,
|
||||||
order_value: Option<f64>,
|
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 {
|
let impact_ratio = match order_value {
|
||||||
Some(value) if value.is_finite() && value > 0.0 => {
|
Some(value) if value.is_finite() && value > 0.0 => {
|
||||||
value / calibration.turnover_proxy
|
value / calibration.turnover_proxy
|
||||||
}
|
}
|
||||||
_ => 0.0,
|
_ => 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;
|
+ 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!(
|
.ok_or_else(|| BacktestError::Execution(format!(
|
||||||
"historical_slippage_calibration_required symbol={} execution_date={}", snapshot.symbol, snapshot.date,
|
"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 {
|
match side {
|
||||||
OrderSide::Buy => raw_price * (1.0 + ratio),
|
OrderSide::Buy => raw_price * (1.0 + ratio),
|
||||||
OrderSide::Sell => 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 {
|
fn limit_test_candidate(allow_buy: bool, allow_sell: bool) -> CandidateEligibility {
|
||||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
CandidateEligibility {
|
CandidateEligibility {
|
||||||
|
|||||||
@@ -3038,7 +3038,7 @@ impl PlatformExprStrategy {
|
|||||||
let calibration = crate::broker::HistoricalSlippageCalibration::for_execution(
|
let calibration = crate::broker::HistoricalSlippageCalibration::for_execution(
|
||||||
ctx.data, market.date, &market.symbol,
|
ctx.data, market.date, &market.symbol,
|
||||||
)?;
|
)?;
|
||||||
let ratio = config.ratio(&calibration, order_value);
|
let ratio = config.ratio(&calibration, order_value)?;
|
||||||
match side {
|
match side {
|
||||||
OrderSide::Buy => raw_price * (1.0 + ratio),
|
OrderSide::Buy => raw_price * (1.0 + ratio),
|
||||||
OrderSide::Sell => raw_price * (1.0 - ratio),
|
OrderSide::Sell => raw_price * (1.0 - ratio),
|
||||||
@@ -16043,7 +16043,7 @@ mod tests {
|
|||||||
symbol,
|
symbol,
|
||||||
3_410.0,
|
3_410.0,
|
||||||
&mut execution_state,
|
&mut execution_state,
|
||||||
),
|
).unwrap(),
|
||||||
Some(200)
|
Some(200)
|
||||||
);
|
);
|
||||||
assert_eq!(projected.position(symbol).unwrap().quantity, 300);
|
assert_eq!(projected.position(symbol).unwrap().quantity, 300);
|
||||||
@@ -17723,7 +17723,7 @@ mod tests {
|
|||||||
symbol,
|
symbol,
|
||||||
125_000.0,
|
125_000.0,
|
||||||
&mut execution_state,
|
&mut execution_state,
|
||||||
);
|
).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.filled_quantity, 24_400);
|
assert_eq!(result.filled_quantity, 24_400);
|
||||||
let position = projected.position(symbol).expect("position");
|
let position = projected.position(symbol).expect("position");
|
||||||
@@ -17857,7 +17857,7 @@ mod tests {
|
|||||||
symbol,
|
symbol,
|
||||||
125_000.0,
|
125_000.0,
|
||||||
&mut execution_state,
|
&mut execution_state,
|
||||||
);
|
).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.filled_quantity, 24_400);
|
assert_eq!(result.filled_quantity, 24_400);
|
||||||
}
|
}
|
||||||
@@ -18089,7 +18089,7 @@ mod tests {
|
|||||||
symbol,
|
symbol,
|
||||||
target_value,
|
target_value,
|
||||||
&mut execution_state,
|
&mut execution_state,
|
||||||
);
|
).unwrap();
|
||||||
|
|
||||||
assert_eq!(filled, Some(500));
|
assert_eq!(filled, Some(500));
|
||||||
assert_eq!(projected.position(symbol).unwrap().quantity, 19_100);
|
assert_eq!(projected.position(symbol).unwrap().quantity, 19_100);
|
||||||
@@ -24603,7 +24603,7 @@ mod tests {
|
|||||||
symbol,
|
symbol,
|
||||||
target_value,
|
target_value,
|
||||||
&mut execution_state,
|
&mut execution_state,
|
||||||
)
|
).unwrap()
|
||||||
.expect("target adjustment should buy");
|
.expect("target adjustment should buy");
|
||||||
|
|
||||||
assert_eq!(filled, 200);
|
assert_eq!(filled, 200);
|
||||||
@@ -33047,7 +33047,7 @@ mod tests {
|
|||||||
let mut execution_state = super::ProjectedExecutionState::default();
|
let mut execution_state = super::ProjectedExecutionState::default();
|
||||||
|
|
||||||
let filled =
|
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_eq!(filled, Some(100));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -33191,7 +33191,7 @@ mod tests {
|
|||||||
symbol,
|
symbol,
|
||||||
10_000.0,
|
10_000.0,
|
||||||
&mut execution_state,
|
&mut execution_state,
|
||||||
);
|
).unwrap();
|
||||||
|
|
||||||
assert_eq!(result.filled_quantity, 0);
|
assert_eq!(result.filled_quantity, 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -33341,7 +33341,7 @@ mod tests {
|
|||||||
symbol,
|
symbol,
|
||||||
10_000.0,
|
10_000.0,
|
||||||
&mut execution_state,
|
&mut execution_state,
|
||||||
);
|
).unwrap();
|
||||||
|
|
||||||
assert!(result.filled_quantity > 0);
|
assert!(result.filled_quantity > 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -33461,7 +33461,7 @@ mod tests {
|
|||||||
decision_date,
|
decision_date,
|
||||||
symbol,
|
symbol,
|
||||||
&mut execution_state,
|
&mut execution_state,
|
||||||
);
|
).unwrap();
|
||||||
|
|
||||||
assert_eq!(filled, Some(1_000));
|
assert_eq!(filled, Some(1_000));
|
||||||
assert!(projected.position(symbol).is_none());
|
assert!(projected.position(symbol).is_none());
|
||||||
@@ -33565,7 +33565,7 @@ mod tests {
|
|||||||
let mut execution_state = super::ProjectedExecutionState::default();
|
let mut execution_state = super::ProjectedExecutionState::default();
|
||||||
|
|
||||||
let filled =
|
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!(filled, None);
|
||||||
assert_eq!(projected.cash(), portfolio.cash());
|
assert_eq!(projected.cash(), portfolio.cash());
|
||||||
@@ -33671,7 +33671,7 @@ mod tests {
|
|||||||
let mut execution_state = super::ProjectedExecutionState::default();
|
let mut execution_state = super::ProjectedExecutionState::default();
|
||||||
|
|
||||||
let filled =
|
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!(filled, None);
|
||||||
assert_eq!(projected.cash(), portfolio.cash());
|
assert_eq!(projected.cash(), portfolio.cash());
|
||||||
|
|||||||
@@ -1740,8 +1740,9 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 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(
|
let data = DataSet::from_components(
|
||||||
vec![Instrument {
|
vec![Instrument {
|
||||||
symbol: "000002.SZ".to_string(),
|
symbol: "000002.SZ".to_string(),
|
||||||
@@ -1752,20 +1753,20 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
delisted_at: None,
|
delisted_at: None,
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
}],
|
}],
|
||||||
vec![DailyMarketSnapshot {
|
[previous_date, date].into_iter().map(|day| DailyMarketSnapshot {
|
||||||
date,
|
date: day,
|
||||||
symbol: "000002.SZ".to_string(),
|
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,
|
day_open: 10.0,
|
||||||
open: 10.0,
|
open: 10.0,
|
||||||
high: 10.1,
|
high: if day == previous_date { 10.1 } else { 10.9 },
|
||||||
low: 9.9,
|
low: if day == previous_date { 9.9 } else { 9.1 },
|
||||||
close: 10.0,
|
close: if day == previous_date { 10.0 } else { 10.8 },
|
||||||
last_price: 10.0,
|
last_price: 10.0,
|
||||||
bid1: 9.99,
|
bid1: 9.99,
|
||||||
ask1: 10.01,
|
ask1: 10.01,
|
||||||
prev_close: 10.0,
|
prev_close: 10.0,
|
||||||
volume: 100_000,
|
volume: if day == previous_date { 100_000 } else { 2_000_000 },
|
||||||
minute_volume: 100_000,
|
minute_volume: 100_000,
|
||||||
bid1_volume: 80_000,
|
bid1_volume: 80_000,
|
||||||
ask1_volume: 80_000,
|
ask1_volume: 80_000,
|
||||||
@@ -1774,7 +1775,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
upper_limit: 11.0,
|
upper_limit: 11.0,
|
||||||
lower_limit: 9.0,
|
lower_limit: 9.0,
|
||||||
price_tick: 0.01,
|
price_tick: 0.01,
|
||||||
}],
|
}).collect(),
|
||||||
vec![DailyFactorSnapshot {
|
vec![DailyFactorSnapshot {
|
||||||
date,
|
date,
|
||||||
symbol: "000002.SZ".to_string(),
|
symbol: "000002.SZ".to_string(),
|
||||||
@@ -1786,8 +1787,8 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
adjustment_factor_backward1: None,
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
[previous_date, date].into_iter().map(|day| CandidateEligibility {
|
||||||
date,
|
date: day,
|
||||||
symbol: "000002.SZ".to_string(),
|
symbol: "000002.SZ".to_string(),
|
||||||
is_st: false,
|
is_st: false,
|
||||||
is_star_st: false,
|
is_star_st: false,
|
||||||
@@ -1798,15 +1799,15 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
is_kcb: false,
|
is_kcb: false,
|
||||||
is_one_yuan: false,
|
is_one_yuan: false,
|
||||||
risk_level_code: None,
|
risk_level_code: None,
|
||||||
}],
|
}).collect(),
|
||||||
vec![BenchmarkSnapshot {
|
[previous_date, date].into_iter().map(|day| BenchmarkSnapshot {
|
||||||
date,
|
date: day,
|
||||||
benchmark: "000300.SH".to_string(),
|
benchmark: "000300.SH".to_string(),
|
||||||
open: 100.0,
|
open: 100.0,
|
||||||
close: 100.0,
|
close: 100.0,
|
||||||
prev_close: 99.0,
|
prev_close: 99.0,
|
||||||
volume: 1_000_000,
|
volume: 1_000_000,
|
||||||
}],
|
}).collect(),
|
||||||
)
|
)
|
||||||
.expect("dataset");
|
.expect("dataset");
|
||||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||||
@@ -1815,7 +1816,9 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
ChinaEquityRuleHooks::default(),
|
ChinaEquityRuleHooks::default(),
|
||||||
PriceField::Open,
|
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,
|
0.5, 0.3, 0.1,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user