Skip to main content

nautilus_execution/python/
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
16//! Python bindings for fill model types.
17
18use nautilus_core::python::{clone_py_object, to_pyruntime_err, to_pytype_err};
19use nautilus_model::{
20    instruments::InstrumentAny,
21    orderbook::OrderBook,
22    orders::OrderAny,
23    python::{instruments::instrument_any_to_pyobject, orders::order_any_to_pyobject},
24    types::Price,
25};
26use pyo3::{
27    prelude::*,
28    types::{PyDict, PyTuple},
29};
30
31use crate::models::fill::{
32    BestPriceFillModel, CompetitionAwareFillModel, DefaultFillModel, FillModel, FillModelAny,
33    FillModelHandle, LimitOrderPartialFillModel, MarketHoursFillModel, OneTickSlippageFillModel,
34    ProbabilisticFillModel, SizeAwareFillModel, ThreeTierFillModel, TwoTierFillModel,
35    VolumeSensitiveFillModel,
36};
37
38#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")]
39#[pyclass(
40    module = "nautilus_trader.execution",
41    name = "FillModel",
42    subclass,
43    unsendable
44)]
45#[derive(Debug)]
46pub struct PyFillModel;
47
48#[pyo3_stub_gen::derive::gen_stub_pymethods]
49#[pymethods]
50impl PyFillModel {
51    #[new]
52    #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
53    #[pyo3(signature = (*_args, **_kwargs))]
54    fn py_new(_args: &Bound<'_, PyTuple>, _kwargs: Option<&Bound<'_, PyDict>>) -> Self {
55        Self
56    }
57
58    fn is_limit_filled(&mut self) -> bool {
59        true
60    }
61
62    fn is_slipped(&mut self) -> bool {
63        false
64    }
65
66    fn fill_limit_inside_spread(&self) -> bool {
67        false
68    }
69
70    fn get_orderbook_for_fill_simulation(
71        &mut self,
72        _instrument: &Bound<'_, PyAny>,
73        _order: &Bound<'_, PyAny>,
74        _best_bid: Option<Price>,
75        _best_ask: Option<Price>,
76    ) -> Option<OrderBook> {
77        None
78    }
79}
80
81#[derive(Debug)]
82pub struct PythonFillModel {
83    obj: Py<PyAny>,
84}
85
86impl PythonFillModel {
87    pub fn new(obj: Py<PyAny>) -> Self {
88        Self { obj }
89    }
90}
91
92impl Clone for PythonFillModel {
93    fn clone(&self) -> Self {
94        Self::new(clone_py_object(&self.obj))
95    }
96}
97
98impl FillModel for PythonFillModel {
99    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
100        call_bool_method(&self.obj, "is_limit_filled")
101    }
102
103    fn is_slipped(&mut self) -> anyhow::Result<bool> {
104        call_bool_method(&self.obj, "is_slipped")
105    }
106
107    fn fill_limit_inside_spread(&self) -> anyhow::Result<bool> {
108        Python::attach(|py| -> anyhow::Result<bool> {
109            let obj = self.obj.bind(py);
110            if !obj.hasattr("fill_limit_inside_spread")? {
111                return Ok(false);
112            }
113
114            obj.call_method0("fill_limit_inside_spread")?
115                .extract()
116                .map_err(|e| anyhow::anyhow!("{e}"))
117        })
118        .map_err(|e| anyhow::anyhow!("Python FillModel.fill_limit_inside_spread failed: {e}"))
119    }
120
121    fn get_orderbook_for_fill_simulation(
122        &mut self,
123        instrument: &InstrumentAny,
124        order: &OrderAny,
125        best_bid: Option<Price>,
126        best_ask: Option<Price>,
127    ) -> anyhow::Result<Option<OrderBook>> {
128        Python::attach(|py| -> anyhow::Result<Option<OrderBook>> {
129            let obj = self.obj.bind(py);
130            if !obj.hasattr("get_orderbook_for_fill_simulation")? {
131                return Ok(None);
132            }
133
134            let instrument = instrument_any_to_pyobject(py, instrument.clone())?;
135            let order = order_any_to_pyobject(py, order.clone())?;
136            obj.call_method1(
137                "get_orderbook_for_fill_simulation",
138                (instrument, order, best_bid, best_ask),
139            )?
140            .extract()
141            .map_err(|e| anyhow::anyhow!("{e}"))
142        })
143        .map_err(|e| {
144            anyhow::anyhow!("Python FillModel.get_orderbook_for_fill_simulation failed: {e}")
145        })
146    }
147}
148
149fn call_bool_method(obj: &Py<PyAny>, method_name: &str) -> anyhow::Result<bool> {
150    Python::attach(|py| -> anyhow::Result<bool> {
151        obj.bind(py)
152            .call_method0(method_name)?
153            .extract()
154            .map_err(|e| anyhow::anyhow!("{e}"))
155    })
156    .map_err(|e| anyhow::anyhow!("Python FillModel.{method_name} failed: {e}"))
157}
158
159/// Extracts a Python fill model object into a Rust [`FillModelAny`].
160///
161/// # Errors
162///
163/// Returns an error if `obj` is neither a supported built-in model nor a Python object with
164/// `is_limit_filled` and `is_slipped` methods.
165pub fn pyobject_to_fill_model_any(obj: &Bound<'_, PyAny>) -> PyResult<FillModelAny> {
166    if let Ok(m) = obj.extract::<DefaultFillModel>() {
167        return Ok(FillModelAny::Default(m));
168    }
169
170    if let Ok(m) = obj.extract::<BestPriceFillModel>() {
171        return Ok(FillModelAny::BestPrice(m));
172    }
173
174    if let Ok(m) = obj.extract::<OneTickSlippageFillModel>() {
175        return Ok(FillModelAny::OneTickSlippage(m));
176    }
177
178    if let Ok(m) = obj.extract::<ProbabilisticFillModel>() {
179        return Ok(FillModelAny::Probabilistic(m));
180    }
181
182    if let Ok(m) = obj.extract::<TwoTierFillModel>() {
183        return Ok(FillModelAny::TwoTier(m));
184    }
185
186    if let Ok(m) = obj.extract::<ThreeTierFillModel>() {
187        return Ok(FillModelAny::ThreeTier(m));
188    }
189
190    if let Ok(m) = obj.extract::<LimitOrderPartialFillModel>() {
191        return Ok(FillModelAny::LimitOrderPartialFill(m));
192    }
193
194    if let Ok(m) = obj.extract::<SizeAwareFillModel>() {
195        return Ok(FillModelAny::SizeAware(m));
196    }
197
198    if let Ok(m) = obj.extract::<CompetitionAwareFillModel>() {
199        return Ok(FillModelAny::CompetitionAware(m));
200    }
201
202    if let Ok(m) = obj.extract::<VolumeSensitiveFillModel>() {
203        return Ok(FillModelAny::VolumeSensitive(m));
204    }
205
206    if let Ok(m) = obj.extract::<MarketHoursFillModel>() {
207        return Ok(FillModelAny::MarketHours(m));
208    }
209
210    let has_required_methods = obj.hasattr("is_limit_filled")? && obj.hasattr("is_slipped")?;
211    if !has_required_methods {
212        let type_name = obj.get_type().name()?;
213        return Err(to_pytype_err(format!(
214            "Cannot convert {type_name} to FillModel"
215        )));
216    }
217
218    Ok(FillModelAny::Python(PythonFillModel::new(
219        obj.clone().unbind(),
220    )))
221}
222
223/// Extracts a Python fill model object into a runtime [`FillModelHandle`].
224///
225/// # Errors
226///
227/// Returns an error if `obj` is neither a supported built-in model nor a Python object with
228/// `is_limit_filled` and `is_slipped` methods.
229pub fn pyobject_to_fill_model_handle(obj: &Bound<'_, PyAny>) -> PyResult<FillModelHandle> {
230    pyobject_to_fill_model_any(obj).map(Into::into)
231}
232
233/// Converts a Rust [`FillModelAny`] into its Python binding object.
234///
235/// # Errors
236///
237/// Returns an error if conversion to a Python object fails.
238pub fn fill_model_any_to_pyobject(py: Python<'_>, model: &FillModelAny) -> PyResult<Py<PyAny>> {
239    match model {
240        FillModelAny::Default(model) => Ok(Py::new(py, model.clone())?.into_any()),
241        FillModelAny::BestPrice(model) => Ok(Py::new(py, model.clone())?.into_any()),
242        FillModelAny::OneTickSlippage(model) => Ok(Py::new(py, model.clone())?.into_any()),
243        FillModelAny::Probabilistic(model) => Ok(Py::new(py, model.clone())?.into_any()),
244        FillModelAny::TwoTier(model) => Ok(Py::new(py, model.clone())?.into_any()),
245        FillModelAny::ThreeTier(model) => Ok(Py::new(py, model.clone())?.into_any()),
246        FillModelAny::LimitOrderPartialFill(model) => Ok(Py::new(py, model.clone())?.into_any()),
247        FillModelAny::SizeAware(model) => Ok(Py::new(py, model.clone())?.into_any()),
248        FillModelAny::CompetitionAware(model) => Ok(Py::new(py, model.clone())?.into_any()),
249        FillModelAny::VolumeSensitive(model) => Ok(Py::new(py, model.clone())?.into_any()),
250        FillModelAny::MarketHours(model) => Ok(Py::new(py, model.clone())?.into_any()),
251        FillModelAny::Python(model) => Ok(model.obj.clone_ref(py)),
252    }
253}
254
255macro_rules! impl_fill_model_pymethods {
256    ($type:ty) => {
257        #[pymethods]
258        #[pyo3_stub_gen::derive::gen_stub_pymethods]
259        impl $type {
260            #[new]
261            #[pyo3(signature = (prob_fill_on_limit=1.0, prob_slippage=0.0, random_seed=None))]
262            fn py_new(
263                prob_fill_on_limit: f64,
264                prob_slippage: f64,
265                random_seed: Option<u64>,
266            ) -> PyResult<Self> {
267                Self::new(prob_fill_on_limit, prob_slippage, random_seed).map_err(to_pyruntime_err)
268            }
269
270            fn __repr__(&self) -> String {
271                format!("{self:?}")
272            }
273        }
274    };
275}
276
277impl_fill_model_pymethods!(DefaultFillModel);
278impl_fill_model_pymethods!(BestPriceFillModel);
279impl_fill_model_pymethods!(OneTickSlippageFillModel);
280impl_fill_model_pymethods!(ProbabilisticFillModel);
281impl_fill_model_pymethods!(TwoTierFillModel);
282impl_fill_model_pymethods!(ThreeTierFillModel);
283impl_fill_model_pymethods!(LimitOrderPartialFillModel);
284impl_fill_model_pymethods!(SizeAwareFillModel);
285impl_fill_model_pymethods!(VolumeSensitiveFillModel);
286impl_fill_model_pymethods!(MarketHoursFillModel);
287
288#[pymethods]
289#[pyo3_stub_gen::derive::gen_stub_pymethods]
290impl CompetitionAwareFillModel {
291    /// Fill model that reduces available liquidity by a factor to simulate market competition.
292    #[new]
293    #[pyo3(signature = (
294        prob_fill_on_limit=1.0,
295        prob_slippage=0.0,
296        random_seed=None,
297        liquidity_factor=0.3,
298    ))]
299    fn py_new(
300        prob_fill_on_limit: f64,
301        prob_slippage: f64,
302        random_seed: Option<u64>,
303        liquidity_factor: f64,
304    ) -> PyResult<Self> {
305        Self::new(
306            prob_fill_on_limit,
307            prob_slippage,
308            random_seed,
309            liquidity_factor,
310        )
311        .map_err(to_pyruntime_err)
312    }
313
314    fn __repr__(&self) -> String {
315        format!("{self:?}")
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use nautilus_core::UnixNanos;
322    use nautilus_model::{
323        data::order::BookOrder,
324        enums::{BookType, OrderSide, OrderType},
325        instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
326        orders::builder::OrderTestBuilder,
327        types::Quantity,
328    };
329    use pyo3::ffi::c_str;
330    use rstest::rstest;
331
332    use super::*;
333
334    #[rstest]
335    fn test_python_fill_model_any_clones_preserve_instance_and_state() {
336        Python::initialize();
337
338        Python::attach(|py| {
339            let model = py
340                .eval(
341                    c_str!(
342                        "type('CustomFillModel', (), {\
343                            'calls': [], \
344                            'is_limit_filled': lambda self: self.calls.append('limit') or False, \
345                            'is_slipped': lambda self: self.calls.append('slip') or True, \
346                            'fill_limit_inside_spread': lambda self: True\
347                        })()"
348                    ),
349                    None,
350                    None,
351                )
352                .unwrap();
353            let first = pyobject_to_fill_model_any(&model).unwrap();
354            let second = first.clone();
355            let returned = fill_model_any_to_pyobject(py, &second).unwrap();
356            let mut first: FillModelHandle = first.into();
357            let mut second: FillModelHandle = second.into();
358
359            assert!(!first.is_limit_filled().unwrap());
360            assert!(second.is_slipped().unwrap());
361            assert!(second.fill_limit_inside_spread().unwrap());
362            assert!(returned.bind(py).is(&model));
363            assert_eq!(
364                model
365                    .getattr("calls")
366                    .unwrap()
367                    .extract::<Vec<String>>()
368                    .unwrap(),
369                vec!["limit", "slip"],
370            );
371        });
372    }
373
374    #[rstest]
375    fn test_python_fill_model_handle_calls_python_methods() {
376        Python::initialize();
377
378        Python::attach(|py| {
379            let model = py
380                .eval(
381                    c_str!(
382                        "type('CustomFillModel', (), {\
383                            'is_limit_filled': lambda self: False, \
384                            'is_slipped': lambda self: True, \
385                            'fill_limit_inside_spread': lambda self: True\
386                        })()"
387                    ),
388                    None,
389                    None,
390                )
391                .unwrap();
392            let mut handle = pyobject_to_fill_model_handle(&model).unwrap();
393
394            assert!(!handle.is_limit_filled().unwrap());
395            assert!(handle.is_slipped().unwrap());
396            assert!(handle.fill_limit_inside_spread().unwrap());
397        });
398    }
399
400    #[rstest]
401    fn test_python_fill_model_handle_calls_python_liquidity_method(
402        #[values(None, Some(Price::from("0.80000")))] best_bid: Option<Price>,
403        #[values(None, Some(Price::from("0.80010")))] best_ask: Option<Price>,
404        #[values(false, true)] returns_book: bool,
405    ) {
406        Python::initialize();
407
408        Python::attach(|py| {
409            let instrument = InstrumentAny::CurrencyPair(audusd_sim());
410            let order = OrderTestBuilder::new(OrderType::Market)
411                .instrument_id(instrument.id())
412                .side(OrderSide::Buy)
413                .quantity(Quantity::from(100_000))
414                .build();
415            let model = py
416                .eval(
417                    c_str!(
418                        "type('CustomFillModel', (), {\
419                            'is_limit_filled': lambda self: True, \
420                            'is_slipped': lambda self: False, \
421                            'quotes': [], \
422                            'get_orderbook_for_fill_simulation': \
423                                lambda self, instrument, order, best_bid, best_ask: \
424                                    self.quotes.append((best_bid, best_ask)) or self.book\
425                        })()"
426                    ),
427                    None,
428                    None,
429                )
430                .unwrap();
431            let mut expected = OrderBook::new(instrument.id(), BookType::L2_MBP);
432            expected.add(
433                BookOrder::new(
434                    OrderSide::Buy,
435                    Price::from("0.71234"),
436                    Quantity::from(12),
437                    17,
438                ),
439                0,
440                23,
441                UnixNanos::from(29),
442            );
443            model
444                .setattr(
445                    "book",
446                    if returns_book {
447                        Some(Py::new(py, expected.clone()).unwrap())
448                    } else {
449                        None
450                    },
451                )
452                .unwrap();
453
454            let mut handle = pyobject_to_fill_model_handle(&model).unwrap();
455
456            let book = handle
457                .get_orderbook_for_fill_simulation(&instrument, &order, best_bid, best_ask)
458                .unwrap();
459
460            if returns_book {
461                let book = book.unwrap();
462                assert_eq!(book.instrument_id, expected.instrument_id);
463                assert_eq!(book.book_type, expected.book_type);
464                assert_eq!(book.sequence, 23);
465                assert_eq!(book.ts_last, UnixNanos::from(29));
466                assert_eq!(book.update_count, 1);
467                assert_eq!(book.bids_as_map(None), expected.bids_as_map(None));
468                assert_eq!(book.asks_as_map(None), expected.asks_as_map(None));
469            } else {
470                assert!(book.is_none());
471            }
472
473            let quotes: Vec<(Option<Price>, Option<Price>)> =
474                model.getattr("quotes").unwrap().extract().unwrap();
475            assert_eq!(quotes, vec![(best_bid, best_ask)]);
476        });
477    }
478
479    #[rstest]
480    fn test_python_fill_model_handle_uses_defaults_for_missing_optional_methods() {
481        Python::initialize();
482
483        Python::attach(|py| {
484            let instrument = InstrumentAny::CurrencyPair(audusd_sim());
485            let order = OrderTestBuilder::new(OrderType::Market)
486                .instrument_id(instrument.id())
487                .side(OrderSide::Buy)
488                .quantity(Quantity::from(100_000))
489                .build();
490            let model = py
491                .eval(
492                    c_str!(
493                        "type('CustomFillModel', (), {\
494                            'is_limit_filled': lambda self: True, \
495                            'is_slipped': lambda self: False\
496                        })()"
497                    ),
498                    None,
499                    None,
500                )
501                .unwrap();
502            let mut handle = pyobject_to_fill_model_handle(&model).unwrap();
503
504            let book = handle
505                .get_orderbook_for_fill_simulation(
506                    &instrument,
507                    &order,
508                    Some(Price::from("0.80000")),
509                    Some(Price::from("0.80010")),
510                )
511                .unwrap();
512
513            assert!(!handle.fill_limit_inside_spread().unwrap());
514            assert!(book.is_none());
515        });
516    }
517
518    #[rstest]
519    #[case("is_limit_filled")]
520    #[case("is_slipped")]
521    #[case("fill_limit_inside_spread")]
522    #[case("get_orderbook_for_fill_simulation")]
523    fn test_python_fill_model_any_propagates_callback_errors(#[case] method: &str) {
524        Python::initialize();
525
526        Python::attach(|py| {
527            let model = py
528                .eval(
529                    c_str!(
530                        "type('CustomFillModel', (), {\
531                            'is_limit_filled': lambda self: True, \
532                            'is_slipped': lambda self: False\
533                        })()"
534                    ),
535                    None,
536                    None,
537                )
538                .unwrap();
539            let callback = py
540                .eval(
541                    c_str!("lambda *args: (_ for _ in ()).throw(ValueError('boom'))"),
542                    None,
543                    None,
544                )
545                .unwrap();
546            model.setattr(method, callback).unwrap();
547            let mut model = pyobject_to_fill_model_any(&model).unwrap();
548            let error = match method {
549                "is_limit_filled" => model.is_limit_filled().unwrap_err(),
550                "is_slipped" => model.is_slipped().unwrap_err(),
551                "fill_limit_inside_spread" => model.fill_limit_inside_spread().unwrap_err(),
552                "get_orderbook_for_fill_simulation" => {
553                    let instrument = InstrumentAny::CurrencyPair(audusd_sim());
554                    let order = OrderTestBuilder::new(OrderType::Market)
555                        .instrument_id(instrument.id())
556                        .side(OrderSide::Buy)
557                        .quantity(Quantity::from(100_000))
558                        .build();
559                    model
560                        .get_orderbook_for_fill_simulation(&instrument, &order, None, None)
561                        .unwrap_err()
562                }
563                _ => unreachable!(),
564            };
565
566            assert_eq!(
567                error.to_string(),
568                format!("Python FillModel.{method} failed: ValueError: boom")
569            );
570        });
571    }
572
573    #[rstest]
574    fn test_python_fill_model_handle_propagates_python_error() {
575        Python::initialize();
576
577        Python::attach(|py| {
578            let model = py
579                .eval(
580                    c_str!(
581                        "type('CustomFillModel', (), {\
582                            'is_limit_filled': lambda self: \
583                                (_ for _ in ()).throw(RuntimeError('boom')), \
584                            'is_slipped': lambda self: False\
585                        })()"
586                    ),
587                    None,
588                    None,
589                )
590                .unwrap();
591            let mut handle = pyobject_to_fill_model_handle(&model).unwrap();
592            let error = handle.is_limit_filled().unwrap_err().to_string();
593
594            assert!(error.contains("Python FillModel.is_limit_filled failed"));
595            assert!(error.contains("boom"));
596        });
597    }
598}