From 63bb4e98af6764775dc89528b638366e514db467 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Tue, 3 Mar 2026 03:07:45 +0100 Subject: [PATCH] feat(ml): add volume-dependent slippage model SlippageModel trait with two implementations: - LinearImpactModel: sqrt market impact (spread/2 + eta*sqrt(V/ADV) + order premium) - FixedCostModel: backward-compat wrapper for existing 15/5/10 bps constants Co-Authored-By: Claude Opus 4.6 --- crates/ml/src/risk/mod.rs | 2 + crates/ml/src/risk/slippage.rs | 470 +++++++++++++++++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 crates/ml/src/risk/slippage.rs diff --git a/crates/ml/src/risk/mod.rs b/crates/ml/src/risk/mod.rs index 2b7e16ca7..f50037c18 100644 --- a/crates/ml/src/risk/mod.rs +++ b/crates/ml/src/risk/mod.rs @@ -8,6 +8,7 @@ pub mod graph_risk_model; pub mod kelly_optimizer; pub mod kelly_position_sizing_service; pub mod position_sizing; +pub mod slippage; pub mod var_models; // Export types from modules that actually exist @@ -16,6 +17,7 @@ pub use kelly_optimizer::{ KellyCriterionOptimizer, KellyOptimizerConfig, KellyPositionRecommendation, }; pub use position_sizing::PositionSizingNetwork; +pub use slippage::{FixedCostModel, LinearImpactConfig, LinearImpactModel, SlippageModel}; pub use var_models::{NeuralVarConfig, NeuralVarModel}; // Export graph risk model types from TGNN module diff --git a/crates/ml/src/risk/slippage.rs b/crates/ml/src/risk/slippage.rs new file mode 100644 index 000000000..1d99fc003 --- /dev/null +++ b/crates/ml/src/risk/slippage.rs @@ -0,0 +1,470 @@ +//! # Slippage and Market Impact Models +//! +//! Volume-dependent transaction cost modeling for realistic backtesting +//! and RL reward computation. Replaces fixed bps constants with +//! square-root market impact models calibrated per instrument. + +use serde::{Deserialize, Serialize}; + +use crate::common::action::OrderType; + +// --------------------------------------------------------------------------- +// Trait +// --------------------------------------------------------------------------- + +/// Estimate total transaction cost including market impact. +/// +/// Implementations range from simple fixed-cost wrappers (backward compat) +/// to full square-root impact models with temporary decay. +pub trait SlippageModel: Send + Sync { + /// Estimate total transaction cost including market impact. + /// + /// Returns cost as a **decimal fraction** (e.g., 0.0015 = 15 bps). + /// + /// # Arguments + /// + /// * `position_change` - Signed change in position (lots / contracts). + /// Only the absolute value matters for cost estimation. + /// * `volume` - Number of lots/contracts traded in this order. + /// * `adv` - Average daily volume (20-day rolling), used to normalize + /// participation rate. + /// * `order_type` - Execution strategy (Market / LimitMaker / IoC). + fn estimate_cost( + &self, + position_change: f64, + volume: f64, + adv: f64, + order_type: OrderType, + ) -> f64; +} + +// --------------------------------------------------------------------------- +// FixedCostModel (backward-compat wrapper) +// --------------------------------------------------------------------------- + +/// Backward-compatible fixed-cost model that delegates to +/// [`OrderType::transaction_cost()`]. +/// +/// Ignores volume and ADV entirely -- useful for unit tests and as a +/// migration shim while callers are updated to supply volume data. +#[derive(Debug, Clone, Copy, Default)] +pub struct FixedCostModel; + +impl SlippageModel for FixedCostModel { + fn estimate_cost( + &self, + _position_change: f64, + _volume: f64, + _adv: f64, + order_type: OrderType, + ) -> f64 { + order_type.transaction_cost() + } +} + +// --------------------------------------------------------------------------- +// LinearImpactConfig +// --------------------------------------------------------------------------- + +/// Configuration for the [`LinearImpactModel`]. +/// +/// All costs are expressed in **decimal fractions** (1 bp = 0.0001). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinearImpactConfig { + /// Square-root impact coefficient (bps per sqrt-lot participation). + /// + /// Typical values: + /// - ES futures: ~0.1 bps / sqrt(lot) + /// - 6E futures: ~0.3 bps / sqrt(lot) + pub sqrt_impact_bps: f64, + + /// Half-spread estimate. When OHLCV data is available the caller + /// should compute `0.5 * (high - low) / close * normalization_factor` + /// and pass it here. Defaults to 1 bp (0.0001). + pub half_spread: f64, + + /// Order-type premium/rebate in decimal: + /// - Market: +0 bps (0.0) + /// - IoC: +2 bps (0.0002) + /// - LimitMaker: -5 bps (-0.0005) (maker rebate) + pub market_premium: f64, + pub ioc_premium: f64, + pub limit_rebate: f64, + +} + +impl Default for LinearImpactConfig { + fn default() -> Self { + Self { + sqrt_impact_bps: 0.1, // ES-like default + half_spread: 0.0001, // 1 bp + market_premium: 0.0, + ioc_premium: 0.0002, // +2 bps + limit_rebate: -0.0005, // -5 bps (rebate) + } + } +} + +// --------------------------------------------------------------------------- +// LinearImpactModel +// --------------------------------------------------------------------------- + +/// Square-root market impact model. +/// +/// ```text +/// cost = half_spread + sqrt_impact * sqrt(|volume| / adv) + order_premium +/// ``` +/// +/// The model is additive: base spread + market impact + order-type premium. +/// All components are in decimal fractions (e.g., 0.0001 = 1 bp). +#[derive(Debug, Clone)] +pub struct LinearImpactModel { + config: LinearImpactConfig, +} + +impl Default for LinearImpactModel { + fn default() -> Self { + Self { + config: LinearImpactConfig::default(), + } + } +} + +impl LinearImpactModel { + /// Create a new model from the given configuration. + pub fn new(config: LinearImpactConfig) -> Self { + Self { config } + } + + /// Create with a specific sqrt-impact coefficient (bps per sqrt-lot). + /// Other parameters use defaults. + pub fn with_sqrt_impact(sqrt_impact_bps: f64) -> Self { + Self { + config: LinearImpactConfig { + sqrt_impact_bps, + ..Default::default() + }, + } + } + + /// Return the order-type premium/rebate for the given order type. + fn order_premium(&self, order_type: OrderType) -> f64 { + match order_type { + OrderType::Market => self.config.market_premium, + OrderType::IoC => self.config.ioc_premium, + OrderType::LimitMaker => self.config.limit_rebate, + } + } +} + +impl SlippageModel for LinearImpactModel { + fn estimate_cost( + &self, + _position_change: f64, + volume: f64, + adv: f64, + order_type: OrderType, + ) -> f64 { + let abs_volume = volume.abs(); + + // Guard: if ADV is zero or negative, fall back to spread + premium only + // to avoid division by zero / NaN. + let impact = if adv > 0.0 { + // Convert bps coefficient to decimal: 0.1 bps = 0.00001 + let sqrt_impact_decimal = self.config.sqrt_impact_bps * 0.0001; + sqrt_impact_decimal * (abs_volume / adv).sqrt() + } else { + 0.0 + }; + + let premium = self.order_premium(order_type); + + // Total cost floored at zero -- a maker rebate cannot make cost negative + // when spread + impact are small enough. + let total = self.config.half_spread + impact + premium; + total.max(0.0) + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // FixedCostModel + // ----------------------------------------------------------------------- + + #[test] + fn test_fixed_cost_model_matches_order_type() { + let model = FixedCostModel; + let adv = 100_000.0; + let vol = 10.0; + let pos = 5.0; + + assert!( + (model.estimate_cost(pos, vol, adv, OrderType::Market) + - OrderType::Market.transaction_cost()) + .abs() + < f64::EPSILON + ); + assert!( + (model.estimate_cost(pos, vol, adv, OrderType::LimitMaker) + - OrderType::LimitMaker.transaction_cost()) + .abs() + < f64::EPSILON + ); + assert!( + (model.estimate_cost(pos, vol, adv, OrderType::IoC) + - OrderType::IoC.transaction_cost()) + .abs() + < f64::EPSILON + ); + } + + // ----------------------------------------------------------------------- + // LinearImpactModel - config defaults + // ----------------------------------------------------------------------- + + #[test] + fn test_linear_impact_config_defaults() { + let cfg = LinearImpactConfig::default(); + // sqrt_impact should be positive and small + assert!(cfg.sqrt_impact_bps > 0.0); + assert!(cfg.sqrt_impact_bps < 10.0); // reasonable upper bound + // half_spread should be ~ 1bp + assert!((cfg.half_spread - 0.0001).abs() < 1e-8); + // market premium is zero + assert!((cfg.market_premium - 0.0).abs() < 1e-8); + // ioc premium is positive + assert!(cfg.ioc_premium > 0.0); + // limit rebate is negative + assert!(cfg.limit_rebate < 0.0); + } + + // ----------------------------------------------------------------------- + // LinearImpactModel - core behavior + // ----------------------------------------------------------------------- + + #[test] + fn test_linear_impact_higher_volume() { + let model = LinearImpactModel::default(); + let adv = 100_000.0; + + let cost_low = model.estimate_cost(10.0, 100.0, adv, OrderType::Market); + let cost_high = model.estimate_cost(10.0, 10_000.0, adv, OrderType::Market); + + assert!( + cost_high > cost_low, + "Higher volume should yield higher cost: {} vs {}", + cost_high, + cost_low + ); + } + + #[test] + fn test_linear_impact_zero_volume() { + let model = LinearImpactModel::default(); + let adv = 100_000.0; + + let cost = model.estimate_cost(0.0, 0.0, adv, OrderType::Market); + // With zero volume, impact term is zero. + // Cost should equal half_spread + market_premium = 0.0001 + 0.0 + assert!( + (cost - 0.0001).abs() < 1e-8, + "Zero volume should give base spread cost only, got {}", + cost + ); + } + + #[test] + fn test_linear_impact_order_type_premium() { + let model = LinearImpactModel::default(); + let adv = 100_000.0; + let volume = 500.0; + let pos = 10.0; + + let cost_market = model.estimate_cost(pos, volume, adv, OrderType::Market); + let cost_ioc = model.estimate_cost(pos, volume, adv, OrderType::IoC); + let cost_limit = model.estimate_cost(pos, volume, adv, OrderType::LimitMaker); + + // IoC is more expensive than Market (IoC has +2bps premium) + assert!( + cost_ioc > cost_market, + "IoC should cost more than Market: {} vs {}", + cost_ioc, + cost_market + ); + // Market is more expensive than LimitMaker (LimitMaker gets -5bps rebate) + assert!( + cost_market > cost_limit, + "Market should cost more than LimitMaker: {} vs {}", + cost_market, + cost_limit + ); + } + + #[test] + fn test_linear_impact_sqrt_scaling() { + let model = LinearImpactModel::default(); + let adv = 100_000.0; + + // If we 4x the volume, the sqrt impact should roughly 2x. + let cost_v = model.estimate_cost(10.0, 1_000.0, adv, OrderType::Market); + let cost_4v = model.estimate_cost(10.0, 4_000.0, adv, OrderType::Market); + + // Impact component: sqrt(V/ADV) vs sqrt(4V/ADV) = 2 * sqrt(V/ADV) + // The spread and premium are identical, so: + // cost_4v - spread = 2 * (cost_v - spread) + let spread = model.config.half_spread + model.config.market_premium; + let impact_v = cost_v - spread; + let impact_4v = cost_4v - spread; + + let ratio = impact_4v / impact_v; + assert!( + (ratio - 2.0).abs() < 1e-6, + "sqrt scaling: 4x volume should give 2x impact, ratio = {}", + ratio + ); + } + + #[test] + fn test_linear_impact_negative_position_change() { + let model = LinearImpactModel::default(); + let adv = 100_000.0; + let volume = 500.0; + + let cost_pos = model.estimate_cost(10.0, volume, adv, OrderType::Market); + let cost_neg = model.estimate_cost(-10.0, volume, adv, OrderType::Market); + + // Signed position_change should not affect cost (volume is the driver). + assert!( + (cost_pos - cost_neg).abs() < f64::EPSILON, + "Cost should be identical for +/- position change: {} vs {}", + cost_pos, + cost_neg + ); + } + + #[test] + fn test_linear_impact_negative_volume_uses_abs() { + let model = LinearImpactModel::default(); + let adv = 100_000.0; + + let cost_pos = model.estimate_cost(10.0, 500.0, adv, OrderType::Market); + let cost_neg = model.estimate_cost(10.0, -500.0, adv, OrderType::Market); + + assert!( + (cost_pos - cost_neg).abs() < f64::EPSILON, + "Negative volume should be treated as abs: {} vs {}", + cost_pos, + cost_neg + ); + } + + #[test] + fn test_linear_impact_zero_adv_no_panic() { + let model = LinearImpactModel::default(); + // ADV = 0 should not panic or produce NaN + let cost = model.estimate_cost(10.0, 500.0, 0.0, OrderType::Market); + assert!(cost.is_finite(), "Cost should be finite with zero ADV"); + assert!(cost >= 0.0, "Cost should be non-negative"); + } + + #[test] + fn test_linear_impact_negative_adv_no_panic() { + let model = LinearImpactModel::default(); + let cost = model.estimate_cost(10.0, 500.0, -100_000.0, OrderType::Market); + assert!(cost.is_finite(), "Cost should be finite with negative ADV"); + assert!(cost >= 0.0, "Cost should be non-negative"); + } + + #[test] + fn test_linear_impact_cost_non_negative() { + // Even with a large LimitMaker rebate, cost is floored at 0. + let cfg = LinearImpactConfig { + sqrt_impact_bps: 0.01, + half_spread: 0.0001, + limit_rebate: -0.01, // huge rebate: -100 bps + ..Default::default() + }; + let model = LinearImpactModel::new(cfg); + let cost = model.estimate_cost(1.0, 1.0, 100_000.0, OrderType::LimitMaker); + assert!(cost >= 0.0, "Cost must never be negative, got {}", cost); + } + + // ----------------------------------------------------------------------- + // Trait object dispatch + // ----------------------------------------------------------------------- + + #[test] + fn test_trait_object_dispatch() { + let fixed: Box = Box::new(FixedCostModel); + let linear: Box = Box::new(LinearImpactModel::default()); + + let adv = 100_000.0; + let volume = 500.0; + let pos = 10.0; + + let cost_fixed = fixed.estimate_cost(pos, volume, adv, OrderType::Market); + let cost_linear = linear.estimate_cost(pos, volume, adv, OrderType::Market); + + // Both should return finite, positive values + assert!(cost_fixed.is_finite() && cost_fixed > 0.0); + assert!(cost_linear.is_finite() && cost_linear > 0.0); + + // They should differ (different models) + assert!( + (cost_fixed - cost_linear).abs() > 1e-10, + "Fixed and linear models should produce different costs" + ); + } + + // ----------------------------------------------------------------------- + // Custom configuration + // ----------------------------------------------------------------------- + + #[test] + fn test_with_sqrt_impact_constructor() { + let model = LinearImpactModel::with_sqrt_impact(0.3); + assert!( + (model.config.sqrt_impact_bps - 0.3).abs() < 1e-8, + "sqrt_impact_bps should be 0.3" + ); + // Other defaults preserved + assert!( + (model.config.half_spread - 0.0001).abs() < 1e-8, + "half_spread should still be default" + ); + } + + #[test] + fn test_higher_sqrt_impact_means_higher_cost() { + let model_low = LinearImpactModel::with_sqrt_impact(0.1); + let model_high = LinearImpactModel::with_sqrt_impact(0.5); + + let adv = 100_000.0; + let volume = 5_000.0; + + let cost_low = model_low.estimate_cost(10.0, volume, adv, OrderType::Market); + let cost_high = model_high.estimate_cost(10.0, volume, adv, OrderType::Market); + + assert!( + cost_high > cost_low, + "Higher sqrt_impact should yield higher cost: {} vs {}", + cost_high, + cost_low + ); + } + + #[test] + fn test_default_model_impl() { + // Ensure Default trait works for LinearImpactModel + let model: LinearImpactModel = Default::default(); + let cost = model.estimate_cost(1.0, 100.0, 50_000.0, OrderType::Market); + assert!(cost.is_finite() && cost > 0.0); + } +}