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::{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 FillModel for PythonFillModel {
93    fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
94        call_bool_method(&self.obj, "is_limit_filled")
95    }
96
97    fn is_slipped(&mut self) -> anyhow::Result<bool> {
98        call_bool_method(&self.obj, "is_slipped")
99    }
100
101    fn fill_limit_inside_spread(&self) -> anyhow::Result<bool> {
102        Python::attach(|py| -> anyhow::Result<bool> {
103            let obj = self.obj.bind(py);
104            if !obj.hasattr("fill_limit_inside_spread")? {
105                return Ok(false);
106            }
107
108            obj.call_method0("fill_limit_inside_spread")?
109                .extract()
110                .map_err(|e| anyhow::anyhow!("{e}"))
111        })
112        .map_err(|e| anyhow::anyhow!("Python FillModel.fill_limit_inside_spread failed: {e}"))
113    }
114
115    fn get_orderbook_for_fill_simulation(
116        &mut self,
117        instrument: &InstrumentAny,
118        order: &OrderAny,
119        best_bid: Option<Price>,
120        best_ask: Option<Price>,
121    ) -> anyhow::Result<Option<OrderBook>> {
122        Python::attach(|py| -> anyhow::Result<Option<OrderBook>> {
123            let obj = self.obj.bind(py);
124            if !obj.hasattr("get_orderbook_for_fill_simulation")? {
125                return Ok(None);
126            }
127
128            let instrument = instrument_any_to_pyobject(py, instrument.clone())?;
129            let order = order_any_to_pyobject(py, order.clone())?;
130            obj.call_method1(
131                "get_orderbook_for_fill_simulation",
132                (instrument, order, best_bid, best_ask),
133            )?
134            .extract()
135            .map_err(|e| anyhow::anyhow!("{e}"))
136        })
137        .map_err(|e| {
138            anyhow::anyhow!("Python FillModel.get_orderbook_for_fill_simulation failed: {e}")
139        })
140    }
141}
142
143fn call_bool_method(obj: &Py<PyAny>, method_name: &str) -> anyhow::Result<bool> {
144    Python::attach(|py| -> anyhow::Result<bool> {
145        obj.bind(py)
146            .call_method0(method_name)?
147            .extract()
148            .map_err(|e| anyhow::anyhow!("{e}"))
149    })
150    .map_err(|e| anyhow::anyhow!("Python FillModel.{method_name} failed: {e}"))
151}
152
153/// Extracts a Python fill model object into a Rust [`FillModelAny`].
154///
155/// # Errors
156///
157/// Returns an error if `obj` is not a supported built-in fill model binding.
158pub fn pyobject_to_fill_model_any(obj: &Bound<'_, PyAny>) -> PyResult<FillModelAny> {
159    if let Ok(m) = obj.extract::<DefaultFillModel>() {
160        return Ok(FillModelAny::Default(m));
161    }
162
163    if let Ok(m) = obj.extract::<BestPriceFillModel>() {
164        return Ok(FillModelAny::BestPrice(m));
165    }
166
167    if let Ok(m) = obj.extract::<OneTickSlippageFillModel>() {
168        return Ok(FillModelAny::OneTickSlippage(m));
169    }
170
171    if let Ok(m) = obj.extract::<ProbabilisticFillModel>() {
172        return Ok(FillModelAny::Probabilistic(m));
173    }
174
175    if let Ok(m) = obj.extract::<TwoTierFillModel>() {
176        return Ok(FillModelAny::TwoTier(m));
177    }
178
179    if let Ok(m) = obj.extract::<ThreeTierFillModel>() {
180        return Ok(FillModelAny::ThreeTier(m));
181    }
182
183    if let Ok(m) = obj.extract::<LimitOrderPartialFillModel>() {
184        return Ok(FillModelAny::LimitOrderPartialFill(m));
185    }
186
187    if let Ok(m) = obj.extract::<SizeAwareFillModel>() {
188        return Ok(FillModelAny::SizeAware(m));
189    }
190
191    if let Ok(m) = obj.extract::<CompetitionAwareFillModel>() {
192        return Ok(FillModelAny::CompetitionAware(m));
193    }
194
195    if let Ok(m) = obj.extract::<VolumeSensitiveFillModel>() {
196        return Ok(FillModelAny::VolumeSensitive(m));
197    }
198
199    if let Ok(m) = obj.extract::<MarketHoursFillModel>() {
200        return Ok(FillModelAny::MarketHours(m));
201    }
202
203    let type_name = obj.get_type().name()?;
204    Err(to_pytype_err(format!(
205        "Cannot convert {type_name} to FillModel"
206    )))
207}
208
209/// Extracts a Python fill model object into a runtime [`FillModelHandle`].
210///
211/// # Errors
212///
213/// Returns an error if `obj` is neither a supported built-in model nor a Python object with
214/// `is_limit_filled` and `is_slipped` methods.
215pub fn pyobject_to_fill_model_handle(obj: &Bound<'_, PyAny>) -> PyResult<FillModelHandle> {
216    if let Ok(model) = pyobject_to_fill_model_any(obj) {
217        return Ok(model.into());
218    }
219
220    let has_required_methods = obj.hasattr("is_limit_filled")? && obj.hasattr("is_slipped")?;
221    if !has_required_methods {
222        let type_name = obj.get_type().name()?;
223        return Err(to_pytype_err(format!(
224            "Cannot convert {type_name} to FillModel"
225        )));
226    }
227
228    Ok(FillModelHandle::new(PythonFillModel::new(
229        obj.clone().unbind(),
230    )))
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    }
252}
253
254macro_rules! impl_fill_model_pymethods {
255    ($type:ty) => {
256        #[pymethods]
257        #[pyo3_stub_gen::derive::gen_stub_pymethods]
258        impl $type {
259            #[new]
260            #[pyo3(signature = (prob_fill_on_limit=1.0, prob_slippage=0.0, random_seed=None))]
261            fn py_new(
262                prob_fill_on_limit: f64,
263                prob_slippage: f64,
264                random_seed: Option<u64>,
265            ) -> PyResult<Self> {
266                Self::new(prob_fill_on_limit, prob_slippage, random_seed).map_err(to_pyruntime_err)
267            }
268
269            fn __repr__(&self) -> String {
270                format!("{self:?}")
271            }
272        }
273    };
274}
275
276impl_fill_model_pymethods!(DefaultFillModel);
277impl_fill_model_pymethods!(BestPriceFillModel);
278impl_fill_model_pymethods!(OneTickSlippageFillModel);
279impl_fill_model_pymethods!(ProbabilisticFillModel);
280impl_fill_model_pymethods!(TwoTierFillModel);
281impl_fill_model_pymethods!(ThreeTierFillModel);
282impl_fill_model_pymethods!(LimitOrderPartialFillModel);
283impl_fill_model_pymethods!(SizeAwareFillModel);
284impl_fill_model_pymethods!(VolumeSensitiveFillModel);
285impl_fill_model_pymethods!(MarketHoursFillModel);
286
287#[pymethods]
288#[pyo3_stub_gen::derive::gen_stub_pymethods]
289impl CompetitionAwareFillModel {
290    /// Fill model that reduces available liquidity by a factor to simulate market competition.
291    #[new]
292    #[pyo3(signature = (
293        prob_fill_on_limit=1.0,
294        prob_slippage=0.0,
295        random_seed=None,
296        liquidity_factor=0.3,
297    ))]
298    fn py_new(
299        prob_fill_on_limit: f64,
300        prob_slippage: f64,
301        random_seed: Option<u64>,
302        liquidity_factor: f64,
303    ) -> PyResult<Self> {
304        Self::new(
305            prob_fill_on_limit,
306            prob_slippage,
307            random_seed,
308            liquidity_factor,
309        )
310        .map_err(to_pyruntime_err)
311    }
312
313    fn __repr__(&self) -> String {
314        format!("{self:?}")
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use nautilus_model::{
321        enums::{OrderSide, OrderType},
322        instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
323        orders::builder::OrderTestBuilder,
324        types::Quantity,
325    };
326    use pyo3::ffi::c_str;
327    use rstest::rstest;
328
329    use super::*;
330
331    #[rstest]
332    fn test_python_fill_model_handle_calls_python_methods() {
333        Python::initialize();
334
335        Python::attach(|py| {
336            let model = py
337                .eval(
338                    c_str!(
339                        "type('CustomFillModel', (), {\
340                            'is_limit_filled': lambda self: False, \
341                            'is_slipped': lambda self: True, \
342                            'fill_limit_inside_spread': lambda self: True\
343                        })()"
344                    ),
345                    None,
346                    None,
347                )
348                .unwrap();
349            let mut handle = pyobject_to_fill_model_handle(&model).unwrap();
350
351            assert!(!handle.is_limit_filled().unwrap());
352            assert!(handle.is_slipped().unwrap());
353            assert!(handle.fill_limit_inside_spread().unwrap());
354        });
355    }
356
357    #[rstest]
358    fn test_python_fill_model_handle_calls_python_liquidity_method(
359        #[values(None, Some(Price::from("0.80000")))] best_bid: Option<Price>,
360        #[values(None, Some(Price::from("0.80010")))] best_ask: Option<Price>,
361    ) {
362        Python::initialize();
363
364        Python::attach(|py| {
365            let instrument = InstrumentAny::CurrencyPair(audusd_sim());
366            let order = OrderTestBuilder::new(OrderType::Market)
367                .instrument_id(instrument.id())
368                .side(OrderSide::Buy)
369                .quantity(Quantity::from(100_000))
370                .build();
371            let model = py
372                .eval(
373                    c_str!(
374                        "type('CustomFillModel', (), {\
375                            'is_limit_filled': lambda self: True, \
376                            'is_slipped': lambda self: False, \
377                            'quotes': [], \
378                            'get_orderbook_for_fill_simulation': \
379                                lambda self, instrument, order, best_bid, best_ask: \
380                                    self.quotes.append((best_bid, best_ask))\
381                        })()"
382                    ),
383                    None,
384                    None,
385                )
386                .unwrap();
387            let mut handle = pyobject_to_fill_model_handle(&model).unwrap();
388
389            let book = handle
390                .get_orderbook_for_fill_simulation(&instrument, &order, best_bid, best_ask)
391                .unwrap();
392
393            assert!(book.is_none());
394            let quotes: Vec<(Option<Price>, Option<Price>)> =
395                model.getattr("quotes").unwrap().extract().unwrap();
396            assert_eq!(quotes, vec![(best_bid, best_ask)]);
397        });
398    }
399
400    #[rstest]
401    fn test_python_fill_model_handle_uses_defaults_for_missing_optional_methods() {
402        Python::initialize();
403
404        Python::attach(|py| {
405            let instrument = InstrumentAny::CurrencyPair(audusd_sim());
406            let order = OrderTestBuilder::new(OrderType::Market)
407                .instrument_id(instrument.id())
408                .side(OrderSide::Buy)
409                .quantity(Quantity::from(100_000))
410                .build();
411            let model = py
412                .eval(
413                    c_str!(
414                        "type('CustomFillModel', (), {\
415                            'is_limit_filled': lambda self: True, \
416                            'is_slipped': lambda self: False\
417                        })()"
418                    ),
419                    None,
420                    None,
421                )
422                .unwrap();
423            let mut handle = pyobject_to_fill_model_handle(&model).unwrap();
424
425            let book = handle
426                .get_orderbook_for_fill_simulation(
427                    &instrument,
428                    &order,
429                    Some(Price::from("0.80000")),
430                    Some(Price::from("0.80010")),
431                )
432                .unwrap();
433
434            assert!(!handle.fill_limit_inside_spread().unwrap());
435            assert!(book.is_none());
436        });
437    }
438
439    #[rstest]
440    fn test_python_fill_model_handle_propagates_python_error() {
441        Python::initialize();
442
443        Python::attach(|py| {
444            let model = py
445                .eval(
446                    c_str!(
447                        "type('CustomFillModel', (), {\
448                            'is_limit_filled': lambda self: \
449                                (_ for _ in ()).throw(RuntimeError('boom')), \
450                            'is_slipped': lambda self: False\
451                        })()"
452                    ),
453                    None,
454                    None,
455                )
456                .unwrap();
457            let mut handle = pyobject_to_fill_model_handle(&model).unwrap();
458            let error = handle.is_limit_filled().unwrap_err().to_string();
459
460            assert!(error.contains("Python FillModel.is_limit_filled failed"));
461            assert!(error.contains("boom"));
462        });
463    }
464}