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}