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