Skip to main content

rustrade_execution/
fill.rs

1use rust_decimal::Decimal;
2use rustrade_instrument::Side;
3use serde::{Deserialize, Serialize};
4
5/// Simulates the execution price for a backtest fill.
6///
7/// Called by the mock exchange engine when deciding at what price a pending
8/// order should be filled against incoming market data.
9///
10/// # Backtest-only
11///
12/// This trait is only relevant for simulated execution via `MockExchange`.
13/// Live execution clients receive real fill prices from the venue — they do
14/// not use `FillModel`.
15///
16/// # Arguments
17///
18/// * `side` — order side (Buy or Sell).
19/// * `order_price` — limit price if limit order; `None` for market orders.
20/// * `best_bid` — current best bid in the order book, if available.
21/// * `best_ask` — current best ask in the order book, if available.
22/// * `last_price` — most recent trade price, if available.
23///
24/// Returns `None` if insufficient market data is available to determine a
25/// fill price (e.g. no prices at all on the first tick of a backtest).
26pub trait FillModel {
27    fn fill_price(
28        &self,
29        side: Side,
30        order_price: Option<Decimal>,
31        best_bid: Option<Decimal>,
32        best_ask: Option<Decimal>,
33        last_price: Option<Decimal>,
34    ) -> Option<Decimal>;
35}
36
37/// Fills at the last trade price for market orders, or the order's limit
38/// price for limit orders.
39///
40/// Fallback chain: `order_price` → `last_price` → `best_ask` (Buy) / `best_bid` (Sell).
41///
42/// This is the simplest fill model and is well-suited for RL training where
43/// speed matters more than realism: it eliminates spread noise and keeps
44/// episode reward signals clean.
45#[derive(
46    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
47)]
48pub struct LastPriceFillModel;
49
50impl FillModel for LastPriceFillModel {
51    fn fill_price(
52        &self,
53        side: Side,
54        order_price: Option<Decimal>,
55        best_bid: Option<Decimal>,
56        best_ask: Option<Decimal>,
57        last_price: Option<Decimal>,
58    ) -> Option<Decimal> {
59        order_price.or(last_price).or(match side {
60            Side::Buy => best_ask,
61            Side::Sell => best_bid,
62        })
63    }
64}
65
66/// Fills market orders at the current best ask (buys) or best bid (sells),
67/// crossing the spread as a market order taker would.
68///
69/// Limit orders fill at the limit price (the price is already favorable
70/// relative to the market when fill is triggered).
71///
72/// Falls back to `last_price` if bid/ask are not available. This model is
73/// more realistic than [`LastPriceFillModel`] for strategies that frequently
74/// cross the spread.
75#[derive(
76    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
77)]
78pub struct BidAskFillModel;
79
80impl FillModel for BidAskFillModel {
81    fn fill_price(
82        &self,
83        side: Side,
84        order_price: Option<Decimal>,
85        best_bid: Option<Decimal>,
86        best_ask: Option<Decimal>,
87        last_price: Option<Decimal>,
88    ) -> Option<Decimal> {
89        if let Some(limit) = order_price {
90            // Limit order: fill at the limit price (caller is responsible for
91            // only calling fill_price when the limit is marketable).
92            return Some(limit);
93        }
94        // Market order: taker crosses the spread.
95        match side {
96            Side::Buy => best_ask.or(last_price),
97            Side::Sell => best_bid.or(last_price),
98        }
99    }
100}
101
102/// Fills at the midpoint of best bid and best ask.
103///
104/// Falls back to `order_price`, then `last_price` when the book is incomplete.
105///
106/// Useful when modelling execution quality between taker (crossing spread)
107/// and maker (resting at the limit), or when bid/ask data is always
108/// available in the backtest feed.
109///
110/// # Note on `order_price` (limit orders)
111///
112/// Unlike [`BidAskFillModel`], this model does **not** honour `order_price`
113/// when both bid and ask are present — it always fills at the midpoint
114/// regardless of the limit price. When the book is incomplete (only one
115/// side present or neither), `order_price` is preferred over `last_price`
116/// to avoid a fill at a worse price than the limit due to a stale last-trade
117/// price (above the limit for buys, below the limit for sells).
118///
119/// The caller is responsible for invoking `fill_price` only when a limit
120/// order is marketable (i.e., the limit has already been crossed). Using
121/// `MidpointFillModel` for strategies that require limit-price guarantees
122/// may result in fills at the midpoint rather than the limit.
123#[derive(
124    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
125)]
126pub struct MidpointFillModel;
127
128impl FillModel for MidpointFillModel {
129    fn fill_price(
130        &self,
131        _side: Side,
132        order_price: Option<Decimal>,
133        best_bid: Option<Decimal>,
134        best_ask: Option<Decimal>,
135        last_price: Option<Decimal>,
136    ) -> Option<Decimal> {
137        match (best_bid, best_ask) {
138            (Some(bid), Some(ask)) => Some((bid + ask) / Decimal::TWO),
139            _ => order_price.or(last_price),
140        }
141    }
142}
143
144/// Enum-dispatched fill model for use in types that require `Clone`,
145/// `Serialize`, and `Deserialize` (e.g. `MockExchangeConfig`).
146///
147/// Prefer this over `Box<dyn FillModel>` when the field must be part of
148/// a derived `serde` struct. Defaults to [`LastPriceFillModel`].
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
150pub enum SimFillConfig {
151    LastPrice(LastPriceFillModel),
152    BidAsk(BidAskFillModel),
153    Midpoint(MidpointFillModel),
154}
155
156impl Default for SimFillConfig {
157    fn default() -> Self {
158        Self::LastPrice(LastPriceFillModel)
159    }
160}
161
162impl FillModel for SimFillConfig {
163    fn fill_price(
164        &self,
165        side: Side,
166        order_price: Option<Decimal>,
167        best_bid: Option<Decimal>,
168        best_ask: Option<Decimal>,
169        last_price: Option<Decimal>,
170    ) -> Option<Decimal> {
171        match self {
172            SimFillConfig::LastPrice(m) => {
173                m.fill_price(side, order_price, best_bid, best_ask, last_price)
174            }
175            SimFillConfig::BidAsk(m) => {
176                m.fill_price(side, order_price, best_bid, best_ask, last_price)
177            }
178            SimFillConfig::Midpoint(m) => {
179                m.fill_price(side, order_price, best_bid, best_ask, last_price)
180            }
181        }
182    }
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
187mod tests {
188    use super::*;
189
190    fn d(s: &str) -> Decimal {
191        s.parse().unwrap()
192    }
193
194    fn prices() -> (Option<Decimal>, Option<Decimal>, Option<Decimal>) {
195        (Some(d("99.5")), Some(d("100.5")), Some(d("100.0")))
196    }
197
198    #[test]
199    fn last_price_market_buy_uses_last() {
200        let (bid, ask, last) = prices();
201        assert_eq!(
202            LastPriceFillModel.fill_price(Side::Buy, None, bid, ask, last),
203            Some(d("100.0"))
204        );
205    }
206
207    #[test]
208    fn last_price_limit_uses_order_price() {
209        let (bid, ask, last) = prices();
210        assert_eq!(
211            LastPriceFillModel.fill_price(Side::Buy, Some(d("99.0")), bid, ask, last),
212            Some(d("99.0"))
213        );
214    }
215
216    #[test]
217    fn bid_ask_market_buy_uses_ask() {
218        let (bid, ask, last) = prices();
219        assert_eq!(
220            BidAskFillModel.fill_price(Side::Buy, None, bid, ask, last),
221            Some(d("100.5"))
222        );
223    }
224
225    #[test]
226    fn bid_ask_market_sell_uses_bid() {
227        let (bid, ask, last) = prices();
228        assert_eq!(
229            BidAskFillModel.fill_price(Side::Sell, None, bid, ask, last),
230            Some(d("99.5"))
231        );
232    }
233
234    #[test]
235    fn midpoint_uses_mid() {
236        let (bid, ask, last) = prices();
237        assert_eq!(
238            MidpointFillModel.fill_price(Side::Buy, None, bid, ask, last),
239            Some(d("100.0"))
240        );
241    }
242
243    #[test]
244    fn midpoint_falls_back_to_last_when_no_bid_ask() {
245        assert_eq!(
246            MidpointFillModel.fill_price(Side::Buy, None, None, None, Some(d("100.0"))),
247            Some(d("100.0"))
248        );
249    }
250
251    // --- SimFillConfig enum dispatch ---
252
253    #[test]
254    fn fill_model_config_last_price_dispatches() {
255        let (bid, ask, last) = prices();
256        let cfg = SimFillConfig::LastPrice(LastPriceFillModel);
257        assert_eq!(
258            cfg.fill_price(Side::Buy, None, bid, ask, last),
259            LastPriceFillModel.fill_price(Side::Buy, None, bid, ask, last),
260        );
261    }
262
263    #[test]
264    fn fill_model_config_bid_ask_dispatches() {
265        let (bid, ask, last) = prices();
266        let cfg = SimFillConfig::BidAsk(BidAskFillModel);
267        assert_eq!(
268            cfg.fill_price(Side::Sell, None, bid, ask, last),
269            BidAskFillModel.fill_price(Side::Sell, None, bid, ask, last),
270        );
271    }
272
273    #[test]
274    fn fill_model_config_midpoint_dispatches() {
275        let (bid, ask, last) = prices();
276        let cfg = SimFillConfig::Midpoint(MidpointFillModel);
277        assert_eq!(
278            cfg.fill_price(Side::Buy, None, bid, ask, last),
279            MidpointFillModel.fill_price(Side::Buy, None, bid, ask, last),
280        );
281    }
282
283    #[test]
284    fn fill_model_config_default_is_last_price() {
285        assert_eq!(
286            SimFillConfig::default(),
287            SimFillConfig::LastPrice(LastPriceFillModel)
288        );
289    }
290
291    // --- Edge cases ---
292
293    #[test]
294    fn last_price_all_none_returns_none() {
295        // No market data at all — e.g. first tick of a backtest before any prices arrive.
296        // The mock exchange falls back to request.state.price when fill_price returns None.
297        assert_eq!(
298            LastPriceFillModel.fill_price(Side::Buy, None, None, None, None),
299            None
300        );
301        assert_eq!(
302            LastPriceFillModel.fill_price(Side::Sell, None, None, None, None),
303            None
304        );
305    }
306
307    #[test]
308    fn last_price_falls_back_to_bid_ask_when_no_last_price() {
309        // When last_price=None but bid/ask are present, the model falls back to
310        // bid/ask (as documented in the fallback chain). This exercises the tertiary
311        // fallback that was previously untested.
312        assert_eq!(
313            LastPriceFillModel.fill_price(Side::Buy, None, Some(d("99.5")), Some(d("100.5")), None),
314            Some(d("100.5")),
315            "Buy with no last_price should fall back to best_ask"
316        );
317        assert_eq!(
318            LastPriceFillModel.fill_price(
319                Side::Sell,
320                None,
321                Some(d("99.5")),
322                Some(d("100.5")),
323                None
324            ),
325            Some(d("99.5")),
326            "Sell with no last_price should fall back to best_bid"
327        );
328    }
329
330    #[test]
331    fn bid_ask_limit_order_wins_over_bid_ask() {
332        // Limit price must take priority over bid/ask even when both are present.
333        let (bid, ask, last) = prices();
334        let limit = Some(d("98.0"));
335        assert_eq!(
336            BidAskFillModel.fill_price(Side::Buy, limit, bid, ask, last),
337            limit,
338            "limit price should beat best_ask for buy"
339        );
340        assert_eq!(
341            BidAskFillModel.fill_price(Side::Sell, limit, bid, ask, last),
342            limit,
343            "limit price should beat best_bid for sell"
344        );
345    }
346
347    #[test]
348    fn midpoint_with_only_bid_falls_back_to_last() {
349        // Partial book: only bid present, no ask. Should fall back to last_price.
350        assert_eq!(
351            MidpointFillModel.fill_price(Side::Buy, None, Some(d("99.5")), None, Some(d("100.0"))),
352            Some(d("100.0"))
353        );
354    }
355
356    #[test]
357    fn midpoint_with_only_ask_falls_back_to_last() {
358        // Partial book: only ask present, no bid. Should fall back to last_price
359        // (order_price is None, so order_price.or(last_price) = last_price).
360        assert_eq!(
361            MidpointFillModel.fill_price(
362                Side::Sell,
363                None,
364                None,
365                Some(d("100.5")),
366                Some(d("100.0"))
367            ),
368            Some(d("100.0"))
369        );
370    }
371
372    #[test]
373    fn midpoint_partial_book_prefers_order_price_over_last() {
374        // With a partial book (one side missing), limit price takes priority over
375        // a potentially stale last_price. Previously last_price would win, which
376        // could fill a limit buy above its own limit.
377        assert_eq!(
378            MidpointFillModel.fill_price(
379                Side::Buy,
380                Some(d("100.0")),
381                Some(d("99.5")),
382                None,
383                Some(d("110.0"))
384            ),
385            Some(d("100.0")),
386            "partial book: limit price should beat stale last_price"
387        );
388    }
389}