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