Skip to main content

rustrade_execution/
fill.rs

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