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::{
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
153pub 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
209pub 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
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 }
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 #[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}