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    fn get_orderbook_for_fill_simulation(
1359        &mut self,
1360        instrument: &InstrumentAny,
1361        order: &OrderAny,
1362        best_bid: Price,
1363        best_ask: Price,
1364    ) -> anyhow::Result<Option<OrderBook>> {
1365        match self {
1366            Self::Default(m) => {
1367                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1368            }
1369            Self::BestPrice(m) => {
1370                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1371            }
1372            Self::OneTickSlippage(m) => {
1373                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1374            }
1375            Self::Probabilistic(m) => {
1376                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1377            }
1378            Self::TwoTier(m) => {
1379                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1380            }
1381            Self::ThreeTier(m) => {
1382                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1383            }
1384            Self::LimitOrderPartialFill(m) => {
1385                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1386            }
1387            Self::SizeAware(m) => {
1388                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1389            }
1390            Self::CompetitionAware(m) => {
1391                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1392            }
1393            Self::VolumeSensitive(m) => {
1394                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1395            }
1396            Self::MarketHours(m) => {
1397                m.get_orderbook_for_fill_simulation(instrument, order, best_bid, best_ask)
1398            }
1399        }
1400    }
1401}
1402
1403impl Default for FillModelAny {
1404    fn default() -> Self {
1405        Self::Default(DefaultFillModel::default())
1406    }
1407}
1408
1409impl Display for FillModelAny {
1410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1411        match self {
1412            Self::Default(m) => write!(f, "{m}"),
1413            Self::BestPrice(_) => write!(f, "BestPriceFillModel"),
1414            Self::OneTickSlippage(_) => write!(f, "OneTickSlippageFillModel"),
1415            Self::Probabilistic(_) => write!(f, "ProbabilisticFillModel"),
1416            Self::TwoTier(_) => write!(f, "TwoTierFillModel"),
1417            Self::ThreeTier(_) => write!(f, "ThreeTierFillModel"),
1418            Self::LimitOrderPartialFill(_) => write!(f, "LimitOrderPartialFillModel"),
1419            Self::SizeAware(_) => write!(f, "SizeAwareFillModel"),
1420            Self::CompetitionAware(_) => write!(f, "CompetitionAwareFillModel"),
1421            Self::VolumeSensitive(_) => write!(f, "VolumeSensitiveFillModel"),
1422            Self::MarketHours(_) => write!(f, "MarketHoursFillModel"),
1423        }
1424    }
1425}
1426
1427#[cfg(test)]
1428mod tests {
1429    use nautilus_core::correctness::CorrectnessError;
1430    use nautilus_model::{
1431        enums::OrderType,
1432        instruments::stubs::{audusd_sim, crypto_perpetual_ethusdt},
1433        orders::builder::OrderTestBuilder,
1434    };
1435    use rstest::{fixture, rstest};
1436
1437    use super::*;
1438
1439    #[fixture]
1440    fn fill_model() -> DefaultFillModel {
1441        let seed = 42;
1442        DefaultFillModel::new(0.5, 0.1, Some(seed)).unwrap()
1443    }
1444
1445    #[rstest]
1446    fn test_fill_model_param_prob_fill_on_limit_error() {
1447        let error = DefaultFillModel::new(1.1, 0.1, None).unwrap_err();
1448
1449        assert_eq!(
1450            error.downcast_ref::<CorrectnessError>(),
1451            Some(&CorrectnessError::OutOfRange {
1452                param: "prob_fill_on_limit".to_string(),
1453                min: "0".to_string(),
1454                max: "1".to_string(),
1455                value: "1.1".to_string(),
1456                type_name: "f64",
1457            })
1458        );
1459        assert_eq!(
1460            error.to_string(),
1461            "invalid f64 for 'prob_fill_on_limit' not in range [0, 1], was 1.1"
1462        );
1463    }
1464
1465    #[rstest]
1466    fn test_fill_model_param_prob_slippage_error() {
1467        let error = DefaultFillModel::new(0.5, 1.1, None).unwrap_err();
1468
1469        assert_eq!(
1470            error.downcast_ref::<CorrectnessError>(),
1471            Some(&CorrectnessError::OutOfRange {
1472                param: "prob_slippage".to_string(),
1473                min: "0".to_string(),
1474                max: "1".to_string(),
1475                value: "1.1".to_string(),
1476                type_name: "f64",
1477            })
1478        );
1479        assert_eq!(
1480            error.to_string(),
1481            "invalid f64 for 'prob_slippage' not in range [0, 1], was 1.1"
1482        );
1483    }
1484
1485    #[rstest]
1486    #[case(f64::NAN, "NaN")]
1487    #[case(f64::INFINITY, "inf")]
1488    #[case(f64::NEG_INFINITY, "-inf")]
1489    fn test_competition_aware_fill_model_rejects_non_finite_liquidity_factor(
1490        #[case] value: f64,
1491        #[case] expected_value: &str,
1492    ) {
1493        let error = CompetitionAwareFillModel::new(1.0, 0.0, None, value).unwrap_err();
1494
1495        assert_eq!(
1496            error.downcast_ref::<CorrectnessError>(),
1497            Some(&CorrectnessError::InvalidValue {
1498                param: "liquidity_factor".to_string(),
1499                value: expected_value.to_string(),
1500                type_name: "f64",
1501            })
1502        );
1503    }
1504
1505    #[rstest]
1506    #[case(-0.1, "-0.1")]
1507    #[case(1.1, "1.1")]
1508    fn test_competition_aware_fill_model_rejects_out_of_range_liquidity_factor(
1509        #[case] value: f64,
1510        #[case] expected_value: &str,
1511    ) {
1512        let error = CompetitionAwareFillModel::new(1.0, 0.0, None, value).unwrap_err();
1513
1514        assert_eq!(
1515            error.downcast_ref::<CorrectnessError>(),
1516            Some(&CorrectnessError::OutOfRange {
1517                param: "liquidity_factor".to_string(),
1518                min: "0".to_string(),
1519                max: "1".to_string(),
1520                value: expected_value.to_string(),
1521                type_name: "f64",
1522            })
1523        );
1524    }
1525
1526    #[rstest]
1527    #[case(f64::NAN, "NaN")]
1528    #[case(f64::INFINITY, "inf")]
1529    #[case(f64::NEG_INFINITY, "-inf")]
1530    fn test_volume_sensitive_fill_model_rejects_non_finite_volume(
1531        #[case] volume: f64,
1532        #[case] expected_value: &str,
1533    ) {
1534        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1535        let order = OrderTestBuilder::new(OrderType::Market)
1536            .instrument_id(instrument.id())
1537            .side(OrderSide::Buy)
1538            .quantity(Quantity::from(100_000))
1539            .build();
1540        let mut model = VolumeSensitiveFillModel::default();
1541        model.set_recent_volume(volume);
1542
1543        let error = model
1544            .get_orderbook_for_fill_simulation(
1545                &instrument,
1546                &order,
1547                Price::from("0.80000"),
1548                Price::from("0.80010"),
1549            )
1550            .unwrap_err();
1551
1552        assert_eq!(
1553            error.downcast_ref::<CorrectnessError>(),
1554            Some(&CorrectnessError::InvalidValue {
1555                param: "recent_volume".to_string(),
1556                value: expected_value.to_string(),
1557                type_name: "f64",
1558            })
1559        );
1560    }
1561
1562    #[rstest]
1563    fn test_volume_sensitive_fill_model_rejects_negative_volume() {
1564        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1565        let order = OrderTestBuilder::new(OrderType::Market)
1566            .instrument_id(instrument.id())
1567            .side(OrderSide::Buy)
1568            .quantity(Quantity::from(100_000))
1569            .build();
1570        let mut model = VolumeSensitiveFillModel::default();
1571        model.set_recent_volume(-1.0);
1572
1573        let error = model
1574            .get_orderbook_for_fill_simulation(
1575                &instrument,
1576                &order,
1577                Price::from("0.80000"),
1578                Price::from("0.80010"),
1579            )
1580            .unwrap_err();
1581
1582        assert_eq!(
1583            error.downcast_ref::<CorrectnessError>(),
1584            Some(&CorrectnessError::NegativeValue {
1585                param: "recent_volume".to_string(),
1586                value: "-1".to_string(),
1587                type_name: "f64",
1588            })
1589        );
1590    }
1591
1592    #[rstest]
1593    fn test_volume_sensitive_fill_model_rejects_volume_above_quantity_range() {
1594        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1595        let order = OrderTestBuilder::new(OrderType::Market)
1596            .instrument_id(instrument.id())
1597            .side(OrderSide::Buy)
1598            .quantity(Quantity::from(100_000))
1599            .build();
1600        let mut model = VolumeSensitiveFillModel::default();
1601        model.set_recent_volume(100_000_000_000_000_000.0);
1602
1603        let error = model
1604            .get_orderbook_for_fill_simulation(
1605                &instrument,
1606                &order,
1607                Price::from("0.80000"),
1608                Price::from("0.80010"),
1609            )
1610            .unwrap_err();
1611
1612        assert!(matches!(
1613            error.downcast_ref::<CorrectnessError>(),
1614            Some(CorrectnessError::PredicateViolation { message })
1615                if message.contains("QuantityRaw") || message.contains("QUANTITY_RAW_MAX")
1616        ));
1617    }
1618
1619    #[rstest]
1620    #[case(0.0, Quantity::from(1))]
1621    #[case(0.5, Quantity::from(500))]
1622    #[case(1.0, Quantity::from(1_000))]
1623    fn test_competition_aware_fill_model_builds_expected_liquidity(
1624        #[case] liquidity_factor: f64,
1625        #[case] expected_size: Quantity,
1626    ) {
1627        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1628        let order = OrderTestBuilder::new(OrderType::Market)
1629            .instrument_id(instrument.id())
1630            .side(OrderSide::Buy)
1631            .quantity(Quantity::from(100_000))
1632            .build();
1633        let best_bid = Price::from("0.80000");
1634        let best_ask = Price::from("0.80010");
1635        let mut model = CompetitionAwareFillModel::new(1.0, 0.0, None, liquidity_factor).unwrap();
1636
1637        let book = model
1638            .get_orderbook_for_fill_simulation(&instrument, &order, best_bid, best_ask)
1639            .unwrap()
1640            .unwrap();
1641
1642        assert_eq!(book.best_bid_price(), Some(best_bid));
1643        assert_eq!(book.best_ask_price(), Some(best_ask));
1644        assert_eq!(book.best_bid_size(), Some(expected_size));
1645        assert_eq!(book.best_ask_size(), Some(expected_size));
1646    }
1647
1648    #[rstest]
1649    fn test_competition_aware_fill_model_preserves_instrument_size_precision() {
1650        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
1651        let order = OrderTestBuilder::new(OrderType::Market)
1652            .instrument_id(instrument.id())
1653            .side(OrderSide::Buy)
1654            .quantity(Quantity::from(100_000))
1655            .build();
1656        let best_bid = Price::from("2000.00");
1657        let best_ask = Price::from("2000.01");
1658        let mut model = CompetitionAwareFillModel::new(1.0, 0.0, None, 0.001234).unwrap();
1659
1660        let book = model
1661            .get_orderbook_for_fill_simulation(&instrument, &order, best_bid, best_ask)
1662            .unwrap()
1663            .unwrap();
1664
1665        assert_eq!(book.best_bid_price(), Some(best_bid));
1666        assert_eq!(book.best_ask_price(), Some(best_ask));
1667        assert_eq!(book.best_bid_size(), Some(Quantity::from("1.234")));
1668        assert_eq!(book.best_ask_size(), Some(Quantity::from("1.234")));
1669    }
1670
1671    #[rstest]
1672    fn test_volume_sensitive_fill_model_builds_expected_liquidity() {
1673        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
1674        let order = OrderTestBuilder::new(OrderType::Market)
1675            .instrument_id(instrument.id())
1676            .side(OrderSide::Buy)
1677            .quantity(Quantity::from(100_000))
1678            .build();
1679        let best_bid = Price::from("2000.00");
1680        let best_ask = Price::from("2000.01");
1681        let mut model = VolumeSensitiveFillModel::default();
1682        model.set_recent_volume(5.678);
1683
1684        let book = model
1685            .get_orderbook_for_fill_simulation(&instrument, &order, best_bid, best_ask)
1686            .unwrap()
1687            .unwrap();
1688
1689        assert_eq!(book.best_bid_price(), Some(best_bid));
1690        assert_eq!(book.best_ask_price(), Some(best_ask));
1691        assert_eq!(book.best_bid_size(), Some(Quantity::from("1.420")));
1692        assert_eq!(book.best_ask_size(), Some(Quantity::from("1.420")));
1693    }
1694
1695    #[rstest]
1696    fn test_fill_model_is_limit_filled(mut fill_model: DefaultFillModel) {
1697        // Fixed seed makes this deterministic
1698        let result = fill_model.is_limit_filled().unwrap();
1699        assert!(!result);
1700    }
1701
1702    #[rstest]
1703    fn test_fill_model_is_slipped(mut fill_model: DefaultFillModel) {
1704        // Fixed seed makes this deterministic
1705        let result = fill_model.is_slipped().unwrap();
1706        assert!(!result);
1707    }
1708
1709    #[rstest]
1710    fn test_default_fill_model_returns_none() {
1711        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1712        let order = OrderTestBuilder::new(OrderType::Market)
1713            .instrument_id(instrument.id())
1714            .side(OrderSide::Buy)
1715            .quantity(Quantity::from(100_000))
1716            .build();
1717
1718        let mut model = DefaultFillModel::default();
1719        let result = model
1720            .get_orderbook_for_fill_simulation(
1721                &instrument,
1722                &order,
1723                Price::from("0.80000"),
1724                Price::from("0.80010"),
1725            )
1726            .unwrap();
1727        assert!(result.is_none());
1728    }
1729
1730    #[rstest]
1731    fn test_best_price_fill_model_returns_book() {
1732        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1733        let order = OrderTestBuilder::new(OrderType::Market)
1734            .instrument_id(instrument.id())
1735            .side(OrderSide::Buy)
1736            .quantity(Quantity::from(100_000))
1737            .build();
1738
1739        let mut model = BestPriceFillModel::default();
1740        let result = model
1741            .get_orderbook_for_fill_simulation(
1742                &instrument,
1743                &order,
1744                Price::from("0.80000"),
1745                Price::from("0.80010"),
1746            )
1747            .unwrap();
1748        assert!(result.is_some());
1749        let book = result.unwrap();
1750        assert_eq!(book.best_bid_price().unwrap(), Price::from("0.80000"));
1751        assert_eq!(book.best_ask_price().unwrap(), Price::from("0.80010"));
1752    }
1753
1754    #[rstest]
1755    fn test_one_tick_slippage_fill_model() {
1756        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1757        let order = OrderTestBuilder::new(OrderType::Market)
1758            .instrument_id(instrument.id())
1759            .side(OrderSide::Buy)
1760            .quantity(Quantity::from(100_000))
1761            .build();
1762
1763        let tick = instrument.price_increment();
1764        let best_bid = Price::from("0.80000");
1765        let best_ask = Price::from("0.80010");
1766
1767        let mut model = OneTickSlippageFillModel::default();
1768        let result = model
1769            .get_orderbook_for_fill_simulation(&instrument, &order, best_bid, best_ask)
1770            .unwrap();
1771        assert!(result.is_some());
1772        let book = result.unwrap();
1773
1774        assert_eq!(book.best_bid_price().unwrap(), best_bid - tick);
1775        assert_eq!(book.best_ask_price().unwrap(), best_ask + tick);
1776    }
1777
1778    #[rstest]
1779    fn test_fill_model_any_dispatch() {
1780        let model = FillModelAny::default();
1781        assert!(matches!(model, FillModelAny::Default(_)));
1782    }
1783
1784    #[rstest]
1785    fn test_fill_model_any_is_limit_filled() {
1786        let mut model = FillModelAny::Default(DefaultFillModel::new(0.5, 0.1, Some(42)).unwrap());
1787        let result = model.is_limit_filled().unwrap();
1788        assert!(!result);
1789    }
1790
1791    #[rstest]
1792    fn test_fill_model_handle_from_any_owns_state_per_conversion() {
1793        let model = FillModelAny::Default(DefaultFillModel::new(0.5, 0.0, Some(42)).unwrap());
1794        let mut expected_model = model.clone();
1795        let mut first: FillModelHandle = model.clone().into();
1796        let mut second: FillModelHandle = model.into();
1797
1798        let expected: Vec<_> = (0..16)
1799            .map(|_| expected_model.is_limit_filled().unwrap())
1800            .collect();
1801        let first_results: Vec<_> = (0..16).map(|_| first.is_limit_filled().unwrap()).collect();
1802        let second_results: Vec<_> = (0..16).map(|_| second.is_limit_filled().unwrap()).collect();
1803        let has_variation = expected.windows(2).any(|window| window[0] != window[1]);
1804
1805        assert!(has_variation);
1806        assert_eq!(first_results, expected);
1807        assert_eq!(second_results, expected);
1808    }
1809
1810    #[rstest]
1811    fn test_default_fill_model_fill_limit_inside_spread_is_false() {
1812        let model = DefaultFillModel::default();
1813        assert!(!model.fill_limit_inside_spread().unwrap());
1814    }
1815
1816    #[rstest]
1817    fn test_best_price_fill_model_fill_limit_inside_spread_is_true() {
1818        let model = BestPriceFillModel::default();
1819        assert!(model.fill_limit_inside_spread().unwrap());
1820    }
1821
1822    #[rstest]
1823    fn test_one_tick_slippage_fill_model_fill_limit_inside_spread_is_false() {
1824        let model = OneTickSlippageFillModel::default();
1825        assert!(!model.fill_limit_inside_spread().unwrap());
1826    }
1827
1828    #[rstest]
1829    fn test_fill_model_any_fill_limit_inside_spread_dispatch() {
1830        let default = FillModelAny::Default(DefaultFillModel::default());
1831        assert!(!default.fill_limit_inside_spread().unwrap());
1832
1833        let best_price = FillModelAny::BestPrice(BestPriceFillModel::default());
1834        assert!(best_price.fill_limit_inside_spread().unwrap());
1835
1836        let one_tick = FillModelAny::OneTickSlippage(OneTickSlippageFillModel::default());
1837        assert!(!one_tick.fill_limit_inside_spread().unwrap());
1838    }
1839}