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>
288 lines
8.7 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|