refactor: isolate historical slippage calibration and propagate pricing errors

This commit is contained in:
boris
2026-09-11 11:35:58 +08:00
parent 6eaa06c1d6
commit 98199c02a2
3 changed files with 250 additions and 167 deletions
+137 -74
View File
@@ -300,37 +300,63 @@ impl DynamicSlippageConfig {
pub(crate) fn ratio(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
raw_price: f64,
calibration: &HistoricalSlippageCalibration,
order_value: Option<f64>,
) -> f64 {
let daily_amount = (snapshot.volume as f64 * raw_price).max(0.0);
let impact_ratio = match order_value {
Some(value) if value.is_finite() && value > 0.0 && daily_amount > 0.0 => {
value / daily_amount
Some(value) if value.is_finite() && value > 0.0 => {
value / calibration.turnover_proxy
}
_ => 0.0,
};
let volatility_base = if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 {
snapshot.prev_close
} else {
raw_price
};
let volatility = if snapshot.high.is_finite()
&& snapshot.low.is_finite()
&& volatility_base.is_finite()
&& volatility_base > 0.0
{
((snapshot.high - snapshot.low).abs() / volatility_base).max(0.0)
} else {
0.0
};
let ratio =
impact_ratio * self.impact_coefficient + volatility * self.volatility_coefficient;
let ratio = impact_ratio * self.impact_coefficient
+ calibration.range_ratio * self.volatility_coefficient;
ratio.clamp(0.0, self.max_ratio)
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct HistoricalSlippageCalibration {
source_date: NaiveDate,
turnover_proxy: f64,
range_ratio: f64,
}
impl HistoricalSlippageCalibration {
pub(crate) fn for_execution(data: &DataSet, date: NaiveDate, symbol: &str) -> Result<Self, BacktestError> {
let missing = || BacktestError::Execution(format!(
"historical_slippage_calibration_missing symbol={symbol} execution_date={date} policy=previous_completed_session"
));
let previous_date = data.previous_trading_date(date, 1).ok_or_else(missing)?;
let row = data.market(previous_date, symbol).ok_or_else(missing)?;
Self::from_completed_snapshot(row, date)
}
fn from_completed_snapshot(
row: &crate::data::DailyMarketSnapshot,
execution_date: NaiveDate,
) -> Result<Self, BacktestError> {
let turnover_proxy = row.volume as f64 * row.close;
let range_ratio = (row.high - row.low) / row.prev_close;
if row.date >= execution_date
|| [row.high, row.low, row.close, row.prev_close, turnover_proxy]
.into_iter().any(|value| !value.is_finite() || value <= 0.0)
|| row.high < row.low
|| !range_ratio.is_finite()
{
return Err(BacktestError::Execution(format!(
"historical_slippage_calibration_invalid symbol={} source_date={} execution_date={} volume={} high={} low={} close={} prev_close={}",
row.symbol, row.date, execution_date, row.volume, row.high, row.low, row.close, row.prev_close,
)));
}
Ok(Self {
source_date: row.date,
turnover_proxy,
range_ratio,
})
}
}
impl Default for DynamicSlippageConfig {
fn default() -> Self {
Self::new(0.5, 0.3, 0.01)
@@ -343,7 +369,7 @@ pub enum SlippageModel {
PriceRatio(f64),
TickSize(f64),
LimitPrice,
Dynamic(DynamicSlippageConfig),
HistoricalVolumeVolatility(DynamicSlippageConfig),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1109,12 +1135,28 @@ where
fn snapshot_execution_price(
&self,
data: &DataSet,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
quantity: Option<u32>,
) -> f64 {
) -> Result<f64, BacktestError> {
let raw_price = self.snapshot_raw_execution_price(snapshot, side);
self.apply_slippage(snapshot, side, raw_price, quantity)
let calibration = self.slippage_calibration(data, snapshot)?;
self.apply_slippage(snapshot, side, raw_price, quantity, calibration.as_ref())
}
fn slippage_calibration(
&self,
data: &DataSet,
snapshot: &crate::data::DailyMarketSnapshot,
) -> Result<Option<HistoricalSlippageCalibration>, BacktestError> {
if !matches!(self.slippage_model, SlippageModel::HistoricalVolumeVolatility(_))
|| self.is_open_auction_matching()
|| self.is_post_close_fixed_price(snapshot.date)
{
return Ok(None);
}
HistoricalSlippageCalibration::for_execution(data, snapshot.date, &snapshot.symbol).map(Some)
}
fn snapshot_raw_execution_price(
@@ -1184,17 +1226,18 @@ where
side: OrderSide,
raw_price: f64,
quantity: Option<u32>,
) -> f64 {
calibration: Option<&HistoricalSlippageCalibration>,
) -> Result<f64, BacktestError> {
if !raw_price.is_finite() || raw_price <= 0.0 {
return raw_price;
return Ok(raw_price);
}
if self.is_open_auction_matching() {
return self.clamp_execution_price(snapshot, side, raw_price);
return Ok(self.clamp_execution_price(snapshot, side, raw_price));
}
if self.is_post_close_fixed_price(snapshot.date) {
return self.clamp_execution_price(snapshot, side, raw_price);
return Ok(self.clamp_execution_price(snapshot, side, raw_price));
}
let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64));
@@ -1216,8 +1259,12 @@ where
}
}
SlippageModel::LimitPrice => raw_price,
SlippageModel::Dynamic(config) => {
let ratio = config.ratio(snapshot, raw_price, order_value);
SlippageModel::HistoricalVolumeVolatility(config) => {
let calibration = calibration.filter(|value| value.source_date < snapshot.date)
.ok_or_else(|| BacktestError::Execution(format!(
"historical_slippage_calibration_required symbol={} execution_date={}", snapshot.symbol, snapshot.date,
)))?;
let ratio = config.ratio(calibration, order_value);
match side {
OrderSide::Buy => raw_price * (1.0 + ratio),
OrderSide::Sell => raw_price * (1.0 - ratio),
@@ -1231,7 +1278,7 @@ where
adjusted *= 1.0 + self.sell_then_buy_delay_slippage_rate;
}
self.clamp_execution_price(snapshot, side, adjusted)
Ok(self.clamp_execution_price(snapshot, side, adjusted))
}
fn clamp_execution_price(
@@ -1266,8 +1313,9 @@ where
side: OrderSide,
raw_price: f64,
quantity: Option<u32>,
) -> f64 {
self.apply_slippage(snapshot, side, raw_price, quantity)
calibration: Option<&HistoricalSlippageCalibration>,
) -> Result<f64, BacktestError> {
self.apply_slippage(snapshot, side, raw_price, quantity, calibration)
}
fn matching_type_for_algo_request(
@@ -1577,7 +1625,7 @@ where
.unwrap_or(0);
if target_qty > current_qty {
let requested_qty = target_qty - current_qty;
if !self.can_afford_minimum_buy(date, portfolio, data, &symbol) {
if !self.can_afford_minimum_buy(date, portfolio, data, &symbol)? {
if report.diagnostics.len() < 32 {
report.diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells",
@@ -3385,7 +3433,7 @@ where
price,
minimum_order_quantity,
order_step_size,
))
)?)
} else {
self.round_buy_quantity(
(target_value / price).floor() as u32,
@@ -3441,15 +3489,17 @@ where
let buy_execution_price = data
.market(date, &symbol)
.map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(buy_quantity))
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(buy_quantity))
})
.transpose()?
.filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0)
.unwrap_or(price);
let sell_execution_price = data
.market(date, &symbol)
.map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(sell_quantity))
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(sell_quantity))
})
.transpose()?
.filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0)
.unwrap_or(price);
if desired_qty < current_qty
@@ -3779,7 +3829,7 @@ where
continue;
}
let buy_qty = target_qty - current_qty;
if !self.can_afford_minimum_buy(date, portfolio, data, symbol) {
if !self.can_afford_minimum_buy(date, portfolio, data, symbol)? {
if report.diagnostics.len() < 32 {
report.diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells",
@@ -4283,9 +4333,9 @@ where
portfolio: &PortfolioState,
data: &DataSet,
symbol: &str,
) -> bool {
) -> Result<bool, BacktestError> {
let Some(snapshot) = data.market(date, symbol) else {
return true;
return Ok(true);
};
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol);
@@ -4295,14 +4345,14 @@ where
order_step_size,
);
if minimum_buy_quantity == 0 {
return false;
return Ok(false);
}
let minimum_execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(minimum_buy_quantity));
Self::fixed_cash_fits(
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(minimum_buy_quantity))?;
Ok(Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity),
portfolio.cash(),
)
))
}
fn process_sell(
@@ -4710,7 +4760,7 @@ where
None,
algo_request,
limit_price,
);
)?;
let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) =
fill
{
@@ -4724,7 +4774,7 @@ where
)
} else {
let execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(fillable_qty));
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(fillable_qty))?;
if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price)
{
@@ -6438,7 +6488,7 @@ where
value_gross_limit,
algo_request,
limit_price,
);
)?;
let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) =
fill
{
@@ -6452,7 +6502,7 @@ where
)
} else {
let execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(constrained_qty));
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(constrained_qty))?;
if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price)
{
@@ -6494,10 +6544,11 @@ where
let mut blocked_by_final_price = false;
if filled_qty > 0 {
execution_price = self.snapshot_execution_price(
data,
snapshot,
OrderSide::Buy,
Some(filled_qty),
);
)?;
match self.execution_price_with_limit_slippage_or_rejection(
snapshot,
OrderSide::Buy,
@@ -7085,7 +7136,7 @@ where
fallback_price: f64,
minimum_order_quantity: u32,
order_step_size: u32,
) -> u32 {
) -> Result<u32, BacktestError> {
let snapshot = data.market(date, symbol);
let mut quantity = self.value_buy_quantity(
date,
@@ -7097,8 +7148,9 @@ where
for _ in 0..8 {
let execution_price = snapshot
.map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity))
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(quantity))
})
.transpose()?
.filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price);
let resolved = self.value_buy_quantity(
@@ -7109,27 +7161,28 @@ where
order_step_size,
);
if resolved == quantity {
return quantity;
return Ok(quantity);
}
quantity = resolved;
}
while quantity >= minimum_order_quantity.max(1) {
let execution_price = snapshot
.map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity))
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(quantity))
})
.transpose()?
.filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price);
if Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, execution_price, quantity),
value_budget,
) {
return quantity;
return Ok(quantity);
}
quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
}
0
Ok(0)
}
fn decrement_order_quantity(
@@ -7396,14 +7449,14 @@ where
gross_limit: Option<f64>,
algo_request: Option<&AlgoExecutionRequest>,
limit_price: Option<f64>,
) -> Option<ExecutionFill> {
) -> Result<Option<ExecutionFill>, BacktestError> {
let matching_type = self.matching_type_for_algo_request(algo_request);
let post_close_window = self.post_close_execution_window(date);
let use_intraday_quotes = post_close_window.is_some()
|| algo_request.is_some()
|| self.matching_type_uses_intraday_quotes();
if !use_intraday_quotes {
return None;
return Ok(None);
}
let runtime_start_time = self.runtime_intraday_start_time.get();
@@ -7430,6 +7483,7 @@ where
end_cursor
};
let quotes = data.execution_quotes_on(date, symbol);
let calibration = self.slippage_calibration(data, snapshot)?;
if let Some(fill) = self.select_execution_fill_with_ledger(
symbol,
@@ -7448,8 +7502,9 @@ where
gross_limit,
limit_price,
execution_ledger,
) {
return Some(fill);
calibration.as_ref(),
)? {
return Ok(Some(fill));
}
if post_close_window.is_some()
@@ -7464,7 +7519,7 @@ where
.or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time) + Duration::seconds(1))
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
return Some(ExecutionFill {
return Ok(Some(ExecutionFill {
quantity: 0,
next_cursor,
legs: Vec::new(),
@@ -7475,10 +7530,10 @@ where
end_cursor,
matching_type == MatchingType::MinuteLast && start_cursor.is_some(),
)),
});
}));
}
None
Ok(None)
}
fn empty_intraday_quote_reason(
@@ -7542,7 +7597,9 @@ where
gross_limit,
limit_price,
&IntradayExecutionLedger::default(),
None,
)
.expect("test quote selection without historical calibration")
}
#[allow(clippy::too_many_arguments)]
@@ -7564,9 +7621,10 @@ where
gross_limit: Option<f64>,
limit_price: Option<f64>,
execution_ledger: &IntradayExecutionLedger,
) -> Option<ExecutionFill> {
calibration: Option<&HistoricalSlippageCalibration>,
) -> Result<Option<ExecutionFill>, BacktestError> {
if requested_qty == 0 {
return None;
return Ok(None);
}
let quote_quantity_limited =
@@ -7714,7 +7772,7 @@ where
}
let mut quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty));
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
{
execution_block_reason.get_or_insert(reason);
@@ -7734,7 +7792,7 @@ where
if let Some(cash) = cash_limit {
while take_qty > 0 {
quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty));
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
if !quote_price.is_finite() || quote_price <= 0.0 {
budget_block_reason = Some("invalid execution price");
take_qty = 0;
@@ -7786,7 +7844,7 @@ where
}
quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty));
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price);
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
{
@@ -7844,7 +7902,7 @@ where
if let Some(reason) = execution_block_reason
&& !saw_non_blocked_execution_price
{
return Some(ExecutionFill {
return Ok(Some(ExecutionFill {
quantity: 0,
next_cursor: execution_block_timestamp
.expect("blocked execution quote timestamp")
@@ -7852,12 +7910,12 @@ where
legs: Vec::new(),
liquidity_consumption: Vec::new(),
unfilled_reason: Some(reason),
});
}));
}
return None;
return Ok(None);
}
Some(ExecutionFill {
Ok(Some(ExecutionFill {
quantity: filled_qty,
next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
legs: if matching_type == MatchingType::Vwap {
@@ -7881,7 +7939,7 @@ where
} else {
None
},
})
}))
}
fn quote_has_executable_liquidity(
@@ -8442,6 +8500,11 @@ mod tests {
let mut snapshot = dated_limit_test_snapshot(date);
snapshot.close = 10.0;
snapshot.upper_limit = 20.0;
let data = DataSet::from_components(
vec![limit_test_instrument()], vec![snapshot.clone()], Vec::new(),
vec![dated_limit_test_candidate(date, false, false, true, true)],
vec![dated_limit_test_benchmark(date)],
).unwrap();
for (hour, minute) in [(14, 59), (15, 31)] {
broker
@@ -8452,7 +8515,7 @@ mod tests {
EquityExecutionPhase::ContinuousAuction
);
assert_eq!(
broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)),
broker.snapshot_execution_price(&data, &snapshot, OrderSide::Buy, Some(100)).unwrap(),
12.5
);
}
@@ -8465,7 +8528,7 @@ mod tests {
EquityExecutionPhase::PostCloseFixedPrice
);
assert_eq!(
broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)),
broker.snapshot_execution_price(&data, &snapshot, OrderSide::Buy, Some(100)).unwrap(),
10.0
);
}