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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
470
crates/ml/src/risk/slippage.rs
Normal file
470
crates/ml/src/risk/slippage.rs
Normal file
@@ -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<dyn SlippageModel> = Box::new(FixedCostModel);
|
||||
let linear: Box<dyn SlippageModel> = 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user