Skip to main content

nautilus_execution/models/
fill.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{
17    cell::RefCell,
18    fmt::{Debug, Display},
19    rc::Rc,
20};
21
22#[cfg(all(feature = "simulation", madsim))]
23use madsim::rand::RngCore;
24use nautilus_core::{
25    UnixNanos,
26    correctness::{check_in_range_inclusive_f64, check_non_negative_f64},
27};
28use nautilus_model::{
29    data::order::BookOrder,
30    enums::{BookType, OrderSide},
31    identifiers::InstrumentId,
32    instruments::{Instrument, InstrumentAny},
33    orderbook::OrderBook,
34    orders::{Order, OrderAny},
35    types::{Price, Quantity},
36};
37use rand::{RngExt, SeedableRng, rngs::StdRng};
38use rust_decimal::Decimal;
39use rust_decimal_macros::dec;
40
41// Sentinel size used as "unlimited" liquidity in the synthetic fill book.
42const UNLIMITED_LIQUIDITY_UNITS: u64 = 10_000_000_000;
43
44fn unlimited_liquidity(precision: u8) -> Quantity {
45    Quantity::from_mantissa_exponent(UNLIMITED_LIQUIDITY_UNITS, 0, precision)
46}
47
48pub trait FillModel {
49    /// Returns `true` if a limit order should be filled based on the model.
50    ///
51    /// # Errors
52    ///
53    /// Returns an error if the model cannot determine whether the order should fill.
54    fn is_limit_filled(&mut self) -> anyhow::Result<bool>;
55
56    /// Returns `true` if an order fill should slip by one tick.
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if the model cannot determine whether the order should slip.
61    fn is_slipped(&mut self) -> anyhow::Result<bool>;
62
63    /// Returns whether limit orders at or inside the spread are fillable.
64    ///
65    /// When true, the matching core treats a limit order as fillable if its
66    /// price is at or better than the current best quote on its own side
67    /// (BUY >= bid, SELL <= ask), not just when it crosses the spread.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the model cannot determine its spread-fill behavior.
72    fn fill_limit_inside_spread(&self) -> anyhow::Result<bool> {
73        Ok(false)
74    }
75
76    /// Returns a simulated `OrderBook` for fill simulation.
77    ///
78    /// Custom fill models provide their own liquidity simulation by returning an
79    /// `OrderBook` that represents expected market liquidity. The matching engine
80    /// uses this to determine fills.
81    ///
82    /// Returns `None` to use the matching engine's standard fill logic.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if the model cannot provide simulated liquidity.
87    fn get_orderbook_for_fill_simulation(
88        &mut self,
89        instrument: &InstrumentAny,
90        order: &OrderAny,
91        best_bid: Price,
92        best_ask: Price,
93    ) -> anyhow::Result<Option<OrderBook>>;
94}
95
96/// Shared runtime handle for a fill model.
97#[derive(Clone)]
98pub struct FillModelHandle(Rc<RefCell<dyn FillModel>>);
99
100impl FillModelHandle {
101    /// Creates a new [`FillModelHandle`] from a fill model.
102    #[must_use]
103    pub fn new<T>(model: T) -> Self
104    where
105        T: FillModel + 'static,
106    {
107        Self(Rc::new(RefCell::new(model)))
108    }
109
110    /// Creates a new [`FillModelHandle`] from an existing reference-counted model.
111    #[must_use]
112    pub fn from_rc(model: Rc<RefCell<dyn FillModel>>) -> Self {
113        Self(model)
114    }
115}
116
117impl Debug for FillModelHandle {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_tuple(stringify!(FillModelHandle))
120            .field(&"<dyn FillModel>")
121            .finish()
122    }
123}
124
125impl FillModel for FillModelHandle {
126    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
127        self.0.borrow_mut().is_limit_filled()
128    }
129
130    fn is_slipped(&mut self) -> anyhow::Result<bool> {
131        self.0.borrow_mut().is_slipped()
132    }
133
134    fn fill_limit_inside_spread(&self) -> anyhow::Result<bool> {
135        self.0.borrow().fill_limit_inside_spread()
136    }
137
138    fn get_orderbook_for_fill_simulation(
139        &mut self,
140        instrument: &InstrumentAny,
141        order: &OrderAny,
142        best_bid: Price,
143        best_ask: Price,
144    ) -> anyhow::Result<Option<OrderBook>> {
145        self.0
146            .borrow_mut()
147            .get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
148    }
149}
150
151impl Default for FillModelHandle {
152    fn default() -> Self {
153        FillModelAny::default().into()
154    }
155}
156
157impl From<FillModelAny> for FillModelHandle {
158    fn from(model: FillModelAny) -> Self {
159        Self::new(model)
160    }
161}
162
163#[derive(Debug)]
164pub struct ProbabilisticFillState {
165    prob_fill_on_limit: f64,
166    prob_slippage: f64,
167    random_seed: Option<u64>,
168    rng: StdRng,
169}
170
171impl ProbabilisticFillState {
172    /// Creates a new [`ProbabilisticFillState`] instance.
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if probability parameters are not in range [0, 1].
177    pub fn new(
178        prob_fill_on_limit: f64,
179        prob_slippage: f64,
180        random_seed: Option<u64>,
181    ) -> anyhow::Result<Self> {
182        check_in_range_inclusive_f64(prob_fill_on_limit, 0.0, 1.0, "prob_fill_on_limit")?;
183        check_in_range_inclusive_f64(prob_slippage, 0.0, 1.0, "prob_slippage")?;
184        let rng = match random_seed {
185            Some(seed) => StdRng::seed_from_u64(seed),
186            None => default_std_rng(),
187        };
188        Ok(Self {
189            prob_fill_on_limit,
190            prob_slippage,
191            random_seed,
192            rng,
193        })
194    }
195
196    pub fn is_limit_filled(&mut self) -> bool {
197        self.event_success(self.prob_fill_on_limit)
198    }
199
200    pub fn is_slipped(&mut self) -> bool {
201        self.event_success(self.prob_slippage)
202    }
203
204    pub fn random_bool(&mut self, probability: f64) -> bool {
205        self.event_success(probability)
206    }
207
208    fn event_success(&mut self, probability: f64) -> bool {
209        match probability {
210            0.0 => false,
211            1.0 => true,
212            _ => self.rng.random_bool(probability),
213        }
214    }
215}
216
217impl Clone for ProbabilisticFillState {
218    fn clone(&self) -> Self {
219        Self::new(
220            self.prob_fill_on_limit,
221            self.prob_slippage,
222            self.random_seed,
223        )
224        .expect("ProbabilisticFillState clone should not fail with valid parameters")
225    }
226}
227
228fn default_std_rng() -> StdRng {
229    #[cfg(all(feature = "simulation", madsim))]
230    {
231        // Deterministic RNG when running inside a madsim runtime; otherwise
232        // (e.g. plain `#[rstest]` tests under `cfg(madsim)`) fall back to the
233        // host RNG. Production paths under simulation always run inside a
234        // runtime, so they continue to consume seeded bytes.
235        if madsim::runtime::Handle::try_current().is_ok() {
236            let mut seed = [0u8; 32];
237            madsim::rand::thread_rng().fill_bytes(&mut seed);
238            return StdRng::from_seed(seed);
239        }
240    }
241
242    StdRng::from_rng(&mut rand::rng()) // dst-ok: outside madsim runtime
243}
244
245fn build_l2_book(instrument_id: InstrumentId) -> OrderBook {
246    OrderBook::new(instrument_id, BookType::L2_MBP)
247}
248
249fn add_order(book: &mut OrderBook, side: OrderSide, price: Price, size: Quantity, order_id: u64) {
250    let order = BookOrder::new(side, price, size, order_id);
251    book.add(order, 0, 0, UnixNanos::default());
252}
253
254#[derive(Debug)]
255#[cfg_attr(
256    feature = "python",
257    pyo3::pyclass(
258        module = "nautilus_trader.core.nautilus_pyo3.execution",
259        unsendable,
260        from_py_object
261    )
262)]
263#[cfg_attr(
264    feature = "python",
265    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
266)]
267pub struct DefaultFillModel {
268    state: ProbabilisticFillState,
269}
270
271impl DefaultFillModel {
272    /// Creates a new [`DefaultFillModel`] instance.
273    ///
274    /// # Errors
275    ///
276    /// Returns an error if probability parameters are not in range [0, 1].
277    pub fn new(
278        prob_fill_on_limit: f64,
279        prob_slippage: f64,
280        random_seed: Option<u64>,
281    ) -> anyhow::Result<Self> {
282        Ok(Self {
283            state: ProbabilisticFillState::new(prob_fill_on_limit, prob_slippage, random_seed)?,
284        })
285    }
286}
287
288impl Clone for DefaultFillModel {
289    fn clone(&self) -> Self {
290        Self {
291            state: self.state.clone(),
292        }
293    }
294}
295
296impl Default for DefaultFillModel {
297    fn default() -> Self {
298        Self::new(1.0, 0.0, None).unwrap()
299    }
300}
301
302impl Display for DefaultFillModel {
303    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304        write!(
305            f,
306            "DefaultFillModel(prob_fill_on_limit: {}, prob_slippage: {})",
307            self.state.prob_fill_on_limit, self.state.prob_slippage
308        )
309    }
310}
311
312impl FillModel for DefaultFillModel {
313    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
314        Ok(self.state.is_limit_filled())
315    }
316
317    fn is_slipped(&mut self) -> anyhow::Result<bool> {
318        Ok(self.state.is_slipped())
319    }
320
321    fn get_orderbook_for_fill_simulation(
322        &mut self,
323        _instrument: &InstrumentAny,
324        _order: &OrderAny,
325        _best_bid: Price,
326        _best_ask: Price,
327    ) -> anyhow::Result<Option<OrderBook>> {
328        Ok(None)
329    }
330}
331
332/// Fill model that executes all orders at the best available price with unlimited liquidity.
333#[derive(Debug)]
334#[cfg_attr(
335    feature = "python",
336    pyo3::pyclass(
337        module = "nautilus_trader.core.nautilus_pyo3.execution",
338        unsendable,
339        from_py_object
340    )
341)]
342#[cfg_attr(
343    feature = "python",
344    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
345)]
346pub struct BestPriceFillModel {
347    state: ProbabilisticFillState,
348}
349
350impl BestPriceFillModel {
351    /// Creates a new [`BestPriceFillModel`] instance.
352    ///
353    /// # Errors
354    ///
355    /// Returns an error if probability parameters are not in range [0, 1].
356    pub fn new(
357        prob_fill_on_limit: f64,
358        prob_slippage: f64,
359        random_seed: Option<u64>,
360    ) -> anyhow::Result<Self> {
361        Ok(Self {
362            state: ProbabilisticFillState::new(prob_fill_on_limit, prob_slippage, random_seed)?,
363        })
364    }
365}
366
367impl Clone for BestPriceFillModel {
368    fn clone(&self) -> Self {
369        Self {
370            state: self.state.clone(),
371        }
372    }
373}
374
375impl Default for BestPriceFillModel {
376    fn default() -> Self {
377        Self::new(1.0, 0.0, None).unwrap()
378    }
379}
380
381impl FillModel for BestPriceFillModel {
382    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
383        Ok(self.state.is_limit_filled())
384    }
385
386    fn is_slipped(&mut self) -> anyhow::Result<bool> {
387        Ok(self.state.is_slipped())
388    }
389
390    fn fill_limit_inside_spread(&self) -> anyhow::Result<bool> {
391        Ok(true)
392    }
393
394    fn get_orderbook_for_fill_simulation(
395        &mut self,
396        instrument: &InstrumentAny,
397        _order: &OrderAny,
398        best_bid: Price,
399        best_ask: Price,
400    ) -> anyhow::Result<Option<OrderBook>> {
401        let mut book = build_l2_book(instrument.id());
402        let size_prec = instrument.size_precision();
403        add_order(
404            &mut book,
405            OrderSide::Buy,
406            best_bid,
407            unlimited_liquidity(size_prec),
408            1,
409        );
410        add_order(
411            &mut book,
412            OrderSide::Sell,
413            best_ask,
414            unlimited_liquidity(size_prec),
415            2,
416        );
417        Ok(Some(book))
418    }
419}
420
421/// Fill model that forces exactly one tick of slippage for all orders.
422#[derive(Debug)]
423#[cfg_attr(
424    feature = "python",
425    pyo3::pyclass(
426        module = "nautilus_trader.core.nautilus_pyo3.execution",
427        unsendable,
428        from_py_object
429    )
430)]
431#[cfg_attr(
432    feature = "python",
433    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
434)]
435pub struct OneTickSlippageFillModel {
436    state: ProbabilisticFillState,
437}
438
439impl OneTickSlippageFillModel {
440    /// Creates a new [`OneTickSlippageFillModel`] instance.
441    ///
442    /// # Errors
443    ///
444    /// Returns an error if probability parameters are not in range [0, 1].
445    pub fn new(
446        prob_fill_on_limit: f64,
447        prob_slippage: f64,
448        random_seed: Option<u64>,
449    ) -> anyhow::Result<Self> {
450        Ok(Self {
451            state: ProbabilisticFillState::new(prob_fill_on_limit, prob_slippage, random_seed)?,
452        })
453    }
454}
455
456impl Clone for OneTickSlippageFillModel {
457    fn clone(&self) -> Self {
458        Self {
459            state: self.state.clone(),
460        }
461    }
462}
463
464impl Default for OneTickSlippageFillModel {
465    fn default() -> Self {
466        Self::new(1.0, 0.0, None).unwrap()
467    }
468}
469
470impl FillModel for OneTickSlippageFillModel {
471    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
472        Ok(self.state.is_limit_filled())
473    }
474
475    fn is_slipped(&mut self) -> anyhow::Result<bool> {
476        Ok(self.state.is_slipped())
477    }
478
479    fn get_orderbook_for_fill_simulation(
480        &mut self,
481        instrument: &InstrumentAny,
482        _order: &OrderAny,
483        best_bid: Price,
484        best_ask: Price,
485    ) -> anyhow::Result<Option<OrderBook>> {
486        let tick = instrument.price_increment();
487        let size_prec = instrument.size_precision();
488        let mut book = build_l2_book(instrument.id());
489
490        add_order(
491            &mut book,
492            OrderSide::Buy,
493            best_bid - tick,
494            unlimited_liquidity(size_prec),
495            1,
496        );
497        add_order(
498            &mut book,
499            OrderSide::Sell,
500            best_ask + tick,
501            unlimited_liquidity(size_prec),
502            2,
503        );
504        Ok(Some(book))
505    }
506}
507
508/// Fill model with 50/50 chance of best price fill or one tick slippage.
509#[derive(Debug)]
510#[cfg_attr(
511    feature = "python",
512    pyo3::pyclass(
513        module = "nautilus_trader.core.nautilus_pyo3.execution",
514        unsendable,
515        from_py_object
516    )
517)]
518#[cfg_attr(
519    feature = "python",
520    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
521)]
522pub struct ProbabilisticFillModel {
523    state: ProbabilisticFillState,
524}
525
526impl ProbabilisticFillModel {
527    /// Creates a new [`ProbabilisticFillModel`] instance.
528    ///
529    /// # Errors
530    ///
531    /// Returns an error if probability parameters are not in range [0, 1].
532    pub fn new(
533        prob_fill_on_limit: f64,
534        prob_slippage: f64,
535        random_seed: Option<u64>,
536    ) -> anyhow::Result<Self> {
537        Ok(Self {
538            state: ProbabilisticFillState::new(prob_fill_on_limit, prob_slippage, random_seed)?,
539        })
540    }
541}
542
543impl Clone for ProbabilisticFillModel {
544    fn clone(&self) -> Self {
545        Self {
546            state: self.state.clone(),
547        }
548    }
549}
550
551impl Default for ProbabilisticFillModel {
552    fn default() -> Self {
553        Self::new(1.0, 0.0, None).unwrap()
554    }
555}
556
557impl FillModel for ProbabilisticFillModel {
558    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
559        Ok(self.state.is_limit_filled())
560    }
561
562    fn is_slipped(&mut self) -> anyhow::Result<bool> {
563        Ok(self.state.is_slipped())
564    }
565
566    fn get_orderbook_for_fill_simulation(
567        &mut self,
568        instrument: &InstrumentAny,
569        _order: &OrderAny,
570        best_bid: Price,
571        best_ask: Price,
572    ) -> anyhow::Result<Option<OrderBook>> {
573        let tick = instrument.price_increment();
574        let size_prec = instrument.size_precision();
575        let mut book = build_l2_book(instrument.id());
576
577        if self.state.random_bool(0.5) {
578            add_order(
579                &mut book,
580                OrderSide::Buy,
581                best_bid,
582                unlimited_liquidity(size_prec),
583                1,
584            );
585            add_order(
586                &mut book,
587                OrderSide::Sell,
588                best_ask,
589                unlimited_liquidity(size_prec),
590                2,
591            );
592        } else {
593            add_order(
594                &mut book,
595                OrderSide::Buy,
596                best_bid - tick,
597                unlimited_liquidity(size_prec),
598                1,
599            );
600            add_order(
601                &mut book,
602                OrderSide::Sell,
603                best_ask + tick,
604                unlimited_liquidity(size_prec),
605                2,
606            );
607        }
608        Ok(Some(book))
609    }
610}
611
612/// Fill model with two tiers: first 10 contracts at best price, remainder one tick worse.
613#[derive(Debug)]
614#[cfg_attr(
615    feature = "python",
616    pyo3::pyclass(
617        module = "nautilus_trader.core.nautilus_pyo3.execution",
618        unsendable,
619        from_py_object
620    )
621)]
622#[cfg_attr(
623    feature = "python",
624    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
625)]
626pub struct TwoTierFillModel {
627    state: ProbabilisticFillState,
628}
629
630impl TwoTierFillModel {
631    /// Creates a new [`TwoTierFillModel`] instance.
632    ///
633    /// # Errors
634    ///
635    /// Returns an error if probability parameters are not in range [0, 1].
636    pub fn new(
637        prob_fill_on_limit: f64,
638        prob_slippage: f64,
639        random_seed: Option<u64>,
640    ) -> anyhow::Result<Self> {
641        Ok(Self {
642            state: ProbabilisticFillState::new(prob_fill_on_limit, prob_slippage, random_seed)?,
643        })
644    }
645}
646
647impl Clone for TwoTierFillModel {
648    fn clone(&self) -> Self {
649        Self {
650            state: self.state.clone(),
651        }
652    }
653}
654
655impl Default for TwoTierFillModel {
656    fn default() -> Self {
657        Self::new(1.0, 0.0, None).unwrap()
658    }
659}
660
661impl FillModel for TwoTierFillModel {
662    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
663        Ok(self.state.is_limit_filled())
664    }
665
666    fn is_slipped(&mut self) -> anyhow::Result<bool> {
667        Ok(self.state.is_slipped())
668    }
669
670    fn get_orderbook_for_fill_simulation(
671        &mut self,
672        instrument: &InstrumentAny,
673        _order: &OrderAny,
674        best_bid: Price,
675        best_ask: Price,
676    ) -> anyhow::Result<Option<OrderBook>> {
677        let tick = instrument.price_increment();
678        let size_prec = instrument.size_precision();
679        let mut book = build_l2_book(instrument.id());
680
681        add_order(
682            &mut book,
683            OrderSide::Buy,
684            best_bid,
685            Quantity::new(10.0, size_prec),
686            1,
687        );
688        add_order(
689            &mut book,
690            OrderSide::Sell,
691            best_ask,
692            Quantity::new(10.0, size_prec),
693            2,
694        );
695        add_order(
696            &mut book,
697            OrderSide::Buy,
698            best_bid - tick,
699            unlimited_liquidity(size_prec),
700            3,
701        );
702        add_order(
703            &mut book,
704            OrderSide::Sell,
705            best_ask + tick,
706            unlimited_liquidity(size_prec),
707            4,
708        );
709        Ok(Some(book))
710    }
711}
712
713/// Fill model with three tiers: 50 at best, 30 at +1 tick, 20 at +2 ticks.
714#[derive(Debug)]
715#[cfg_attr(
716    feature = "python",
717    pyo3::pyclass(
718        module = "nautilus_trader.core.nautilus_pyo3.execution",
719        unsendable,
720        from_py_object
721    )
722)]
723#[cfg_attr(
724    feature = "python",
725    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
726)]
727pub struct ThreeTierFillModel {
728    state: ProbabilisticFillState,
729}
730
731impl ThreeTierFillModel {
732    /// Creates a new [`ThreeTierFillModel`] instance.
733    ///
734    /// # Errors
735    ///
736    /// Returns an error if probability parameters are not in range [0, 1].
737    pub fn new(
738        prob_fill_on_limit: f64,
739        prob_slippage: f64,
740        random_seed: Option<u64>,
741    ) -> anyhow::Result<Self> {
742        Ok(Self {
743            state: ProbabilisticFillState::new(prob_fill_on_limit, prob_slippage, random_seed)?,
744        })
745    }
746}
747
748impl Clone for ThreeTierFillModel {
749    fn clone(&self) -> Self {
750        Self {
751            state: self.state.clone(),
752        }
753    }
754}
755
756impl Default for ThreeTierFillModel {
757    fn default() -> Self {
758        Self::new(1.0, 0.0, None).unwrap()
759    }
760}
761
762impl FillModel for ThreeTierFillModel {
763    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
764        Ok(self.state.is_limit_filled())
765    }
766
767    fn is_slipped(&mut self) -> anyhow::Result<bool> {
768        Ok(self.state.is_slipped())
769    }
770
771    fn get_orderbook_for_fill_simulation(
772        &mut self,
773        instrument: &InstrumentAny,
774        _order: &OrderAny,
775        best_bid: Price,
776        best_ask: Price,
777    ) -> anyhow::Result<Option<OrderBook>> {
778        let tick = instrument.price_increment();
779        let two_ticks = tick + tick;
780        let size_prec = instrument.size_precision();
781        let mut book = build_l2_book(instrument.id());
782
783        add_order(
784            &mut book,
785            OrderSide::Buy,
786            best_bid,
787            Quantity::new(50.0, size_prec),
788            1,
789        );
790        add_order(
791            &mut book,
792            OrderSide::Sell,
793            best_ask,
794            Quantity::new(50.0, size_prec),
795            2,
796        );
797        add_order(
798            &mut book,
799            OrderSide::Buy,
800            best_bid - tick,
801            Quantity::new(30.0, size_prec),
802            3,
803        );
804        add_order(
805            &mut book,
806            OrderSide::Sell,
807            best_ask + tick,
808            Quantity::new(30.0, size_prec),
809            4,
810        );
811        add_order(
812            &mut book,
813            OrderSide::Buy,
814            best_bid - two_ticks,
815            Quantity::new(20.0, size_prec),
816            5,
817        );
818        add_order(
819            &mut book,
820            OrderSide::Sell,
821            best_ask + two_ticks,
822            Quantity::new(20.0, size_prec),
823            6,
824        );
825        Ok(Some(book))
826    }
827}
828
829/// Fill model that simulates partial fills: max 5 contracts at best, unlimited one tick worse.
830#[derive(Debug)]
831#[cfg_attr(
832    feature = "python",
833    pyo3::pyclass(
834        module = "nautilus_trader.core.nautilus_pyo3.execution",
835        unsendable,
836        from_py_object
837    )
838)]
839#[cfg_attr(
840    feature = "python",
841    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
842)]
843pub struct LimitOrderPartialFillModel {
844    state: ProbabilisticFillState,
845}
846
847impl LimitOrderPartialFillModel {
848    /// Creates a new [`LimitOrderPartialFillModel`] instance.
849    ///
850    /// # Errors
851    ///
852    /// Returns an error if probability parameters are not in range [0, 1].
853    pub fn new(
854        prob_fill_on_limit: f64,
855        prob_slippage: f64,
856        random_seed: Option<u64>,
857    ) -> anyhow::Result<Self> {
858        Ok(Self {
859            state: ProbabilisticFillState::new(prob_fill_on_limit, prob_slippage, random_seed)?,
860        })
861    }
862}
863
864impl Clone for LimitOrderPartialFillModel {
865    fn clone(&self) -> Self {
866        Self {
867            state: self.state.clone(),
868        }
869    }
870}
871
872impl Default for LimitOrderPartialFillModel {
873    fn default() -> Self {
874        Self::new(1.0, 0.0, None).unwrap()
875    }
876}
877
878impl FillModel for LimitOrderPartialFillModel {
879    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
880        Ok(self.state.is_limit_filled())
881    }
882
883    fn is_slipped(&mut self) -> anyhow::Result<bool> {
884        Ok(self.state.is_slipped())
885    }
886
887    fn get_orderbook_for_fill_simulation(
888        &mut self,
889        instrument: &InstrumentAny,
890        _order: &OrderAny,
891        best_bid: Price,
892        best_ask: Price,
893    ) -> anyhow::Result<Option<OrderBook>> {
894        let tick = instrument.price_increment();
895        let size_prec = instrument.size_precision();
896        let mut book = build_l2_book(instrument.id());
897
898        add_order(
899            &mut book,
900            OrderSide::Buy,
901            best_bid,
902            Quantity::new(5.0, size_prec),
903            1,
904        );
905        add_order(
906            &mut book,
907            OrderSide::Sell,
908            best_ask,
909            Quantity::new(5.0, size_prec),
910            2,
911        );
912        add_order(
913            &mut book,
914            OrderSide::Buy,
915            best_bid - tick,
916            unlimited_liquidity(size_prec),
917            3,
918        );
919        add_order(
920            &mut book,
921            OrderSide::Sell,
922            best_ask + tick,
923            unlimited_liquidity(size_prec),
924            4,
925        );
926        Ok(Some(book))
927    }
928}
929
930/// Fill model that applies different execution based on order size.
931/// Small orders (<=10) get 50 contracts at best. Large orders get 10 at best, remainder at +1 tick.
932#[derive(Debug)]
933#[cfg_attr(
934    feature = "python",
935    pyo3::pyclass(
936        module = "nautilus_trader.core.nautilus_pyo3.execution",
937        unsendable,
938        from_py_object
939    )
940)]
941#[cfg_attr(
942    feature = "python",
943    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
944)]
945pub struct SizeAwareFillModel {
946    state: ProbabilisticFillState,
947}
948
949impl SizeAwareFillModel {
950    /// Creates a new [`SizeAwareFillModel`] instance.
951    ///
952    /// # Errors
953    ///
954    /// Returns an error if probability parameters are not in range [0, 1].
955    pub fn new(
956        prob_fill_on_limit: f64,
957        prob_slippage: f64,
958        random_seed: Option<u64>,
959    ) -> anyhow::Result<Self> {
960        Ok(Self {
961            state: ProbabilisticFillState::new(prob_fill_on_limit, prob_slippage, random_seed)?,
962        })
963    }
964}
965
966impl Clone for SizeAwareFillModel {
967    fn clone(&self) -> Self {
968        Self {
969            state: self.state.clone(),
970        }
971    }
972}
973
974impl Default for SizeAwareFillModel {
975    fn default() -> Self {
976        Self::new(1.0, 0.0, None).unwrap()
977    }
978}
979
980impl FillModel for SizeAwareFillModel {
981    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
982        Ok(self.state.is_limit_filled())
983    }
984
985    fn is_slipped(&mut self) -> anyhow::Result<bool> {
986        Ok(self.state.is_slipped())
987    }
988
989    fn get_orderbook_for_fill_simulation(
990        &mut self,
991        instrument: &InstrumentAny,
992        order: &OrderAny,
993        best_bid: Price,
994        best_ask: Price,
995    ) -> anyhow::Result<Option<OrderBook>> {
996        let tick = instrument.price_increment();
997        let size_prec = instrument.size_precision();
998        let mut book = build_l2_book(instrument.id());
999
1000        let threshold = Quantity::new(10.0, size_prec);
1001        if order.quantity() <= threshold {
1002            // Small orders: good liquidity at best
1003            add_order(
1004                &mut book,
1005                OrderSide::Buy,
1006                best_bid,
1007                Quantity::new(50.0, size_prec),
1008                1,
1009            );
1010            add_order(
1011                &mut book,
1012                OrderSide::Sell,
1013                best_ask,
1014                Quantity::new(50.0, size_prec),
1015                2,
1016            );
1017        } else {
1018            // Large orders: price impact
1019            let remaining = order.quantity() - threshold;
1020            add_order(&mut book, OrderSide::Buy, best_bid, threshold, 1);
1021            add_order(&mut book, OrderSide::Sell, best_ask, threshold, 2);
1022            add_order(&mut book, OrderSide::Buy, best_bid - tick, remaining, 3);
1023            add_order(&mut book, OrderSide::Sell, best_ask + tick, remaining, 4);
1024        }
1025        Ok(Some(book))
1026    }
1027}
1028
1029/// Fill model that reduces available liquidity by a factor to simulate market competition.
1030#[derive(Debug)]
1031#[cfg_attr(
1032    feature = "python",
1033    pyo3::pyclass(
1034        module = "nautilus_trader.core.nautilus_pyo3.execution",
1035        unsendable,
1036        from_py_object
1037    )
1038)]
1039#[cfg_attr(
1040    feature = "python",
1041    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
1042)]
1043pub struct CompetitionAwareFillModel {
1044    state: ProbabilisticFillState,
1045    liquidity_factor: Decimal,
1046}
1047
1048impl CompetitionAwareFillModel {
1049    /// Creates a new [`CompetitionAwareFillModel`] instance.
1050    ///
1051    /// # Errors
1052    ///
1053    /// Returns an error if probability parameters or `liquidity_factor` are not in range [0, 1].
1054    pub fn new(
1055        prob_fill_on_limit: f64,
1056        prob_slippage: f64,
1057        random_seed: Option<u64>,
1058        liquidity_factor: f64,
1059    ) -> anyhow::Result<Self> {
1060        let state = ProbabilisticFillState::new(prob_fill_on_limit, prob_slippage, random_seed)?;
1061        check_in_range_inclusive_f64(liquidity_factor, 0.0, 1.0, "liquidity_factor")?;
1062        let liquidity_factor = Decimal::try_from(liquidity_factor)?;
1063
1064        Ok(Self {
1065            state,
1066            liquidity_factor,
1067        })
1068    }
1069}
1070
1071impl Clone for CompetitionAwareFillModel {
1072    fn clone(&self) -> Self {
1073        Self {
1074            state: self.state.clone(),
1075            liquidity_factor: self.liquidity_factor,
1076        }
1077    }
1078}
1079
1080impl Default for CompetitionAwareFillModel {
1081    fn default() -> Self {
1082        Self::new(1.0, 0.0, None, 0.3).unwrap()
1083    }
1084}
1085
1086impl FillModel for CompetitionAwareFillModel {
1087    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
1088        Ok(self.state.is_limit_filled())
1089    }
1090
1091    fn is_slipped(&mut self) -> anyhow::Result<bool> {
1092        Ok(self.state.is_slipped())
1093    }
1094
1095    fn get_orderbook_for_fill_simulation(
1096        &mut self,
1097        instrument: &InstrumentAny,
1098        _order: &OrderAny,
1099        best_bid: Price,
1100        best_ask: Price,
1101    ) -> anyhow::Result<Option<OrderBook>> {
1102        let size_prec = instrument.size_precision();
1103        let mut book = build_l2_book(instrument.id());
1104
1105        // Minimum 1 to avoid zero-size orders
1106        let available = Quantity::from_decimal_dp(
1107            (dec!(1000) * self.liquidity_factor).max(Decimal::ONE),
1108            size_prec,
1109        )?;
1110
1111        add_order(&mut book, OrderSide::Buy, best_bid, available, 1);
1112        add_order(&mut book, OrderSide::Sell, best_ask, available, 2);
1113        Ok(Some(book))
1114    }
1115}
1116
1117/// Fill model that adjusts liquidity based on recent trading volume.
1118/// Uses 25% of recent volume at best price, unlimited one tick worse.
1119#[derive(Debug)]
1120#[cfg_attr(
1121    feature = "python",
1122    pyo3::pyclass(
1123        module = "nautilus_trader.core.nautilus_pyo3.execution",
1124        unsendable,
1125        from_py_object
1126    )
1127)]
1128#[cfg_attr(
1129    feature = "python",
1130    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
1131)]
1132pub struct VolumeSensitiveFillModel {
1133    state: ProbabilisticFillState,
1134    recent_volume: f64,
1135}
1136
1137impl VolumeSensitiveFillModel {
1138    /// Creates a new [`VolumeSensitiveFillModel`] instance.
1139    ///
1140    /// # Errors
1141    ///
1142    /// Returns an error if probability parameters are not in range [0, 1].
1143    pub fn new(
1144        prob_fill_on_limit: f64,
1145        prob_slippage: f64,
1146        random_seed: Option<u64>,
1147    ) -> anyhow::Result<Self> {
1148        Ok(Self {
1149            state: ProbabilisticFillState::new(prob_fill_on_limit, prob_slippage, random_seed)?,
1150            recent_volume: 1000.0,
1151        })
1152    }
1153
1154    pub fn set_recent_volume(&mut self, volume: f64) {
1155        self.recent_volume = volume;
1156    }
1157}
1158
1159impl Clone for VolumeSensitiveFillModel {
1160    fn clone(&self) -> Self {
1161        Self {
1162            state: self.state.clone(),
1163            recent_volume: self.recent_volume,
1164        }
1165    }
1166}
1167
1168impl Default for VolumeSensitiveFillModel {
1169    fn default() -> Self {
1170        Self::new(1.0, 0.0, None).unwrap()
1171    }
1172}
1173
1174impl FillModel for VolumeSensitiveFillModel {
1175    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
1176        Ok(self.state.is_limit_filled())
1177    }
1178
1179    fn is_slipped(&mut self) -> anyhow::Result<bool> {
1180        Ok(self.state.is_slipped())
1181    }
1182
1183    fn get_orderbook_for_fill_simulation(
1184        &mut self,
1185        instrument: &InstrumentAny,
1186        _order: &OrderAny,
1187        best_bid: Price,
1188        best_ask: Price,
1189    ) -> anyhow::Result<Option<OrderBook>> {
1190        let tick = instrument.price_increment();
1191        let size_prec = instrument.size_precision();
1192        let mut book = build_l2_book(instrument.id());
1193
1194        check_non_negative_f64(self.recent_volume, "recent_volume")?;
1195        let recent_volume = Decimal::try_from(self.recent_volume)?;
1196
1197        // Minimum 1 to avoid zero-size orders
1198        let available =
1199            Quantity::from_decimal_dp((recent_volume * dec!(0.25)).max(Decimal::ONE), size_prec)?;
1200
1201        add_order(&mut book, OrderSide::Buy, best_bid, available, 1);
1202        add_order(&mut book, OrderSide::Sell, best_ask, available, 2);
1203        add_order(
1204            &mut book,
1205            OrderSide::Buy,
1206            best_bid - tick,
1207            unlimited_liquidity(size_prec),
1208            3,
1209        );
1210        add_order(
1211            &mut book,
1212            OrderSide::Sell,
1213            best_ask + tick,
1214            unlimited_liquidity(size_prec),
1215            4,
1216        );
1217        Ok(Some(book))
1218    }
1219}
1220
1221/// Fill model that simulates varying conditions based on market hours.
1222/// During low liquidity: wider spreads (one tick worse). Normal hours: standard liquidity.
1223#[derive(Debug)]
1224#[cfg_attr(
1225    feature = "python",
1226    pyo3::pyclass(
1227        module = "nautilus_trader.core.nautilus_pyo3.execution",
1228        unsendable,
1229        from_py_object
1230    )
1231)]
1232#[cfg_attr(
1233    feature = "python",
1234    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
1235)]
1236pub struct MarketHoursFillModel {
1237    state: ProbabilisticFillState,
1238    is_low_liquidity: bool,
1239}
1240
1241impl MarketHoursFillModel {
1242    /// Creates a new [`MarketHoursFillModel`] instance.
1243    ///
1244    /// # Errors
1245    ///
1246    /// Returns an error if probability parameters are not in range [0, 1].
1247    pub fn new(
1248        prob_fill_on_limit: f64,
1249        prob_slippage: f64,
1250        random_seed: Option<u64>,
1251    ) -> anyhow::Result<Self> {
1252        Ok(Self {
1253            state: ProbabilisticFillState::new(prob_fill_on_limit, prob_slippage, random_seed)?,
1254            is_low_liquidity: false,
1255        })
1256    }
1257
1258    pub fn set_low_liquidity_period(&mut self, is_low_liquidity: bool) {
1259        self.is_low_liquidity = is_low_liquidity;
1260    }
1261
1262    pub fn is_low_liquidity_period(&self) -> bool {
1263        self.is_low_liquidity
1264    }
1265}
1266
1267impl Clone for MarketHoursFillModel {
1268    fn clone(&self) -> Self {
1269        Self {
1270            state: self.state.clone(),
1271            is_low_liquidity: self.is_low_liquidity,
1272        }
1273    }
1274}
1275
1276impl Default for MarketHoursFillModel {
1277    fn default() -> Self {
1278        Self::new(1.0, 0.0, None).unwrap()
1279    }
1280}
1281
1282impl FillModel for MarketHoursFillModel {
1283    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
1284        Ok(self.state.is_limit_filled())
1285    }
1286
1287    fn is_slipped(&mut self) -> anyhow::Result<bool> {
1288        Ok(self.state.is_slipped())
1289    }
1290
1291    fn get_orderbook_for_fill_simulation(
1292        &mut self,
1293        instrument: &InstrumentAny,
1294        _order: &OrderAny,
1295        best_bid: Price,
1296        best_ask: Price,
1297    ) -> anyhow::Result<Option<OrderBook>> {
1298        let tick = instrument.price_increment();
1299        let size_prec = instrument.size_precision();
1300        let mut book = build_l2_book(instrument.id());
1301        let normal_volume = 500.0;
1302
1303        if self.is_low_liquidity {
1304            add_order(
1305                &mut book,
1306                OrderSide::Buy,
1307                best_bid - tick,
1308                Quantity::new(normal_volume, size_prec),
1309                1,
1310            );
1311            add_order(
1312                &mut book,
1313                OrderSide::Sell,
1314                best_ask + tick,
1315                Quantity::new(normal_volume, size_prec),
1316                2,
1317            );
1318        } else {
1319            add_order(
1320                &mut book,
1321                OrderSide::Buy,
1322                best_bid,
1323                Quantity::new(normal_volume, size_prec),
1324                1,
1325            );
1326            add_order(
1327                &mut book,
1328                OrderSide::Sell,
1329                best_ask,
1330                Quantity::new(normal_volume, size_prec),
1331                2,
1332            );
1333        }
1334        Ok(Some(book))
1335    }
1336}
1337
1338#[derive(Clone, Debug)]
1339pub enum FillModelAny {
1340    Default(DefaultFillModel),
1341    BestPrice(BestPriceFillModel),
1342    OneTickSlippage(OneTickSlippageFillModel),
1343    Probabilistic(ProbabilisticFillModel),
1344    TwoTier(TwoTierFillModel),
1345    ThreeTier(ThreeTierFillModel),
1346    LimitOrderPartialFill(LimitOrderPartialFillModel),
1347    SizeAware(SizeAwareFillModel),
1348    CompetitionAware(CompetitionAwareFillModel),
1349    VolumeSensitive(VolumeSensitiveFillModel),
1350    MarketHours(MarketHoursFillModel),
1351}
1352
1353impl FillModel for FillModelAny {
1354    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
1355        match self {
1356            Self::Default(m) => m.is_limit_filled(),
1357            Self::BestPrice(m) => m.is_limit_filled(),
1358            Self::OneTickSlippage(m) => m.is_limit_filled(),
1359            Self::Probabilistic(m) => m.is_limit_filled(),
1360            Self::TwoTier(m) => m.is_limit_filled(),
1361            Self::ThreeTier(m) => m.is_limit_filled(),
1362            Self::LimitOrderPartialFill(m) => m.is_limit_filled(),
1363            Self::SizeAware(m) => m.is_limit_filled(),
1364            Self::CompetitionAware(m) => m.is_limit_filled(),
1365            Self::VolumeSensitive(m) => m.is_limit_filled(),
1366            Self::MarketHours(m) => m.is_limit_filled(),
1367        }
1368    }
1369
1370    fn fill_limit_inside_spread(&self) -> anyhow::Result<bool> {
1371        match self {
1372            Self::Default(m) => m.fill_limit_inside_spread(),
1373            Self::BestPrice(m) => m.fill_limit_inside_spread(),
1374            Self::OneTickSlippage(m) => m.fill_limit_inside_spread(),
1375            Self::Probabilistic(m) => m.fill_limit_inside_spread(),
1376            Self::TwoTier(m) => m.fill_limit_inside_spread(),
1377            Self::ThreeTier(m) => m.fill_limit_inside_spread(),
1378            Self::LimitOrderPartialFill(m) => m.fill_limit_inside_spread(),
1379            Self::SizeAware(m) => m.fill_limit_inside_spread(),
1380            Self::CompetitionAware(m) => m.fill_limit_inside_spread(),
1381            Self::VolumeSensitive(m) => m.fill_limit_inside_spread(),
1382            Self::MarketHours(m) => m.fill_limit_inside_spread(),
1383        }
1384    }
1385
1386    fn is_slipped(&mut self) -> anyhow::Result<bool> {
1387        match self {
1388            Self::Default(m) => m.is_slipped(),
1389            Self::BestPrice(m) => m.is_slipped(),
1390            Self::OneTickSlippage(m) => m.is_slipped(),
1391            Self::Probabilistic(m) => m.is_slipped(),
1392            Self::TwoTier(m) => m.is_slipped(),
1393            Self::ThreeTier(m) => m.is_slipped(),
1394            Self::LimitOrderPartialFill(m) => m.is_slipped(),
1395            Self::SizeAware(m) => m.is_slipped(),
1396            Self::CompetitionAware(m) => m.is_slipped(),
1397            Self::VolumeSensitive(m) => m.is_slipped(),
1398            Self::MarketHours(m) => m.is_slipped(),
1399        }
1400    }
1401
1402    fn get_orderbook_for_fill_simulation(
1403        &mut self,
1404        instrument: &InstrumentAny,
1405        order: &OrderAny,
1406        best_bid: Price,
1407        best_ask: Price,
1408    ) -> anyhow::Result<Option<OrderBook>> {
1409        match self {
1410            Self::Default(m) => {
1411                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1412            }
1413            Self::BestPrice(m) => {
1414                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1415            }
1416            Self::OneTickSlippage(m) => {
1417                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1418            }
1419            Self::Probabilistic(m) => {
1420                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1421            }
1422            Self::TwoTier(m) => {
1423                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1424            }
1425            Self::ThreeTier(m) => {
1426                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1427            }
1428            Self::LimitOrderPartialFill(m) => {
1429                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1430            }
1431            Self::SizeAware(m) => {
1432                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1433            }
1434            Self::CompetitionAware(m) => {
1435                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1436            }
1437            Self::VolumeSensitive(m) => {
1438                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1439            }
1440            Self::MarketHours(m) => {
1441                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1442            }
1443        }
1444    }
1445}
1446
1447impl Default for FillModelAny {
1448    fn default() -> Self {
1449        Self::Default(DefaultFillModel::default())
1450    }
1451}
1452
1453impl Display for FillModelAny {
1454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1455        match self {
1456            Self::Default(m) => write!(f, "{m}"),
1457            Self::BestPrice(_) => write!(f, "BestPriceFillModel"),
1458            Self::OneTickSlippage(_) => write!(f, "OneTickSlippageFillModel"),
1459            Self::Probabilistic(_) => write!(f, "ProbabilisticFillModel"),
1460            Self::TwoTier(_) => write!(f, "TwoTierFillModel"),
1461            Self::ThreeTier(_) => write!(f, "ThreeTierFillModel"),
1462            Self::LimitOrderPartialFill(_) => write!(f, "LimitOrderPartialFillModel"),
1463            Self::SizeAware(_) => write!(f, "SizeAwareFillModel"),
1464            Self::CompetitionAware(_) => write!(f, "CompetitionAwareFillModel"),
1465            Self::VolumeSensitive(_) => write!(f, "VolumeSensitiveFillModel"),
1466            Self::MarketHours(_) => write!(f, "MarketHoursFillModel"),
1467        }
1468    }
1469}
1470
1471#[cfg(test)]
1472mod tests {
1473    use nautilus_core::correctness::CorrectnessError;
1474    use nautilus_model::{
1475        enums::OrderType,
1476        instruments::stubs::{audusd_sim, crypto_perpetual_ethusdt},
1477        orders::builder::OrderTestBuilder,
1478    };
1479    use rstest::{fixture, rstest};
1480
1481    use super::*;
1482
1483    #[fixture]
1484    fn fill_model() -> DefaultFillModel {
1485        let seed = 42;
1486        DefaultFillModel::new(0.5, 0.1, Some(seed)).unwrap()
1487    }
1488
1489    #[rstest]
1490    fn test_fill_model_param_prob_fill_on_limit_error() {
1491        let error = DefaultFillModel::new(1.1, 0.1, None).unwrap_err();
1492
1493        assert_eq!(
1494            error.downcast_ref::<CorrectnessError>(),
1495            Some(&CorrectnessError::OutOfRange {
1496                param: "prob_fill_on_limit".to_string(),
1497                min: "0".to_string(),
1498                max: "1".to_string(),
1499                value: "1.1".to_string(),
1500                type_name: "f64",
1501            })
1502        );
1503        assert_eq!(
1504            error.to_string(),
1505            "invalid f64 for 'prob_fill_on_limit' not in range [0, 1], was 1.1"
1506        );
1507    }
1508
1509    #[rstest]
1510    fn test_fill_model_param_prob_slippage_error() {
1511        let error = DefaultFillModel::new(0.5, 1.1, None).unwrap_err();
1512
1513        assert_eq!(
1514            error.downcast_ref::<CorrectnessError>(),
1515            Some(&CorrectnessError::OutOfRange {
1516                param: "prob_slippage".to_string(),
1517                min: "0".to_string(),
1518                max: "1".to_string(),
1519                value: "1.1".to_string(),
1520                type_name: "f64",
1521            })
1522        );
1523        assert_eq!(
1524            error.to_string(),
1525            "invalid f64 for 'prob_slippage' not in range [0, 1], was 1.1"
1526        );
1527    }
1528
1529    #[rstest]
1530    #[case(f64::NAN, "NaN")]
1531    #[case(f64::INFINITY, "inf")]
1532    #[case(f64::NEG_INFINITY, "-inf")]
1533    fn test_competition_aware_fill_model_rejects_non_finite_liquidity_factor(
1534        #[case] value: f64,
1535        #[case] expected_value: &str,
1536    ) {
1537        let error = CompetitionAwareFillModel::new(1.0, 0.0, None, value).unwrap_err();
1538
1539        assert_eq!(
1540            error.downcast_ref::<CorrectnessError>(),
1541            Some(&CorrectnessError::InvalidValue {
1542                param: "liquidity_factor".to_string(),
1543                value: expected_value.to_string(),
1544                type_name: "f64",
1545            })
1546        );
1547    }
1548
1549    #[rstest]
1550    #[case(-0.1, "-0.1")]
1551    #[case(1.1, "1.1")]
1552    fn test_competition_aware_fill_model_rejects_out_of_range_liquidity_factor(
1553        #[case] value: f64,
1554        #[case] expected_value: &str,
1555    ) {
1556        let error = CompetitionAwareFillModel::new(1.0, 0.0, None, value).unwrap_err();
1557
1558        assert_eq!(
1559            error.downcast_ref::<CorrectnessError>(),
1560            Some(&CorrectnessError::OutOfRange {
1561                param: "liquidity_factor".to_string(),
1562                min: "0".to_string(),
1563                max: "1".to_string(),
1564                value: expected_value.to_string(),
1565                type_name: "f64",
1566            })
1567        );
1568    }
1569
1570    #[rstest]
1571    #[case(f64::NAN, "NaN")]
1572    #[case(f64::INFINITY, "inf")]
1573    #[case(f64::NEG_INFINITY, "-inf")]
1574    fn test_volume_sensitive_fill_model_rejects_non_finite_volume(
1575        #[case] volume: f64,
1576        #[case] expected_value: &str,
1577    ) {
1578        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1579        let order = OrderTestBuilder::new(OrderType::Market)
1580            .instrument_id(instrument.id())
1581            .side(OrderSide::Buy)
1582            .quantity(Quantity::from(100_000))
1583            .build();
1584        let mut model = VolumeSensitiveFillModel::default();
1585        model.set_recent_volume(volume);
1586
1587        let error = model
1588            .get_orderbook_for_fill_simulation(
1589                &instrument,
1590                &order,
1591                Price::from("0.80000"),
1592                Price::from("0.80010"),
1593            )
1594            .unwrap_err();
1595
1596        assert_eq!(
1597            error.downcast_ref::<CorrectnessError>(),
1598            Some(&CorrectnessError::InvalidValue {
1599                param: "recent_volume".to_string(),
1600                value: expected_value.to_string(),
1601                type_name: "f64",
1602            })
1603        );
1604    }
1605
1606    #[rstest]
1607    fn test_volume_sensitive_fill_model_rejects_negative_volume() {
1608        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1609        let order = OrderTestBuilder::new(OrderType::Market)
1610            .instrument_id(instrument.id())
1611            .side(OrderSide::Buy)
1612            .quantity(Quantity::from(100_000))
1613            .build();
1614        let mut model = VolumeSensitiveFillModel::default();
1615        model.set_recent_volume(-1.0);
1616
1617        let error = model
1618            .get_orderbook_for_fill_simulation(
1619                &instrument,
1620                &order,
1621                Price::from("0.80000"),
1622                Price::from("0.80010"),
1623            )
1624            .unwrap_err();
1625
1626        assert_eq!(
1627            error.downcast_ref::<CorrectnessError>(),
1628            Some(&CorrectnessError::NegativeValue {
1629                param: "recent_volume".to_string(),
1630                value: "-1".to_string(),
1631                type_name: "f64",
1632            })
1633        );
1634    }
1635
1636    #[rstest]
1637    fn test_volume_sensitive_fill_model_rejects_volume_above_quantity_range() {
1638        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1639        let order = OrderTestBuilder::new(OrderType::Market)
1640            .instrument_id(instrument.id())
1641            .side(OrderSide::Buy)
1642            .quantity(Quantity::from(100_000))
1643            .build();
1644        let mut model = VolumeSensitiveFillModel::default();
1645        model.set_recent_volume(100_000_000_000_000_000.0);
1646
1647        let error = model
1648            .get_orderbook_for_fill_simulation(
1649                &instrument,
1650                &order,
1651                Price::from("0.80000"),
1652                Price::from("0.80010"),
1653            )
1654            .unwrap_err();
1655
1656        assert!(matches!(
1657            error.downcast_ref::<CorrectnessError>(),
1658            Some(CorrectnessError::PredicateViolation { message })
1659                if message.contains("QuantityRaw") || message.contains("QUANTITY_RAW_MAX")
1660        ));
1661    }
1662
1663    #[rstest]
1664    #[case(0.0, Quantity::from(1))]
1665    #[case(0.5, Quantity::from(500))]
1666    #[case(1.0, Quantity::from(1_000))]
1667    fn test_competition_aware_fill_model_builds_expected_liquidity(
1668        #[case] liquidity_factor: f64,
1669        #[case] expected_size: Quantity,
1670    ) {
1671        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1672        let order = OrderTestBuilder::new(OrderType::Market)
1673            .instrument_id(instrument.id())
1674            .side(OrderSide::Buy)
1675            .quantity(Quantity::from(100_000))
1676            .build();
1677        let best_bid = Price::from("0.80000");
1678        let best_ask = Price::from("0.80010");
1679        let mut model = CompetitionAwareFillModel::new(1.0, 0.0, None, liquidity_factor).unwrap();
1680
1681        let book = model
1682            .get_orderbook_for_fill_simulation(&instrument, &order, best_bid, best_ask)
1683            .unwrap()
1684            .unwrap();
1685
1686        assert_eq!(book.best_bid_price(), Some(best_bid));
1687        assert_eq!(book.best_ask_price(), Some(best_ask));
1688        assert_eq!(book.best_bid_size(), Some(expected_size));
1689        assert_eq!(book.best_ask_size(), Some(expected_size));
1690    }
1691
1692    #[rstest]
1693    fn test_competition_aware_fill_model_preserves_instrument_size_precision() {
1694        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
1695        let order = OrderTestBuilder::new(OrderType::Market)
1696            .instrument_id(instrument.id())
1697            .side(OrderSide::Buy)
1698            .quantity(Quantity::from(100_000))
1699            .build();
1700        let best_bid = Price::from("2000.00");
1701        let best_ask = Price::from("2000.01");
1702        let mut model = CompetitionAwareFillModel::new(1.0, 0.0, None, 0.001234).unwrap();
1703
1704        let book = model
1705            .get_orderbook_for_fill_simulation(&instrument, &order, best_bid, best_ask)
1706            .unwrap()
1707            .unwrap();
1708
1709        assert_eq!(book.best_bid_price(), Some(best_bid));
1710        assert_eq!(book.best_ask_price(), Some(best_ask));
1711        assert_eq!(book.best_bid_size(), Some(Quantity::from("1.234")));
1712        assert_eq!(book.best_ask_size(), Some(Quantity::from("1.234")));
1713    }
1714
1715    #[rstest]
1716    fn test_volume_sensitive_fill_model_builds_expected_liquidity() {
1717        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
1718        let order = OrderTestBuilder::new(OrderType::Market)
1719            .instrument_id(instrument.id())
1720            .side(OrderSide::Buy)
1721            .quantity(Quantity::from(100_000))
1722            .build();
1723        let best_bid = Price::from("2000.00");
1724        let best_ask = Price::from("2000.01");
1725        let mut model = VolumeSensitiveFillModel::default();
1726        model.set_recent_volume(5.678);
1727
1728        let book = model
1729            .get_orderbook_for_fill_simulation(&instrument, &order, best_bid, best_ask)
1730            .unwrap()
1731            .unwrap();
1732
1733        assert_eq!(book.best_bid_price(), Some(best_bid));
1734        assert_eq!(book.best_ask_price(), Some(best_ask));
1735        assert_eq!(book.best_bid_size(), Some(Quantity::from("1.420")));
1736        assert_eq!(book.best_ask_size(), Some(Quantity::from("1.420")));
1737    }
1738
1739    #[rstest]
1740    fn test_fill_model_is_limit_filled(mut fill_model: DefaultFillModel) {
1741        // Fixed seed makes this deterministic
1742        let result = fill_model.is_limit_filled().unwrap();
1743        assert!(!result);
1744    }
1745
1746    #[rstest]
1747    fn test_fill_model_is_slipped(mut fill_model: DefaultFillModel) {
1748        // Fixed seed makes this deterministic
1749        let result = fill_model.is_slipped().unwrap();
1750        assert!(!result);
1751    }
1752
1753    #[rstest]
1754    fn test_default_fill_model_returns_none() {
1755        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1756        let order = OrderTestBuilder::new(OrderType::Market)
1757            .instrument_id(instrument.id())
1758            .side(OrderSide::Buy)
1759            .quantity(Quantity::from(100_000))
1760            .build();
1761
1762        let mut model = DefaultFillModel::default();
1763        let result = model
1764            .get_orderbook_for_fill_simulation(
1765                &instrument,
1766                &order,
1767                Price::from("0.80000"),
1768                Price::from("0.80010"),
1769            )
1770            .unwrap();
1771        assert!(result.is_none());
1772    }
1773
1774    #[rstest]
1775    fn test_best_price_fill_model_returns_book() {
1776        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1777        let order = OrderTestBuilder::new(OrderType::Market)
1778            .instrument_id(instrument.id())
1779            .side(OrderSide::Buy)
1780            .quantity(Quantity::from(100_000))
1781            .build();
1782
1783        let mut model = BestPriceFillModel::default();
1784        let result = model
1785            .get_orderbook_for_fill_simulation(
1786                &instrument,
1787                &order,
1788                Price::from("0.80000"),
1789                Price::from("0.80010"),
1790            )
1791            .unwrap();
1792        assert!(result.is_some());
1793        let book = result.unwrap();
1794        assert_eq!(book.best_bid_price().unwrap(), Price::from("0.80000"));
1795        assert_eq!(book.best_ask_price().unwrap(), Price::from("0.80010"));
1796    }
1797
1798    #[rstest]
1799    fn test_one_tick_slippage_fill_model() {
1800        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1801        let order = OrderTestBuilder::new(OrderType::Market)
1802            .instrument_id(instrument.id())
1803            .side(OrderSide::Buy)
1804            .quantity(Quantity::from(100_000))
1805            .build();
1806
1807        let tick = instrument.price_increment();
1808        let best_bid = Price::from("0.80000");
1809        let best_ask = Price::from("0.80010");
1810
1811        let mut model = OneTickSlippageFillModel::default();
1812        let result = model
1813            .get_orderbook_for_fill_simulation(&instrument, &order, best_bid, best_ask)
1814            .unwrap();
1815        assert!(result.is_some());
1816        let book = result.unwrap();
1817
1818        assert_eq!(book.best_bid_price().unwrap(), best_bid - tick);
1819        assert_eq!(book.best_ask_price().unwrap(), best_ask + tick);
1820    }
1821
1822    #[rstest]
1823    fn test_fill_model_any_dispatch() {
1824        let model = FillModelAny::default();
1825        assert!(matches!(model, FillModelAny::Default(_)));
1826    }
1827
1828    #[rstest]
1829    fn test_fill_model_any_is_limit_filled() {
1830        let mut model = FillModelAny::Default(DefaultFillModel::new(0.5, 0.1, Some(42)).unwrap());
1831        let result = model.is_limit_filled().unwrap();
1832        assert!(!result);
1833    }
1834
1835    #[rstest]
1836    fn test_fill_model_handle_from_any_owns_state_per_conversion() {
1837        let model = FillModelAny::Default(DefaultFillModel::new(0.5, 0.0, Some(42)).unwrap());
1838        let mut expected_model = model.clone();
1839        let mut first: FillModelHandle = model.clone().into();
1840        let mut second: FillModelHandle = model.into();
1841
1842        let expected: Vec<_> = (0..16)
1843            .map(|_| expected_model.is_limit_filled().unwrap())
1844            .collect();
1845        let first_results: Vec<_> = (0..16).map(|_| first.is_limit_filled().unwrap()).collect();
1846        let second_results: Vec<_> = (0..16).map(|_| second.is_limit_filled().unwrap()).collect();
1847        let has_variation = expected.windows(2).any(|window| window[0] != window[1]);
1848
1849        assert!(has_variation);
1850        assert_eq!(first_results, expected);
1851        assert_eq!(second_results, expected);
1852    }
1853
1854    #[rstest]
1855    fn test_default_fill_model_fill_limit_inside_spread_is_false() {
1856        let model = DefaultFillModel::default();
1857        assert!(!model.fill_limit_inside_spread().unwrap());
1858    }
1859
1860    #[rstest]
1861    fn test_best_price_fill_model_fill_limit_inside_spread_is_true() {
1862        let model = BestPriceFillModel::default();
1863        assert!(model.fill_limit_inside_spread().unwrap());
1864    }
1865
1866    #[rstest]
1867    fn test_one_tick_slippage_fill_model_fill_limit_inside_spread_is_false() {
1868        let model = OneTickSlippageFillModel::default();
1869        assert!(!model.fill_limit_inside_spread().unwrap());
1870    }
1871
1872    #[rstest]
1873    fn test_fill_model_any_fill_limit_inside_spread_dispatch() {
1874        let default = FillModelAny::Default(DefaultFillModel::default());
1875        assert!(!default.fill_limit_inside_spread().unwrap());
1876
1877        let best_price = FillModelAny::BestPrice(BestPriceFillModel::default());
1878        assert!(best_price.fill_limit_inside_spread().unwrap());
1879
1880        let one_tick = FillModelAny::OneTickSlippage(OneTickSlippageFillModel::default());
1881        assert!(!one_tick.fill_limit_inside_spread().unwrap());
1882    }
1883}