nautilus_execution/python/
fill.rs1use 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
148pub 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
204pub 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
228pub 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 #[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}