1use 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
159pub 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
223pub fn pyobject_to_fill_model_handle(obj: &Bound<'_, PyAny>) -> PyResult<FillModelHandle> {
230 pyobject_to_fill_model_any(obj).map(Into::into)
231}
232
233pub 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 #[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}