fix(test): use median of 21 runs for inference latency check
Single-sample latency measurement was flaky under concurrent test load. Using median eliminates outlier sensitivity (127μs median vs 1154μs spike). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,7 @@ use rust_decimal::Decimal;
|
||||
|
||||
pub mod metrics;
|
||||
pub mod replay_engine;
|
||||
pub mod slippage;
|
||||
pub mod strategies;
|
||||
pub mod strategy_runner;
|
||||
pub mod strategy_tester;
|
||||
@@ -79,6 +80,10 @@ pub use strategies::{DQNAction, DQNReplayStrategy, PositionState, TradingActionT
|
||||
pub use strategy_runner::{
|
||||
create_adaptive_strategy_with_config, AdaptiveStrategyConfig, FeatureSettings, RiskSettings,
|
||||
};
|
||||
pub use slippage::{
|
||||
FixedSlippage, RegimeAwareSlippage, SlippageContext, SlippageModel, SpreadProfile,
|
||||
VolatilityRegime, VolumeImpactSlippage,
|
||||
};
|
||||
pub use strategy_tester::{
|
||||
PerformanceSnapshot, SignalType, Strategy, StrategyConfig, StrategyContext, StrategyResult,
|
||||
StrategyTester, TradeRecord, TradingSignal,
|
||||
|
||||
287
backtesting/src/slippage.rs
Normal file
287
backtesting/src/slippage.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
//! 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user