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:
jgrusewski
2026-03-03 03:07:45 +01:00
parent a4fc3cdd33
commit 63bb4e98af
2 changed files with 472 additions and 0 deletions

View File

@@ -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

View 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);
}
}