Files
foxhunt/crates/backtesting/src/slippage.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

288 lines
8.7 KiB
Rust

//! Dynamic slippage models for realistic trade execution simulation.
//!
//! Provides pluggable slippage estimation that accounts for order size,
//! market liquidity, bid-ask spreads, and volatility regimes.
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Core types
// ---------------------------------------------------------------------------
/// Volatility regime classification used to scale slippage estimates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VolatilityRegime {
/// Calm markets — tighter spreads, lower impact.
Low,
/// Typical trading conditions.
Normal,
/// Stressed / crisis markets — wide spreads, high impact.
Crisis,
}
/// Contextual information passed to a [`SlippageModel`] for each fill.
#[derive(Debug, Clone)]
pub struct SlippageContext {
/// Size of the order being filled (contracts / shares).
pub order_size: f64,
/// Average daily volume for the instrument.
pub average_daily_volume: f64,
/// Current bid-ask spread (price units).
pub current_spread: f64,
/// Current volatility regime.
pub volatility_regime: VolatilityRegime,
}
/// Spread characteristics for a specific instrument.
#[derive(Debug, Clone)]
pub struct SpreadProfile {
/// Human-readable instrument name.
pub name: String,
/// Minimum price increment.
pub tick_size: f64,
/// Typical spread expressed in ticks.
pub typical_spread_ticks: f64,
}
impl SpreadProfile {
/// E-mini S&P 500 futures (ES) — tick = 0.25, typical spread = 1 tick.
#[must_use]
pub fn es_futures() -> Self {
Self {
name: "ES".to_string(),
tick_size: 0.25,
typical_spread_ticks: 1.0,
}
}
/// 10-Year Treasury Note futures (ZN) — tick = 1/64, typical spread = 1 tick.
#[must_use]
pub fn zn_futures() -> Self {
Self {
name: "ZN".to_string(),
tick_size: 1.0 / 64.0,
typical_spread_ticks: 1.0,
}
}
/// USD/CHF (6S / SIXE) futures — tick = 0.0005 (half-pip), typical spread = 2 ticks.
#[must_use]
pub fn sixe_futures() -> Self {
Self {
name: "6S".to_string(),
tick_size: 0.0005,
typical_spread_ticks: 2.0,
}
}
/// Typical spread in price units (`tick_size * typical_spread_ticks`).
#[must_use]
pub fn typical_spread(&self) -> f64 {
self.tick_size * self.typical_spread_ticks
}
}
// ---------------------------------------------------------------------------
// Trait
// ---------------------------------------------------------------------------
/// A model that estimates execution slippage (in price units) for a given
/// order context. Implementations must be `Send + Sync` so they can be
/// shared across async tasks.
pub trait SlippageModel: Send + Sync {
/// Compute the estimated slippage for the given context.
///
/// Returns a non-negative value in price units that should be *added* to
/// the execution cost (subtracted from fills on buys, added on sells).
fn compute(&self, ctx: &SlippageContext) -> f64;
}
// ---------------------------------------------------------------------------
// Implementations
// ---------------------------------------------------------------------------
/// Returns a constant slippage regardless of context.
///
/// This is the simplest model and serves as a backward-compatible default.
#[derive(Debug, Clone)]
pub struct FixedSlippage {
/// Constant slippage value (price units).
pub slippage: f64,
}
impl FixedSlippage {
/// Create a new fixed slippage model.
#[must_use]
pub fn new(slippage: f64) -> Self {
Self { slippage }
}
}
impl SlippageModel for FixedSlippage {
fn compute(&self, _ctx: &SlippageContext) -> f64 {
self.slippage
}
}
/// Square-root market-impact model: `slippage = k * sqrt(order_size / ADV)`.
///
/// This is the standard Almgren-Chriss style temporary impact model used
/// widely in execution analytics.
#[derive(Debug, Clone)]
pub struct VolumeImpactSlippage {
/// Impact coefficient (scales the sqrt term).
pub k: f64,
}
impl VolumeImpactSlippage {
/// Create a new volume-impact model with the given coefficient.
#[must_use]
pub fn new(k: f64) -> Self {
Self { k }
}
}
impl SlippageModel for VolumeImpactSlippage {
fn compute(&self, ctx: &SlippageContext) -> f64 {
if ctx.average_daily_volume <= 0.0 {
return 0.0;
}
let participation = ctx.order_size / ctx.average_daily_volume;
self.k * participation.sqrt()
}
}
/// Wraps an inner [`SlippageModel`] and multiplies its output by a factor
/// determined by the current [`VolatilityRegime`].
///
/// Regime multipliers:
/// - `Low` — 0.5
/// - `Normal` — 1.0
/// - `Crisis` — 3.0
pub struct RegimeAwareSlippage {
inner: Box<dyn SlippageModel>,
low_factor: f64,
normal_factor: f64,
crisis_factor: f64,
}
impl std::fmt::Debug for RegimeAwareSlippage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegimeAwareSlippage")
.field("low_factor", &self.low_factor)
.field("normal_factor", &self.normal_factor)
.field("crisis_factor", &self.crisis_factor)
.finish_non_exhaustive()
}
}
impl RegimeAwareSlippage {
/// Wrap `inner` with default regime multipliers (0.5 / 1.0 / 3.0).
pub fn new(inner: Box<dyn SlippageModel>) -> Self {
Self {
inner,
low_factor: 0.5,
normal_factor: 1.0,
crisis_factor: 3.0,
}
}
/// Return the multiplier for the given regime.
fn regime_factor(&self, regime: VolatilityRegime) -> f64 {
match regime {
VolatilityRegime::Low => self.low_factor,
VolatilityRegime::Normal => self.normal_factor,
VolatilityRegime::Crisis => self.crisis_factor,
}
}
}
impl SlippageModel for RegimeAwareSlippage {
fn compute(&self, ctx: &SlippageContext) -> f64 {
let base = self.inner.compute(ctx);
base * self.regime_factor(ctx.volatility_regime)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_slippage_returns_constant() {
let model = FixedSlippage::new(0.5);
let ctx = SlippageContext {
order_size: 100.0,
average_daily_volume: 1_000_000.0,
current_spread: 0.25,
volatility_regime: VolatilityRegime::Normal,
};
let result = model.compute(&ctx);
assert!(
(result - 0.5).abs() < f64::EPSILON,
"Fixed slippage should return the constant value, got {result}"
);
}
#[test]
fn volume_impact_scales_with_order_size() {
let model = VolumeImpactSlippage::new(1.0);
let small_ctx = SlippageContext {
order_size: 100.0,
average_daily_volume: 1_000_000.0,
current_spread: 0.25,
volatility_regime: VolatilityRegime::Normal,
};
let large_ctx = SlippageContext {
order_size: 10_000.0,
average_daily_volume: 1_000_000.0,
current_spread: 0.25,
volatility_regime: VolatilityRegime::Normal,
};
let small_slip = model.compute(&small_ctx);
let large_slip = model.compute(&large_ctx);
assert!(
large_slip > small_slip,
"Larger orders should produce more slippage: large={large_slip}, small={small_slip}"
);
}
#[test]
fn regime_aware_crisis_exceeds_normal() {
let inner = Box::new(FixedSlippage::new(1.0));
let model = RegimeAwareSlippage::new(inner);
let normal_ctx = SlippageContext {
order_size: 100.0,
average_daily_volume: 1_000_000.0,
current_spread: 0.25,
volatility_regime: VolatilityRegime::Normal,
};
let crisis_ctx = SlippageContext {
volatility_regime: VolatilityRegime::Crisis,
..normal_ctx.clone()
};
let normal_slip = model.compute(&normal_ctx);
let crisis_slip = model.compute(&crisis_ctx);
assert!(
crisis_slip > normal_slip,
"Crisis slippage ({crisis_slip}) should exceed normal ({normal_slip})"
);
}
#[test]
fn es_spread_profile_tick_size() {
let es = SpreadProfile::es_futures();
assert!(
(es.tick_size - 0.25).abs() < f64::EPSILON,
"ES tick_size should be 0.25, got {}",
es.tick_size
);
}
}