Skip to main content

rustrade_execution/
fill.rs

1use crate::market::MarketSnapshot;
2use rust_decimal::Decimal;
3use rustrade_instrument::Side;
4use serde::{Deserialize, Serialize};
5use std::fmt::Debug;
6
7/// Everything a [`FillModel`] may price from.
8///
9/// # Fields are added, never removed
10///
11/// An implementation only ever reads this, so every increase in the simulated venue's fidelity —
12/// sizes at the touch, depth, a queue position — arrives as a new field rather than as a new
13/// parameter on [`fill_price`](FillModel::fill_price). That is what `#[non_exhaustive]` is for
14/// here: constructing one outside this crate would turn each such addition back into a breaking
15/// change, which is the churn this type exists to end.
16///
17/// The order's limit price is deliberately absent — see [`FillModel`].
18#[non_exhaustive]
19#[derive(Debug, Clone, Copy, Eq, PartialEq)]
20pub struct FillContext<'a> {
21    /// Which way the order trades.
22    pub side: Side,
23    /// How much the order asks to trade, which a size-aware impact model prices from.
24    ///
25    /// This is the order's whole quantity. Where the book shows less size than that, the venue
26    /// fills only what the book has and prices that part at what the model returns, so a model
27    /// reading this prices the order as if all of it traded.
28    pub quantity: Decimal,
29    /// The venue's view of the instrument. Every field is optional and absence is ordinary; see
30    /// [`MarketSnapshot`].
31    pub market: &'a MarketSnapshot,
32}
33
34impl<'a> FillContext<'a> {
35    pub fn new(side: Side, quantity: Decimal, market: &'a MarketSnapshot) -> Self {
36        Self {
37            side,
38            quantity,
39            market,
40        }
41    }
42}
43
44/// Prices a fill the venue has already decided will happen.
45///
46/// # Backtest-only
47///
48/// Only simulated execution consults a `FillModel`. Live execution clients receive real fill
49/// prices from the venue.
50///
51/// # Called only for a taker
52///
53/// A market order, or a limit order that is marketable when it arrives. A resting order matched
54/// later fills at *its own limit price* and never reaches a fill model: a maker is paid the price
55/// it quoted, and price improvement accrues to the aggressor that crossed it.
56///
57/// # This model does not see the limit price, and must not
58///
59/// A limit constrains the *result*, not the pricing. The venue clamps whatever is returned to the
60/// order's limit — never above a buy's, never below a sell's — so an implementation models
61/// slippage and spread and cannot produce a fill outside the order's terms.
62///
63/// Reading the limit here is how two of the three models this crate ships came to return the limit
64/// *itself* for a marketable order: a buy limit of 51,000 arriving against an ask of 50,000 filled
65/// at 51,000 — a worse price than a market order got under the same configuration. The third
66/// returned the midpoint even when it sat above a buy's limit. Removing the parameter is what
67/// makes that class of error unwritable rather than merely documented.
68///
69/// # Extensibility
70///
71/// Implement this to model market impact or other execution dynamics, and hand it to the venue
72/// with [`SimulatedVenue::with_fill_model`]. The built-in models ([`LastPriceFillModel`],
73/// [`BidAskFillModel`], [`MidpointFillModel`]) are baselines, and [`SimFillConfig`] selects one of
74/// them from a serialisable configuration.
75///
76/// Adverse slippage, fixed or drawn at random, needs no model of its own: the venue applies its
77/// [`SlippageConfig`] to whatever price the model returns, whichever model that is.
78///
79/// # It must be a pure function of its context
80///
81/// The venue may call [`fill_price`](Self::fill_price) more than once for one order, and for
82/// orders that never fill: it asks, for instance, whether a short sale is priceable at all before
83/// deciding to refuse it. So the same context must always give the same price, which is why this
84/// takes `&self`. Randomness belongs in the venue's [`SlippageConfig`], whose seeded stream
85/// advances exactly once per fill.
86///
87/// The price returned must be a real market price: the venue's arithmetic on it, slippage and
88/// settlement included, panics on `Decimal` overflow.
89///
90/// # It fills optimistically by default
91///
92/// The venue prices a taker at the market as it stands when the order arrives, which in a backtest
93/// on bars is the close of the bar the strategy has just seen. That is not look-ahead, since the
94/// close is known when deciding, but it is optimistic: it assumes trading at an observed price with
95/// no latency and, unless a model or slippage says otherwise, no cost of crossing.
96///
97/// # Returns
98///
99/// `None` when the context carries nothing this model can price from — no prices at all on the
100/// first ticks of a backtest, say. The venue turns that into a rejection naming the instrument
101/// rather than a panic.
102///
103/// [`SimulatedVenue::with_fill_model`]: crate::exchange::mock::SimulatedVenue::with_fill_model
104/// [`SlippageConfig`]: crate::slippage::SlippageConfig
105pub trait FillModel: Debug + Send + Sync {
106    fn fill_price(&self, fill: &FillContext<'_>) -> Option<Decimal>;
107}
108
109/// Fills at the last traded price, falling back to the price a taker would have to cross to.
110///
111/// Fallback chain: `last_price` → `best_ask` (Buy) / `best_bid` (Sell).
112///
113/// The simplest fill model — it ignores the spread entirely. Useful when spread modelling is
114/// handled elsewhere, or when deterministic fills are preferred.
115///
116/// Note that with both sides of an L1 book present, `last_price` is the *microprice* rather than
117/// the most recent print; see [`MarketSnapshot`].
118#[derive(
119    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
120)]
121pub struct LastPriceFillModel;
122
123impl FillModel for LastPriceFillModel {
124    fn fill_price(&self, fill: &FillContext<'_>) -> Option<Decimal> {
125        fill.market.last_price.or(match fill.side {
126            Side::Buy => fill.market.best_ask,
127            Side::Sell => fill.market.best_bid,
128        })
129    }
130}
131
132/// Fills at the current best ask (buys) or best bid (sells), crossing the spread as a taker does.
133///
134/// Falls back to `last_price` when the relevant side of the book is absent. More realistic than
135/// [`LastPriceFillModel`] for strategies that frequently cross the spread.
136#[derive(
137    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
138)]
139pub struct BidAskFillModel;
140
141impl FillModel for BidAskFillModel {
142    fn fill_price(&self, fill: &FillContext<'_>) -> Option<Decimal> {
143        match fill.side {
144            Side::Buy => fill.market.best_ask.or(fill.market.last_price),
145            Side::Sell => fill.market.best_bid.or(fill.market.last_price),
146        }
147    }
148}
149
150/// Fills at the midpoint of best bid and best ask, falling back to `last_price`.
151///
152/// Useful when modelling execution quality between taker (crossing the spread) and maker (resting
153/// at the quote), or when both sides of the book are always present in the backtest feed.
154///
155/// # It is optimistic for a taker
156///
157/// A midpoint is not a price anyone is offering, so a crossing order modelled this way saves half
158/// the spread it would really have paid. The venue bounds the result by the order's own limit;
159/// nothing bounds it by the touch.
160#[derive(
161    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
162)]
163pub struct MidpointFillModel;
164
165impl FillModel for MidpointFillModel {
166    fn fill_price(&self, fill: &FillContext<'_>) -> Option<Decimal> {
167        fill.market.mid_price().or(fill.market.last_price)
168    }
169}
170
171/// Selects one of the built-in fill models from a serialisable configuration, as
172/// [`MockExecutionConfig::fill_model`] does. Defaults to [`LastPriceFillModel`].
173///
174/// A model of your own cannot be named here; hand it to the venue with
175/// [`SimulatedVenue::with_fill_model`] instead.
176///
177/// [`MockExecutionConfig::fill_model`]: crate::client::mock::MockExecutionConfig::fill_model
178/// [`SimulatedVenue::with_fill_model`]: crate::exchange::mock::SimulatedVenue::with_fill_model
179#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
180pub enum SimFillConfig {
181    LastPrice(LastPriceFillModel),
182    BidAsk(BidAskFillModel),
183    Midpoint(MidpointFillModel),
184}
185
186impl Default for SimFillConfig {
187    fn default() -> Self {
188        Self::LastPrice(LastPriceFillModel)
189    }
190}
191
192impl FillModel for SimFillConfig {
193    fn fill_price(&self, fill: &FillContext<'_>) -> Option<Decimal> {
194        match self {
195            SimFillConfig::LastPrice(model) => model.fill_price(fill),
196            SimFillConfig::BidAsk(model) => model.fill_price(fill),
197            SimFillConfig::Midpoint(model) => model.fill_price(fill),
198        }
199    }
200}
201
202#[cfg(test)]
203#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
204mod tests {
205    use super::*;
206
207    fn d(s: &str) -> Decimal {
208        s.parse().unwrap()
209    }
210
211    /// Bid 99.5 / ask 100.5 / last 100.0 — a complete snapshot, mid also 100.0.
212    fn prices() -> MarketSnapshot {
213        MarketSnapshot::new(Some(d("99.5")), Some(d("100.5")), Some(d("100.0")))
214    }
215
216    /// A snapshot from three literal prices, for the partial-book cases.
217    fn snapshot(
218        bid: Option<Decimal>,
219        ask: Option<Decimal>,
220        last: Option<Decimal>,
221    ) -> MarketSnapshot {
222        MarketSnapshot::new(bid, ask, last)
223    }
224
225    #[test]
226    fn last_price_market_buy_uses_last() {
227        let market = prices();
228        assert_eq!(
229            LastPriceFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
230            Some(d("100.0"))
231        );
232    }
233
234    #[test]
235    fn bid_ask_market_buy_uses_ask() {
236        let market = prices();
237        assert_eq!(
238            BidAskFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
239            Some(d("100.5"))
240        );
241    }
242
243    #[test]
244    fn bid_ask_market_sell_uses_bid() {
245        let market = prices();
246        assert_eq!(
247            BidAskFillModel.fill_price(&FillContext::new(Side::Sell, Decimal::ONE, &market)),
248            Some(d("99.5"))
249        );
250    }
251
252    #[test]
253    fn midpoint_uses_mid() {
254        let market = prices();
255        assert_eq!(
256            MidpointFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
257            Some(d("100.0"))
258        );
259    }
260
261    #[test]
262    fn midpoint_falls_back_to_last_when_no_bid_ask() {
263        assert_eq!(
264            MidpointFillModel.fill_price(&FillContext::new(
265                Side::Buy,
266                Decimal::ONE,
267                &snapshot(None, None, Some(d("100.0")))
268            )),
269            Some(d("100.0"))
270        );
271    }
272
273    // --- SimFillConfig enum dispatch ---
274
275    #[test]
276    fn fill_model_config_last_price_dispatches() {
277        let market = prices();
278        let cfg = SimFillConfig::LastPrice(LastPriceFillModel);
279        assert_eq!(
280            cfg.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
281            LastPriceFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market))
282        );
283    }
284
285    #[test]
286    fn fill_model_config_bid_ask_dispatches() {
287        let market = prices();
288        let cfg = SimFillConfig::BidAsk(BidAskFillModel);
289        assert_eq!(
290            cfg.fill_price(&FillContext::new(Side::Sell, Decimal::ONE, &market)),
291            BidAskFillModel.fill_price(&FillContext::new(Side::Sell, Decimal::ONE, &market))
292        );
293    }
294
295    #[test]
296    fn fill_model_config_midpoint_dispatches() {
297        let market = prices();
298        let cfg = SimFillConfig::Midpoint(MidpointFillModel);
299        assert_eq!(
300            cfg.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
301            MidpointFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market))
302        );
303    }
304
305    #[test]
306    fn fill_model_config_default_is_last_price() {
307        assert_eq!(
308            SimFillConfig::default(),
309            SimFillConfig::LastPrice(LastPriceFillModel)
310        );
311    }
312
313    // --- Edge cases ---
314
315    #[test]
316    fn last_price_all_none_returns_none() {
317        // No market data at all — e.g. first tick of a backtest before any prices arrive.
318        // The mock exchange falls back to request.state.price when fill_price returns None.
319        assert_eq!(
320            LastPriceFillModel.fill_price(&FillContext::new(
321                Side::Buy,
322                Decimal::ONE,
323                &snapshot(None, None, None)
324            )),
325            None
326        );
327        assert_eq!(
328            LastPriceFillModel.fill_price(&FillContext::new(
329                Side::Sell,
330                Decimal::ONE,
331                &snapshot(None, None, None)
332            )),
333            None
334        );
335    }
336
337    #[test]
338    fn last_price_falls_back_to_bid_ask_when_no_last_price() {
339        // When last_price=None but bid/ask are present, the model falls back to
340        // bid/ask (as documented in the fallback chain). This exercises the tertiary
341        // fallback that was previously untested.
342        let market = snapshot(Some(d("99.5")), Some(d("100.5")), None);
343        assert_eq!(
344            LastPriceFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
345            Some(d("100.5")),
346            "Buy with no last_price should fall back to best_ask"
347        );
348        assert_eq!(
349            LastPriceFillModel.fill_price(&FillContext::new(Side::Sell, Decimal::ONE, &market)),
350            Some(d("99.5")),
351            "Sell with no last_price should fall back to best_bid"
352        );
353    }
354
355    /// A partial book falls back to the last price, whichever side is missing.
356    ///
357    /// These replace a pair that asserted the *limit* beat a stale `last_price` on a partial book.
358    /// That special case existed only to stop a limit buy filling above its own limit, which is now
359    /// the venue's job — and doing it here was what let the complete-book arm keep filling above a
360    /// limit unnoticed.
361    #[test]
362    fn midpoint_with_only_bid_falls_back_to_last() {
363        assert_eq!(
364            MidpointFillModel.fill_price(&FillContext::new(
365                Side::Buy,
366                Decimal::ONE,
367                &snapshot(Some(d("99.5")), None, Some(d("100.0")))
368            )),
369            Some(d("100.0"))
370        );
371    }
372
373    #[test]
374    fn midpoint_with_only_ask_falls_back_to_last() {
375        assert_eq!(
376            MidpointFillModel.fill_price(&FillContext::new(
377                Side::Sell,
378                Decimal::ONE,
379                &snapshot(None, Some(d("100.5")), Some(d("100.0")))
380            )),
381            Some(d("100.0"))
382        );
383    }
384
385    /// A complete book prices at the midpoint even where that sits far from the last print.
386    ///
387    /// This case had no coverage at all, and it is where the model used to fill a limit buy above
388    /// its own limit: `mid_price()` won outright and the limit was never consulted. There is no
389    /// limit to consult now — bounding the result is the venue's job — so what this pins is that
390    /// the midpoint wins over `last_price`, which is the whole content of the model.
391    #[test]
392    fn midpoint_complete_book_prices_at_mid_not_last() {
393        let market = snapshot(Some(d("99.5")), Some(d("100.5")), Some(d("110.0")));
394        assert_eq!(
395            MidpointFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
396            Some(d("100.0")),
397            "the midpoint of 99.5/100.5, not the stale 110.0 print"
398        );
399    }
400
401    /// No model reads the side it is not given, and none invents a price from nothing.
402    #[test]
403    fn every_model_returns_none_on_an_empty_snapshot() {
404        let empty = MarketSnapshot::default();
405        for side in [Side::Buy, Side::Sell] {
406            let fill = FillContext::new(side, Decimal::ONE, &empty);
407            assert_eq!(LastPriceFillModel.fill_price(&fill), None);
408            assert_eq!(BidAskFillModel.fill_price(&fill), None);
409            assert_eq!(MidpointFillModel.fill_price(&fill), None);
410        }
411    }
412}