Skip to main content

rustrade_execution/
fill.rs

1use crate::market::{MarketBar, MarketSnapshot};
2use rust_decimal::Decimal;
3use rustrade_instrument::Side;
4use serde::{Deserialize, Serialize};
5use std::{fmt::Debug, time::Duration};
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/// When a simulated venue fills a market order, as [`MockExecutionConfig::market_order_timing`]
203/// selects. Defaults to [`OnArrival`](Self::OnArrival).
204///
205/// `#[non_exhaustive]`: other rules for when a held order is released may be added.
206///
207/// [`MockExecutionConfig::market_order_timing`]: crate::client::mock::MockExecutionConfig::market_order_timing
208#[derive(
209    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize,
210)]
211#[non_exhaustive]
212pub enum MarketOrderTiming {
213    /// Fill the moment the order reaches the venue, against the market it holds then.
214    ///
215    /// On a bar feed with no latency, that market is the close of the bar the sender has just
216    /// seen: the order trades at exactly the price it decided on, with no time to react, no
217    /// spread crossed and no impact. A common convention, and an optimistic one — a strategy can
218    /// come to depend on it. [`NextBar`](Self::NextBar) is the pessimistic comparison.
219    #[default]
220    OnArrival,
221
222    /// Hold the order until its instrument's next bar, and fill it at that bar's price.
223    ///
224    /// For bar data only: the venue must be fed bars, through the `bar` argument of
225    /// [`SimulatedVenue::apply_market`](crate::exchange::mock::SimulatedVenue::apply_market).
226    /// Anything else fails visibly rather than falling back:
227    /// - a market order on an instrument the venue has never been fed a bar for is rejected, naming
228    ///   this setting. That includes every market order on a
229    ///   [`RequestPriced`](crate::exchange::mock::venue::VenueRegime::RequestPriced) venue, which
230    ///   has no feed at all;
231    /// - a tick or quote between two bars moves the market but does not release a held order.
232    ///
233    /// # While it is held
234    /// The order is accepted as `Open`, so a cancel takes it back as it would a resting order. The
235    /// venue holds what the order would cost at its current price, as it holds a limit order's
236    /// cost at its limit, so two held orders cannot both spend one balance. An order the account
237    /// cannot cover at that price is refused on arrival, as it is under
238    /// [`OnArrival`](Self::OnArrival).
239    ///
240    /// # When the next bar arrives
241    /// The hold is released and the order filled as a taker, at the reference price
242    /// [`NextBarPrice`] picks, priced by the venue's [`FillModel`] and moved by its slippage like
243    /// any other taker fill. The fill is costed afresh, and an order the account can no longer
244    /// afford is cancelled and reported. Its time in force decides the rest as on arrival:
245    /// immediate-or-cancel keeps what the bar's size allows and cancels the remainder,
246    /// fill-or-kill trades all of it or nothing. The fill is stamped at the bar's instant.
247    ///
248    /// # When the data ends first
249    /// An order still held when its driver runs out of data is retired as expired, filled
250    /// nothing, so every accepted order is answered.
251    ///
252    /// Only market orders are held. A limit order rests and matches as it always does.
253    ///
254    /// Any bar releases a held order, one reported with zero volume included. To have such a bar
255    /// release nothing, combine this with [`StaleMarketPolicy::Wait`].
256    NextBar(NextBarPrice),
257}
258
259/// Which price of the next bar fills a market order held for it. See
260/// [`MarketOrderTiming::NextBar`].
261///
262/// `#[non_exhaustive]`: other reference prices may be added.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
264#[non_exhaustive]
265pub enum NextBarPrice {
266    /// The next bar's open: the first price the market traded at after the order was sent. The
267    /// realistic choice.
268    Open,
269
270    /// The next bar's worst price for the order: its high for a buy, its low for a sell.
271    ///
272    /// A deliberately **pessimistic bound**, not a model. A market order fills near the start of
273    /// the next bar, not at its extreme, so this overstates the cost of trading, and the more so
274    /// the longer or more volatile the bar. Its use is as a stress test: a strategy that still
275    /// makes money under it does not owe its edge to fill quality. It already charges more than
276    /// any slippage would, so combining it with slippage counts the same cost twice.
277    AdverseExtreme,
278}
279
280/// What a simulated venue does with a market order when the instrument's market is stale, as
281/// [`MockExecutionConfig::stale_market`] selects. Defaults to [`Off`](Self::Off).
282///
283/// # The problem it addresses
284/// A venue fed a market keeps the last one it saw for each instrument, and by default fills a
285/// market order against it however old it is. On a bar feed, a market order on an instrument with
286/// no bar at the current instant therefore fills at the close of its last bar, which may be
287/// minutes, hours or a weekend old. A real order would not get that price: in a quiet market it
288/// trades against the book, and in an outage, a halt or a closed market it is refused or waits
289/// for the reopen, filling at a price that may have gapped. A bar that only carries the previous
290/// close forward, with zero volume, makes this worse: it looks recent, but nothing traded.
291///
292/// A venue cannot tell a quiet market from a dead feed or a halt by looking at bars, and this
293/// policy does not try to. It treats an old market as one nobody can trade in yet.
294///
295/// # When to turn it on, and why it is off by default
296/// Turn it on for bar feeds, with a `max_age` a little over the bar interval, so that only a
297/// market with a missing bar counts as stale. It stays off by default because a venue cannot know
298/// a feed's bar interval, and because on a quote or tick feed the book stays valid until it is
299/// replaced: a quiet book is still a book a market order can trade against.
300///
301/// `#[non_exhaustive]`: other actions on a stale market may be added.
302///
303/// [`MockExecutionConfig::stale_market`]: crate::client::mock::MockExecutionConfig::stale_market
304#[derive(
305    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize,
306)]
307#[non_exhaustive]
308pub enum StaleMarketPolicy {
309    /// Fill a market order against the market the venue holds, however old.
310    #[default]
311    Off,
312
313    /// A market order arriving when the instrument's last fresh observation is more than
314    /// `max_age` old waits for the next one.
315    ///
316    /// # What is fresh
317    /// An observation the driver marks [`fresh`](crate::market::MarketObservation::fresh): a
318    /// trade, a quote, or a bar something traded in. A bar reported with zero volume still moves
319    /// the price the venue holds, but not the instant the market's age is measured from. An
320    /// observation exactly `max_age` old is still fresh, so a `max_age` of zero accepts an order
321    /// only at the instant of a fresh observation.
322    ///
323    /// An instrument whose venue has no price at all is not stale but unpriced, and its market
324    /// orders are rejected as they are without this policy.
325    ///
326    /// # What waits
327    /// Market orders only. The order is accepted as `Open` and held as
328    /// [`MarketOrderTiming::NextBar`] holds one: the venue holds what it would cost at the current
329    /// price, a cancel takes it back, and an order the account cannot cover at that price is
330    /// refused on arrival. An immediate-or-cancel or fill-or-kill market order is rejected
331    /// instead, with a reason naming this policy: it asked to trade now or not at all. That
332    /// includes the immediate-or-cancel orders an engine sends to close positions, so a strategy
333    /// closing out on a stale instrument has to try again once its market is fresh.
334    ///
335    /// A limit order that is marketable on arrival is not covered: it still fills against the
336    /// market the venue holds, however old.
337    ///
338    /// # When it fills
339    /// On the instrument's next fresh observation, as if it had arrived just after it: priced by
340    /// the venue's [`FillModel`] from the market that observation leaves, moved by its slippage,
341    /// and costed afresh, as any taker fill is. That is a trade's or quote's price, or a bar's
342    /// close. The fill is stamped at the observation's instant and follows the fills of orders
343    /// already resting, which the observation crossed first. An order the account can no longer
344    /// afford, or that the market cannot price, is cancelled and reported.
345    ///
346    /// Only an observation of that instrument releases a held order: a clock moving, or a fresh
347    /// observation of another instrument, never does. A deadline the order carries still retires
348    /// it, and an order still waiting when its driver runs out of data is retired as expired,
349    /// filled nothing.
350    ///
351    /// # With [`MarketOrderTiming::NextBar`]
352    /// Every market order already waits for its instrument's next bar and fills at that bar's
353    /// price, so a fresh market changes nothing. A bar reported with zero volume releases nothing:
354    /// the orders held for it wait for the next bar something traded in, except an
355    /// immediate-or-cancel or fill-or-kill order, which the bar cancels, since it had nothing to
356    /// trade. An immediate-or-cancel or fill-or-kill order arriving on a stale market is rejected,
357    /// as above.
358    ///
359    /// # Needs a market feed
360    /// A [`RequestPriced`](crate::exchange::mock::venue::VenueRegime::RequestPriced) venue, the
361    /// kind `MockExchange` drives, has no market feed to measure an age from, so it rejects every
362    /// market order, naming this policy. On a venue that is fed a market, an instrument it has
363    /// never been fed has no price at all, and its market orders are rejected as unpriced, as
364    /// above.
365    ///
366    /// # Serialised
367    /// `max_age` is a [`Duration`], so it serialises as seconds and nanoseconds:
368    /// `{"Wait":{"max_age":{"secs":90,"nanos":0}}}`.
369    Wait {
370        /// How old the last fresh observation may be for a market order to fill on arrival.
371        max_age: Duration,
372    },
373}
374
375impl NextBarPrice {
376    /// The reference price `bar` gives an order on `side`.
377    pub fn reference(self, side: Side, bar: &MarketBar) -> Decimal {
378        match (self, side) {
379            (Self::Open, _) => bar.open,
380            (Self::AdverseExtreme, Side::Buy) => bar.high,
381            (Self::AdverseExtreme, Side::Sell) => bar.low,
382        }
383    }
384}
385
386#[cfg(test)]
387#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
388mod tests {
389    use super::*;
390
391    #[test]
392    fn the_next_bar_price_is_its_open_or_its_worst_for_the_side() {
393        let bar = MarketBar::new(d("100"), d("110"), d("90"), d("105"));
394
395        assert_eq!(NextBarPrice::Open.reference(Side::Buy, &bar), d("100"));
396        assert_eq!(NextBarPrice::Open.reference(Side::Sell, &bar), d("100"));
397        assert_eq!(
398            NextBarPrice::AdverseExtreme.reference(Side::Buy, &bar),
399            d("110")
400        );
401        assert_eq!(
402            NextBarPrice::AdverseExtreme.reference(Side::Sell, &bar),
403            d("90")
404        );
405    }
406
407    #[test]
408    fn market_order_timing_defaults_to_on_arrival_and_round_trips() {
409        assert_eq!(MarketOrderTiming::default(), MarketOrderTiming::OnArrival);
410
411        for timing in [
412            MarketOrderTiming::OnArrival,
413            MarketOrderTiming::NextBar(NextBarPrice::Open),
414            MarketOrderTiming::NextBar(NextBarPrice::AdverseExtreme),
415        ] {
416            let json = serde_json::to_string(&timing).unwrap();
417            assert_eq!(
418                serde_json::from_str::<MarketOrderTiming>(&json).unwrap(),
419                timing
420            );
421        }
422    }
423
424    #[test]
425    fn stale_market_policy_defaults_to_off_and_round_trips() {
426        assert_eq!(StaleMarketPolicy::default(), StaleMarketPolicy::Off);
427
428        let wait = StaleMarketPolicy::Wait {
429            max_age: Duration::from_secs(90),
430        };
431        assert_eq!(
432            serde_json::to_string(&wait).unwrap(),
433            r#"{"Wait":{"max_age":{"secs":90,"nanos":0}}}"#,
434            "the shape the rustdoc promises a configuration file"
435        );
436
437        for policy in [StaleMarketPolicy::Off, wait] {
438            let json = serde_json::to_string(&policy).unwrap();
439            assert_eq!(
440                serde_json::from_str::<StaleMarketPolicy>(&json).unwrap(),
441                policy
442            );
443        }
444    }
445
446    fn d(s: &str) -> Decimal {
447        s.parse().unwrap()
448    }
449
450    /// Bid 99.5 / ask 100.5 / last 100.0 — a complete snapshot, mid also 100.0.
451    fn prices() -> MarketSnapshot {
452        MarketSnapshot::new(Some(d("99.5")), Some(d("100.5")), Some(d("100.0")))
453    }
454
455    /// A snapshot from three literal prices, for the partial-book cases.
456    fn snapshot(
457        bid: Option<Decimal>,
458        ask: Option<Decimal>,
459        last: Option<Decimal>,
460    ) -> MarketSnapshot {
461        MarketSnapshot::new(bid, ask, last)
462    }
463
464    #[test]
465    fn last_price_market_buy_uses_last() {
466        let market = prices();
467        assert_eq!(
468            LastPriceFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
469            Some(d("100.0"))
470        );
471    }
472
473    #[test]
474    fn bid_ask_market_buy_uses_ask() {
475        let market = prices();
476        assert_eq!(
477            BidAskFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
478            Some(d("100.5"))
479        );
480    }
481
482    #[test]
483    fn bid_ask_market_sell_uses_bid() {
484        let market = prices();
485        assert_eq!(
486            BidAskFillModel.fill_price(&FillContext::new(Side::Sell, Decimal::ONE, &market)),
487            Some(d("99.5"))
488        );
489    }
490
491    #[test]
492    fn midpoint_uses_mid() {
493        let market = prices();
494        assert_eq!(
495            MidpointFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
496            Some(d("100.0"))
497        );
498    }
499
500    #[test]
501    fn midpoint_falls_back_to_last_when_no_bid_ask() {
502        assert_eq!(
503            MidpointFillModel.fill_price(&FillContext::new(
504                Side::Buy,
505                Decimal::ONE,
506                &snapshot(None, None, Some(d("100.0")))
507            )),
508            Some(d("100.0"))
509        );
510    }
511
512    // --- SimFillConfig enum dispatch ---
513
514    #[test]
515    fn fill_model_config_last_price_dispatches() {
516        let market = prices();
517        let cfg = SimFillConfig::LastPrice(LastPriceFillModel);
518        assert_eq!(
519            cfg.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
520            LastPriceFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market))
521        );
522    }
523
524    #[test]
525    fn fill_model_config_bid_ask_dispatches() {
526        let market = prices();
527        let cfg = SimFillConfig::BidAsk(BidAskFillModel);
528        assert_eq!(
529            cfg.fill_price(&FillContext::new(Side::Sell, Decimal::ONE, &market)),
530            BidAskFillModel.fill_price(&FillContext::new(Side::Sell, Decimal::ONE, &market))
531        );
532    }
533
534    #[test]
535    fn fill_model_config_midpoint_dispatches() {
536        let market = prices();
537        let cfg = SimFillConfig::Midpoint(MidpointFillModel);
538        assert_eq!(
539            cfg.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
540            MidpointFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market))
541        );
542    }
543
544    #[test]
545    fn fill_model_config_default_is_last_price() {
546        assert_eq!(
547            SimFillConfig::default(),
548            SimFillConfig::LastPrice(LastPriceFillModel)
549        );
550    }
551
552    // --- Edge cases ---
553
554    #[test]
555    fn last_price_all_none_returns_none() {
556        // No market data at all — e.g. first tick of a backtest before any prices arrive.
557        // The mock exchange falls back to request.state.price when fill_price returns None.
558        assert_eq!(
559            LastPriceFillModel.fill_price(&FillContext::new(
560                Side::Buy,
561                Decimal::ONE,
562                &snapshot(None, None, None)
563            )),
564            None
565        );
566        assert_eq!(
567            LastPriceFillModel.fill_price(&FillContext::new(
568                Side::Sell,
569                Decimal::ONE,
570                &snapshot(None, None, None)
571            )),
572            None
573        );
574    }
575
576    #[test]
577    fn last_price_falls_back_to_bid_ask_when_no_last_price() {
578        // When last_price=None but bid/ask are present, the model falls back to
579        // bid/ask (as documented in the fallback chain). This exercises the tertiary
580        // fallback that was previously untested.
581        let market = snapshot(Some(d("99.5")), Some(d("100.5")), None);
582        assert_eq!(
583            LastPriceFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
584            Some(d("100.5")),
585            "Buy with no last_price should fall back to best_ask"
586        );
587        assert_eq!(
588            LastPriceFillModel.fill_price(&FillContext::new(Side::Sell, Decimal::ONE, &market)),
589            Some(d("99.5")),
590            "Sell with no last_price should fall back to best_bid"
591        );
592    }
593
594    /// A partial book falls back to the last price, whichever side is missing.
595    ///
596    /// These replace a pair that asserted the *limit* beat a stale `last_price` on a partial book.
597    /// That special case existed only to stop a limit buy filling above its own limit, which is now
598    /// the venue's job — and doing it here was what let the complete-book arm keep filling above a
599    /// limit unnoticed.
600    #[test]
601    fn midpoint_with_only_bid_falls_back_to_last() {
602        assert_eq!(
603            MidpointFillModel.fill_price(&FillContext::new(
604                Side::Buy,
605                Decimal::ONE,
606                &snapshot(Some(d("99.5")), None, Some(d("100.0")))
607            )),
608            Some(d("100.0"))
609        );
610    }
611
612    #[test]
613    fn midpoint_with_only_ask_falls_back_to_last() {
614        assert_eq!(
615            MidpointFillModel.fill_price(&FillContext::new(
616                Side::Sell,
617                Decimal::ONE,
618                &snapshot(None, Some(d("100.5")), Some(d("100.0")))
619            )),
620            Some(d("100.0"))
621        );
622    }
623
624    /// A complete book prices at the midpoint even where that sits far from the last print.
625    ///
626    /// This case had no coverage at all, and it is where the model used to fill a limit buy above
627    /// its own limit: `mid_price()` won outright and the limit was never consulted. There is no
628    /// limit to consult now — bounding the result is the venue's job — so what this pins is that
629    /// the midpoint wins over `last_price`, which is the whole content of the model.
630    #[test]
631    fn midpoint_complete_book_prices_at_mid_not_last() {
632        let market = snapshot(Some(d("99.5")), Some(d("100.5")), Some(d("110.0")));
633        assert_eq!(
634            MidpointFillModel.fill_price(&FillContext::new(Side::Buy, Decimal::ONE, &market)),
635            Some(d("100.0")),
636            "the midpoint of 99.5/100.5, not the stale 110.0 print"
637        );
638    }
639
640    /// No model reads the side it is not given, and none invents a price from nothing.
641    #[test]
642    fn every_model_returns_none_on_an_empty_snapshot() {
643        let empty = MarketSnapshot::default();
644        for side in [Side::Buy, Side::Sell] {
645            let fill = FillContext::new(side, Decimal::ONE, &empty);
646            assert_eq!(LastPriceFillModel.fill_price(&fill), None);
647            assert_eq!(BidAskFillModel.fill_price(&fill), None);
648            assert_eq!(MidpointFillModel.fill_price(&fill), None);
649        }
650    }
651}