Skip to main content

rustrade_execution/
slippage.rs

1//! Adverse slippage on the simulated venue's taker fills.
2//!
3//! [`SlippageConfig`] says how far a fill is moved against the order that takes liquidity. The
4//! venue holds it with the random stream it draws from, and applies it to the price its
5//! [`FillModel`](crate::fill::FillModel) gives, so slippage composes with every pricing model
6//! alike.
7//!
8//! # Backtest-only
9//!
10//! Only simulated execution slips a fill. Live execution clients receive real fill prices from the
11//! venue.
12
13use rand::{Rng, SeedableRng, rngs::Xoshiro256PlusPlus};
14use rust_decimal::Decimal;
15use rustrade_instrument::Side;
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19/// How far the simulated venue moves a taker fill against the order, as a fraction of the price the
20/// fill would otherwise get.
21///
22/// A buy fills at `reference × (1 + s)` and a sell at `reference × (1 − s)`, where `reference` is
23/// what the venue's [`FillModel`](crate::fill::FillModel) priced the fill at, and `s` is a rate
24/// this configuration gives:
25/// - [`none`](Self::none), the default: `s = 0`, and the fill is exactly the reference.
26/// - [`fixed`](Self::fixed): the same `s` for every fill.
27/// - [`uniform`](Self::uniform): a fresh `s` for each fill, drawn uniformly from `[low, high]`,
28///   both ends included, from a random stream seeded by the configured seed.
29///
30/// The rate is a fraction rather than basis points, so `0.01` is one percent. Several percent per
31/// side is accepted, and so is anything up to but not including `1`, past which a sell would fill
32/// at or below zero.
33///
34/// # What it applies to
35///
36/// Every fill that takes liquidity: a market order, and a limit order that is marketable when it
37/// arrives. The venue clamps the slipped price to a limit order's own limit, so slippage can cost a
38/// limit order its price improvement, but never fill it past its limit. A resting order that the
39/// market later reaches fills at its own limit exactly, with no slippage, as a maker's fill does.
40///
41/// A slipped fill is not bounded by the bar's or the book's range: a large rate on a short bar can
42/// print outside that bar's high and low. Capping it would silently erase most of the rate. Nor
43/// is it rounded to the instrument's tick, which the venue does not know.
44///
45/// # Reproducibility
46///
47/// [`uniform`](Self::uniform) draws from its own generator, seeded by the `u64` in this
48/// configuration and used for nothing else, so a run is reproducible from its seed: the same seed
49/// and the same sequence of orders give the same fill prices. The generator is xoshiro256++, which
50/// the `rand` crate documents as portable, and a test pins the rates a seed gives. One draw is made
51/// for each fill that trades: an order that is refused, cancelled on arrival or rests without
52/// trading takes none. A rate whose two ends are equal makes no draw at all, so a zero rate is
53/// identical to configuring no slippage.
54///
55/// A rate drawn between the two ends is rounded to [`RATE_DECIMAL_PLACES`] decimal places, a
56/// resolution far finer than any price tick. So with ends given to at most that many places, a
57/// slipped price carries at most that many more decimal places than the price it slipped.
58///
59/// # Validation
60///
61/// The constructors refuse a rate outside `0 ≤ low ≤ high < 1`, and so does deserialising one,
62/// so a `SlippageConfig` that exists is one the venue can apply.
63#[derive(
64    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
65)]
66#[serde(try_from = "SlippageRepr", into = "SlippageRepr")]
67pub struct SlippageConfig(SlippageRepr);
68
69/// The serialised form of a [`SlippageConfig`], before validation.
70#[derive(
71    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
72)]
73enum SlippageRepr {
74    #[default]
75    None,
76    Fixed {
77        #[serde(with = "rust_decimal::serde::str")]
78        rate: Decimal,
79    },
80    Uniform {
81        #[serde(with = "rust_decimal::serde::str")]
82        low: Decimal,
83        #[serde(with = "rust_decimal::serde::str")]
84        high: Decimal,
85        seed: u64,
86    },
87}
88
89impl SlippageConfig {
90    /// No slippage: every fill is exactly the price the fill model gives. The default.
91    pub const fn none() -> Self {
92        Self(SlippageRepr::None)
93    }
94
95    /// The same rate for every fill.
96    ///
97    /// # Errors
98    /// Returns [`SlippageConfigError`] unless `0 ≤ rate < 1`.
99    pub fn fixed(rate: Decimal) -> Result<Self, SlippageConfigError> {
100        Self::try_from(SlippageRepr::Fixed { rate })
101    }
102
103    /// A rate drawn for each fill, uniformly from `[low, high]` with both ends included, from a
104    /// generator seeded by `seed`.
105    ///
106    /// # Errors
107    /// Returns [`SlippageConfigError`] unless `0 ≤ low ≤ high < 1`.
108    pub fn uniform(low: Decimal, high: Decimal, seed: u64) -> Result<Self, SlippageConfigError> {
109        Self::try_from(SlippageRepr::Uniform { low, high, seed })
110    }
111
112    /// The lowest rate this configuration can give a fill: zero for [`none`](Self::none), the
113    /// rate for [`fixed`](Self::fixed).
114    pub fn low(&self) -> Decimal {
115        match self.0 {
116            SlippageRepr::None => Decimal::ZERO,
117            SlippageRepr::Fixed { rate } => rate,
118            SlippageRepr::Uniform { low, .. } => low,
119        }
120    }
121
122    /// The highest rate this configuration can give a fill: zero for [`none`](Self::none), the
123    /// rate for [`fixed`](Self::fixed).
124    pub fn high(&self) -> Decimal {
125        match self.0 {
126            SlippageRepr::None => Decimal::ZERO,
127            SlippageRepr::Fixed { rate } => rate,
128            SlippageRepr::Uniform { high, .. } => high,
129        }
130    }
131
132    /// The seed of a [`uniform`](Self::uniform) configuration, `None` for any other.
133    pub fn seed(&self) -> Option<u64> {
134        match self.0 {
135            SlippageRepr::Uniform { seed, .. } => Some(seed),
136            SlippageRepr::None | SlippageRepr::Fixed { .. } => None,
137        }
138    }
139}
140
141impl TryFrom<SlippageRepr> for SlippageConfig {
142    type Error = SlippageConfigError;
143
144    fn try_from(repr: SlippageRepr) -> Result<Self, Self::Error> {
145        let (low, high) = match repr {
146            SlippageRepr::None => return Ok(Self(repr)),
147            SlippageRepr::Fixed { rate } => (rate, rate),
148            SlippageRepr::Uniform { low, high, .. } => (low, high),
149        };
150        if low.is_sign_negative() && !low.is_zero() {
151            return Err(SlippageConfigError::Negative(low));
152        }
153        if high >= Decimal::ONE {
154            return Err(SlippageConfigError::NotBelowOne(high));
155        }
156        if low > high {
157            return Err(SlippageConfigError::Inverted { low, high });
158        }
159        Ok(Self(repr))
160    }
161}
162
163impl From<SlippageConfig> for SlippageRepr {
164    fn from(config: SlippageConfig) -> Self {
165        config.0
166    }
167}
168
169/// Why a [`SlippageConfig`] was refused.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
171pub enum SlippageConfigError {
172    /// A rate below zero, which would move a fill in the order's favour.
173    #[error("slippage rate {0} is negative; slippage is adverse, so a rate is at least 0")]
174    Negative(Decimal),
175    /// A rate of one or more, which would fill a sell at or below zero.
176    #[error("slippage rate {0} is not below 1, so a sell would fill at or below zero")]
177    NotBelowOne(Decimal),
178    /// A range whose low end is above its high end.
179    #[error("slippage range is inverted: low {low} is above high {high}")]
180    Inverted { low: Decimal, high: Decimal },
181}
182
183/// How many decimal places a [`SlippageConfig::uniform`] draw is rounded to.
184pub const RATE_DECIMAL_PLACES: u32 = 12;
185
186/// A [`SlippageConfig`] and the random stream it draws from: what the simulated venue applies to
187/// each taker fill.
188///
189/// Built once per venue from its configuration, and held for the venue's lifetime, so the stream
190/// advances one draw per fill across the whole run.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub(crate) struct Slippage {
193    config: SlippageConfig,
194    rng: Xoshiro256PlusPlus,
195}
196
197impl Slippage {
198    /// The slippage `config` describes, its stream at the start of its seed.
199    pub(crate) fn new(config: SlippageConfig) -> Self {
200        Self {
201            config,
202            // A configuration without a seed never draws, so the stream it gets is never read.
203            rng: Xoshiro256PlusPlus::seed_from_u64(config.seed().unwrap_or_default()),
204        }
205    }
206
207    /// `reference` moved against an order trading `side`, by a rate drawn for this one fill.
208    ///
209    /// Advances the stream by one draw unless the configured range is a single rate.
210    ///
211    /// # Panics
212    /// On `Decimal` overflow, which takes a reference above about 3.9 × 10²⁸.
213    pub(crate) fn apply(&mut self, side: Side, reference: Decimal) -> Decimal {
214        let rate = self.draw();
215        if rate.is_zero() {
216            return reference;
217        }
218        match side {
219            Side::Buy => reference * (Decimal::ONE + rate),
220            Side::Sell => reference * (Decimal::ONE - rate),
221        }
222    }
223
224    /// The rate for one fill.
225    fn draw(&mut self) -> Decimal {
226        let (low, high) = (self.config.low(), self.config.high());
227        if low == high {
228            return low;
229        }
230        // `k / u64::MAX` spans `[0, 1]` with both ends reachable, which is what makes the range
231        // inclusive. Mapped here rather than by `rand`'s range sampling, whose algorithm the crate
232        // does not promise to keep, so a seed's prices hold across `rand` versions. Both factors
233        // of the product lie in `[0, 1]`, so it cannot overflow.
234        let unit = Decimal::from(self.rng.next_u64()) / Decimal::from(u64::MAX);
235        // Rounded so a slipped price does not carry `Decimal`'s full 28 places, then clamped,
236        // since rounding can step past a bound given with more places than this keeps.
237        (low + (high - low) * unit)
238            .round_dp(RATE_DECIMAL_PLACES)
239            .clamp(low, high)
240    }
241}
242
243#[cfg(test)]
244#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
245mod tests {
246    use super::*;
247
248    fn d(s: &str) -> Decimal {
249        s.parse().unwrap()
250    }
251
252    #[test]
253    fn none_is_the_default_and_leaves_every_price_alone() {
254        let mut slippage = Slippage::new(SlippageConfig::default());
255        assert_eq!(SlippageConfig::default(), SlippageConfig::none());
256        assert_eq!(slippage.apply(Side::Buy, d("100.5")), d("100.5"));
257        assert_eq!(slippage.apply(Side::Sell, d("100.5")), d("100.5"));
258    }
259
260    #[test]
261    fn a_fixed_rate_moves_a_buy_up_and_a_sell_down() {
262        let mut slippage = Slippage::new(SlippageConfig::fixed(d("0.01")).unwrap());
263        assert_eq!(slippage.apply(Side::Buy, d("200")), d("202"));
264        assert_eq!(slippage.apply(Side::Sell, d("200")), d("198"));
265    }
266
267    #[test]
268    fn several_percent_is_accepted() {
269        let mut slippage = Slippage::new(SlippageConfig::fixed(d("0.05")).unwrap());
270        assert_eq!(slippage.apply(Side::Sell, d("100")), d("95"));
271    }
272
273    /// A range of one rate never reads the stream, so it is the fixed rate exactly, and a zero
274    /// range is no slippage at all.
275    #[test]
276    fn a_range_of_one_rate_makes_no_draw() {
277        let mut ranged = Slippage::new(SlippageConfig::uniform(d("0.02"), d("0.02"), 9).unwrap());
278        let untouched = ranged.rng.clone();
279        assert_eq!(ranged.apply(Side::Buy, d("50")), d("51"));
280        assert_eq!(
281            ranged.rng, untouched,
282            "a single-rate range drew from its stream"
283        );
284
285        let mut zero =
286            Slippage::new(SlippageConfig::uniform(Decimal::ZERO, Decimal::ZERO, 9).unwrap());
287        assert_eq!(zero.apply(Side::Buy, d("50.25")), d("50.25"));
288    }
289
290    #[test]
291    fn every_uniform_draw_lies_inside_its_range() {
292        let (low, high) = (d("0.01"), d("0.025"));
293        let mut slippage = Slippage::new(SlippageConfig::uniform(low, high, 7).unwrap());
294        for _ in 0..10_000 {
295            let rate = slippage.draw();
296            assert!(
297                low <= rate && rate <= high,
298                "{rate} outside [{low}, {high}]"
299            );
300        }
301    }
302
303    #[test]
304    fn the_same_seed_gives_the_same_prices_and_another_seed_does_not() {
305        let config = |seed| SlippageConfig::uniform(d("0.01"), d("0.025"), seed).unwrap();
306        let prices = |seed| {
307            let mut slippage = Slippage::new(config(seed));
308            (0..32)
309                .map(|i| {
310                    let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
311                    slippage.apply(side, d("100"))
312                })
313                .collect::<Vec<_>>()
314        };
315        assert_eq!(prices(42), prices(42));
316        assert_ne!(prices(42), prices(43));
317    }
318
319    /// Pins the stream a seed gives, so a change of generator or of the mapping from its output
320    /// to a rate, either of which would silently change every seeded backtest's prices, fails here.
321    #[test]
322    fn a_seed_gives_the_same_rates_in_every_version() {
323        let mut slippage =
324            Slippage::new(SlippageConfig::uniform(d("0.01"), d("0.025"), 42).unwrap());
325        let rates = (0..3).map(|_| slippage.draw()).collect::<Vec<_>>();
326        assert_eq!(rates, GOLDEN_RATES.map(d).to_vec());
327    }
328
329    /// Pins the prices too, so a change in how `Decimal` multiplies or rounds is caught as well.
330    #[test]
331    fn a_seed_gives_the_same_prices_in_every_version() {
332        let mut slippage =
333            Slippage::new(SlippageConfig::uniform(d("0.01"), d("0.025"), 42).unwrap());
334        let prices = [Side::Buy, Side::Sell, Side::Buy]
335            .map(|side| slippage.apply(side, d("49000.5")).to_string());
336        assert_eq!(prices, GOLDEN_PRICES);
337    }
338
339    const GOLDEN_PRICES: [&str; 3] = [
340        "50089.0253889615885",
341        "48276.1591443931995",
342        "50213.6745928332615",
343    ];
344
345    /// A bound given with more places than a draw keeps is still never stepped past.
346    #[test]
347    fn a_rounded_draw_stays_inside_a_finely_given_range() {
348        let (low, high) = (d("0.0100000000004"), d("0.0100000000006"));
349        let mut slippage = Slippage::new(SlippageConfig::uniform(low, high, 1).unwrap());
350        for _ in 0..1_000 {
351            let rate = slippage.draw();
352            assert!(
353                low <= rate && rate <= high,
354                "{rate} outside [{low}, {high}]"
355            );
356        }
357    }
358
359    /// The first three rates of seed 42 over `[0.01, 0.025]`, rounded to 12 places. Cross-checked
360    /// against an independent implementation of SplitMix64 seeding and xoshiro256++ from their
361    /// published reference code.
362    const GOLDEN_RATES: [&str; 3] = ["0.022214577177", "0.014782315601", "0.024758412523"];
363
364    #[test]
365    fn the_constructors_refuse_a_rate_the_venue_cannot_apply() {
366        assert_eq!(
367            SlippageConfig::fixed(d("-0.01")),
368            Err(SlippageConfigError::Negative(d("-0.01")))
369        );
370        assert_eq!(
371            SlippageConfig::fixed(Decimal::ONE),
372            Err(SlippageConfigError::NotBelowOne(Decimal::ONE))
373        );
374        assert_eq!(
375            SlippageConfig::uniform(d("0.02"), d("0.01"), 0),
376            Err(SlippageConfigError::Inverted {
377                low: d("0.02"),
378                high: d("0.01")
379            })
380        );
381        assert!(
382            SlippageConfig::fixed(d("-0")).is_ok(),
383            "negative zero is zero"
384        );
385        assert!(SlippageConfig::fixed(d("0.999")).is_ok());
386    }
387
388    #[test]
389    fn deserialising_validates_and_round_trips() {
390        let config = SlippageConfig::uniform(d("0.01"), d("0.025"), 42).unwrap();
391        let json = serde_json::to_string(&config).unwrap();
392        assert_eq!(
393            json,
394            r#"{"Uniform":{"low":"0.01","high":"0.025","seed":42}}"#
395        );
396        assert_eq!(
397            serde_json::from_str::<SlippageConfig>(&json).unwrap(),
398            config
399        );
400
401        assert_eq!(
402            serde_json::from_str::<SlippageConfig>(r#"{"Fixed":{"rate":"0.01"}}"#).unwrap(),
403            SlippageConfig::fixed(d("0.01")).unwrap()
404        );
405        assert_eq!(
406            serde_json::from_str::<SlippageConfig>(r#""None""#).unwrap(),
407            SlippageConfig::none()
408        );
409        let refused = serde_json::from_str::<SlippageConfig>(r#"{"Fixed":{"rate":"1.5"}}"#);
410        assert!(refused.unwrap_err().to_string().contains("not below 1"));
411    }
412}