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}