use nautilus_core::python::{clone_py_object, to_pyruntime_err, to_pytype_err};
use nautilus_model::{
instruments::InstrumentAny,
orderbook::OrderBook,
orders::OrderAny,
python::{instruments::instrument_any_to_pyobject, orders::order_any_to_pyobject},
types::Price,
};
use pyo3::{
prelude::*,
types::{PyDict, PyTuple},
};
use crate::models::fill::{
BestPriceFillModel, CompetitionAwareFillModel, DefaultFillModel, FillModel, FillModelAny,
FillModelHandle, LimitOrderPartialFillModel, MarketHoursFillModel, OneTickSlippageFillModel,
ProbabilisticFillModel, SizeAwareFillModel, ThreeTierFillModel, TwoTierFillModel,
VolumeSensitiveFillModel,
};
#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")]
#[pyclass(
module = "nautilus_trader.execution",
name = "FillModel",
subclass,
unsendable
)]
#[derive(Debug)]
pub struct PyFillModel;
#[pyo3_stub_gen::derive::gen_stub_pymethods]
#[pymethods]
impl PyFillModel {
#[new]
#[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
#[pyo3(signature = (*_args, **_kwargs))]
fn py_new(_args: &Bound<'_, PyTuple>, _kwargs: Option<&Bound<'_, PyDict>>) -> Self {
Self
}
fn is_limit_filled(&mut self) -> bool {
true
}
fn is_slipped(&mut self) -> bool {
false
}
fn fill_limit_inside_spread(&self) -> bool {
false
}
fn get_orderbook_for_fill_simulation(
&mut self,
_instrument: &Bound<'_, PyAny>,
_order: &Bound<'_, PyAny>,
_best_bid: Option<Price>,
_best_ask: Option<Price>,
) -> Option<OrderBook> {
None
}
}
#[derive(Debug)]
pub struct PythonFillModel {
obj: Py<PyAny>,
}
impl PythonFillModel {
pub fn new(obj: Py<PyAny>) -> Self {
Self { obj }
}
}
impl Clone for PythonFillModel {
fn clone(&self) -> Self {
Self::new(clone_py_object(&self.obj))
}
}
impl FillModel for PythonFillModel {
fn is_limit_filled(&mut self) -> anyhow::Result<bool> {
call_bool_method(&self.obj, "is_limit_filled")
}
fn is_slipped(&mut self) -> anyhow::Result<bool> {
call_bool_method(&self.obj, "is_slipped")
}
fn fill_limit_inside_spread(&self) -> anyhow::Result<bool> {
Python::attach(|py| -> anyhow::Result<bool> {
let obj = self.obj.bind(py);
if !obj.hasattr("fill_limit_inside_spread")? {
return Ok(false);
}
obj.call_method0("fill_limit_inside_spread")?
.extract()
.map_err(|e| anyhow::anyhow!("{e}"))
})
.map_err(|e| anyhow::anyhow!("Python FillModel.fill_limit_inside_spread failed: {e}"))
}
fn get_orderbook_for_fill_simulation(
&mut self,
instrument: &InstrumentAny,
order: &OrderAny,
best_bid: Option<Price>,
best_ask: Option<Price>,
) -> anyhow::Result<Option<OrderBook>> {
Python::attach(|py| -> anyhow::Result<Option<OrderBook>> {
let obj = self.obj.bind(py);
if !obj.hasattr("get_orderbook_for_fill_simulation")? {
return Ok(None);
}
let instrument = instrument_any_to_pyobject(py, instrument.clone())?;
let order = order_any_to_pyobject(py, order.clone())?;
obj.call_method1(
"get_orderbook_for_fill_simulation",
(instrument, order, best_bid, best_ask),
)?
.extract()
.map_err(|e| anyhow::anyhow!("{e}"))
})
.map_err(|e| {
anyhow::anyhow!("Python FillModel.get_orderbook_for_fill_simulation failed: {e}")
})
}
}
fn call_bool_method(obj: &Py<PyAny>, method_name: &str) -> anyhow::Result<bool> {
Python::attach(|py| -> anyhow::Result<bool> {
obj.bind(py)
.call_method0(method_name)?
.extract()
.map_err(|e| anyhow::anyhow!("{e}"))
})
.map_err(|e| anyhow::anyhow!("Python FillModel.{method_name} failed: {e}"))
}
pub fn pyobject_to_fill_model_any(obj: &Bound<'_, PyAny>) -> PyResult<FillModelAny> {
if let Ok(m) = obj.extract::<DefaultFillModel>() {
return Ok(FillModelAny::Default(m));
}
if let Ok(m) = obj.extract::<BestPriceFillModel>() {
return Ok(FillModelAny::BestPrice(m));
}
if let Ok(m) = obj.extract::<OneTickSlippageFillModel>() {
return Ok(FillModelAny::OneTickSlippage(m));
}
if let Ok(m) = obj.extract::<ProbabilisticFillModel>() {
return Ok(FillModelAny::Probabilistic(m));
}
if let Ok(m) = obj.extract::<TwoTierFillModel>() {
return Ok(FillModelAny::TwoTier(m));
}
if let Ok(m) = obj.extract::<ThreeTierFillModel>() {
return Ok(FillModelAny::ThreeTier(m));
}
if let Ok(m) = obj.extract::<LimitOrderPartialFillModel>() {
return Ok(FillModelAny::LimitOrderPartialFill(m));
}
if let Ok(m) = obj.extract::<SizeAwareFillModel>() {
return Ok(FillModelAny::SizeAware(m));
}
if let Ok(m) = obj.extract::<CompetitionAwareFillModel>() {
return Ok(FillModelAny::CompetitionAware(m));
}
if let Ok(m) = obj.extract::<VolumeSensitiveFillModel>() {
return Ok(FillModelAny::VolumeSensitive(m));
}
if let Ok(m) = obj.extract::<MarketHoursFillModel>() {
return Ok(FillModelAny::MarketHours(m));
}
let has_required_methods = obj.hasattr("is_limit_filled")? && obj.hasattr("is_slipped")?;
if !has_required_methods {
let type_name = obj.get_type().name()?;
return Err(to_pytype_err(format!(
"Cannot convert {type_name} to FillModel"
)));
}
Ok(FillModelAny::Python(PythonFillModel::new(
obj.clone().unbind(),
)))
}
pub fn pyobject_to_fill_model_handle(obj: &Bound<'_, PyAny>) -> PyResult<FillModelHandle> {
pyobject_to_fill_model_any(obj).map(Into::into)
}
pub fn fill_model_any_to_pyobject(py: Python<'_>, model: &FillModelAny) -> PyResult<Py<PyAny>> {
match model {
FillModelAny::Default(model) => Ok(Py::new(py, model.clone())?.into_any()),
FillModelAny::BestPrice(model) => Ok(Py::new(py, model.clone())?.into_any()),
FillModelAny::OneTickSlippage(model) => Ok(Py::new(py, model.clone())?.into_any()),
FillModelAny::Probabilistic(model) => Ok(Py::new(py, model.clone())?.into_any()),
FillModelAny::TwoTier(model) => Ok(Py::new(py, model.clone())?.into_any()),
FillModelAny::ThreeTier(model) => Ok(Py::new(py, model.clone())?.into_any()),
FillModelAny::LimitOrderPartialFill(model) => Ok(Py::new(py, model.clone())?.into_any()),
FillModelAny::SizeAware(model) => Ok(Py::new(py, model.clone())?.into_any()),
FillModelAny::CompetitionAware(model) => Ok(Py::new(py, model.clone())?.into_any()),
FillModelAny::VolumeSensitive(model) => Ok(Py::new(py, model.clone())?.into_any()),
FillModelAny::MarketHours(model) => Ok(Py::new(py, model.clone())?.into_any()),
FillModelAny::Python(model) => Ok(model.obj.clone_ref(py)),
}
}
macro_rules! impl_fill_model_pymethods {
($type:ty) => {
#[pymethods]
#[pyo3_stub_gen::derive::gen_stub_pymethods]
impl $type {
#[new]
#[pyo3(signature = (prob_fill_on_limit=1.0, prob_slippage=0.0, random_seed=None))]
fn py_new(
prob_fill_on_limit: f64,
prob_slippage: f64,
random_seed: Option<u64>,
) -> PyResult<Self> {
Self::new(prob_fill_on_limit, prob_slippage, random_seed).map_err(to_pyruntime_err)
}
fn __repr__(&self) -> String {
format!("{self:?}")
}
}
};
}
impl_fill_model_pymethods!(DefaultFillModel);
impl_fill_model_pymethods!(BestPriceFillModel);
impl_fill_model_pymethods!(OneTickSlippageFillModel);
impl_fill_model_pymethods!(ProbabilisticFillModel);
impl_fill_model_pymethods!(TwoTierFillModel);
impl_fill_model_pymethods!(ThreeTierFillModel);
impl_fill_model_pymethods!(LimitOrderPartialFillModel);
impl_fill_model_pymethods!(SizeAwareFillModel);
impl_fill_model_pymethods!(VolumeSensitiveFillModel);
impl_fill_model_pymethods!(MarketHoursFillModel);
#[pymethods]
#[pyo3_stub_gen::derive::gen_stub_pymethods]
impl CompetitionAwareFillModel {
#[new]
#[pyo3(signature = (
prob_fill_on_limit=1.0,
prob_slippage=0.0,
random_seed=None,
liquidity_factor=0.3,
))]
fn py_new(
prob_fill_on_limit: f64,
prob_slippage: f64,
random_seed: Option<u64>,
liquidity_factor: f64,
) -> PyResult<Self> {
Self::new(
prob_fill_on_limit,
prob_slippage,
random_seed,
liquidity_factor,
)
.map_err(to_pyruntime_err)
}
fn __repr__(&self) -> String {
format!("{self:?}")
}
}
#[cfg(test)]
mod tests {
use nautilus_core::UnixNanos;
use nautilus_model::{
data::order::BookOrder,
enums::{BookType, OrderSide, OrderType},
instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
orders::builder::OrderTestBuilder,
types::Quantity,
};
use pyo3::ffi::c_str;
use rstest::rstest;
use super::*;
#[rstest]
fn test_python_fill_model_any_clones_preserve_instance_and_state() {
Python::initialize();
Python::attach(|py| {
let model = py
.eval(
c_str!(
"type('CustomFillModel', (), {\
'calls': [], \
'is_limit_filled': lambda self: self.calls.append('limit') or False, \
'is_slipped': lambda self: self.calls.append('slip') or True, \
'fill_limit_inside_spread': lambda self: True\
})()"
),
None,
None,
)
.unwrap();
let first = pyobject_to_fill_model_any(&model).unwrap();
let second = first.clone();
let returned = fill_model_any_to_pyobject(py, &second).unwrap();
let mut first: FillModelHandle = first.into();
let mut second: FillModelHandle = second.into();
assert!(!first.is_limit_filled().unwrap());
assert!(second.is_slipped().unwrap());
assert!(second.fill_limit_inside_spread().unwrap());
assert!(returned.bind(py).is(&model));
assert_eq!(
model
.getattr("calls")
.unwrap()
.extract::<Vec<String>>()
.unwrap(),
vec!["limit", "slip"],
);
});
}
#[rstest]
fn test_python_fill_model_handle_calls_python_methods() {
Python::initialize();
Python::attach(|py| {
let model = py
.eval(
c_str!(
"type('CustomFillModel', (), {\
'is_limit_filled': lambda self: False, \
'is_slipped': lambda self: True, \
'fill_limit_inside_spread': lambda self: True\
})()"
),
None,
None,
)
.unwrap();
let mut handle = pyobject_to_fill_model_handle(&model).unwrap();
assert!(!handle.is_limit_filled().unwrap());
assert!(handle.is_slipped().unwrap());
assert!(handle.fill_limit_inside_spread().unwrap());
});
}
#[rstest]
fn test_python_fill_model_handle_calls_python_liquidity_method(
#[values(None, Some(Price::from("0.80000")))] best_bid: Option<Price>,
#[values(None, Some(Price::from("0.80010")))] best_ask: Option<Price>,
#[values(false, true)] returns_book: bool,
) {
Python::initialize();
Python::attach(|py| {
let instrument = InstrumentAny::CurrencyPair(audusd_sim());
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let model = py
.eval(
c_str!(
"type('CustomFillModel', (), {\
'is_limit_filled': lambda self: True, \
'is_slipped': lambda self: False, \
'quotes': [], \
'get_orderbook_for_fill_simulation': \
lambda self, instrument, order, best_bid, best_ask: \
self.quotes.append((best_bid, best_ask)) or self.book\
})()"
),
None,
None,
)
.unwrap();
let mut expected = OrderBook::new(instrument.id(), BookType::L2_MBP);
expected.add(
BookOrder::new(
OrderSide::Buy,
Price::from("0.71234"),
Quantity::from(12),
17,
),
0,
23,
UnixNanos::from(29),
);
model
.setattr(
"book",
if returns_book {
Some(Py::new(py, expected.clone()).unwrap())
} else {
None
},
)
.unwrap();
let mut handle = pyobject_to_fill_model_handle(&model).unwrap();
let book = handle
.get_orderbook_for_fill_simulation(&instrument, &order, best_bid, best_ask)
.unwrap();
if returns_book {
let book = book.unwrap();
assert_eq!(book.instrument_id, expected.instrument_id);
assert_eq!(book.book_type, expected.book_type);
assert_eq!(book.sequence, 23);
assert_eq!(book.ts_last, UnixNanos::from(29));
assert_eq!(book.update_count, 1);
assert_eq!(book.bids_as_map(None), expected.bids_as_map(None));
assert_eq!(book.asks_as_map(None), expected.asks_as_map(None));
} else {
assert!(book.is_none());
}
let quotes: Vec<(Option<Price>, Option<Price>)> =
model.getattr("quotes").unwrap().extract().unwrap();
assert_eq!(quotes, vec![(best_bid, best_ask)]);
});
}
#[rstest]
fn test_python_fill_model_handle_uses_defaults_for_missing_optional_methods() {
Python::initialize();
Python::attach(|py| {
let instrument = InstrumentAny::CurrencyPair(audusd_sim());
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let model = py
.eval(
c_str!(
"type('CustomFillModel', (), {\
'is_limit_filled': lambda self: True, \
'is_slipped': lambda self: False\
})()"
),
None,
None,
)
.unwrap();
let mut handle = pyobject_to_fill_model_handle(&model).unwrap();
let book = handle
.get_orderbook_for_fill_simulation(
&instrument,
&order,
Some(Price::from("0.80000")),
Some(Price::from("0.80010")),
)
.unwrap();
assert!(!handle.fill_limit_inside_spread().unwrap());
assert!(book.is_none());
});
}
#[rstest]
#[case("is_limit_filled")]
#[case("is_slipped")]
#[case("fill_limit_inside_spread")]
#[case("get_orderbook_for_fill_simulation")]
fn test_python_fill_model_any_propagates_callback_errors(#[case] method: &str) {
Python::initialize();
Python::attach(|py| {
let model = py
.eval(
c_str!(
"type('CustomFillModel', (), {\
'is_limit_filled': lambda self: True, \
'is_slipped': lambda self: False\
})()"
),
None,
None,
)
.unwrap();
let callback = py
.eval(
c_str!("lambda *args: (_ for _ in ()).throw(ValueError('boom'))"),
None,
None,
)
.unwrap();
model.setattr(method, callback).unwrap();
let mut model = pyobject_to_fill_model_any(&model).unwrap();
let error = match method {
"is_limit_filled" => model.is_limit_filled().unwrap_err(),
"is_slipped" => model.is_slipped().unwrap_err(),
"fill_limit_inside_spread" => model.fill_limit_inside_spread().unwrap_err(),
"get_orderbook_for_fill_simulation" => {
let instrument = InstrumentAny::CurrencyPair(audusd_sim());
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
model
.get_orderbook_for_fill_simulation(&instrument, &order, None, None)
.unwrap_err()
}
_ => unreachable!(),
};
assert_eq!(
error.to_string(),
format!("Python FillModel.{method} failed: ValueError: boom")
);
});
}
#[rstest]
fn test_python_fill_model_handle_propagates_python_error() {
Python::initialize();
Python::attach(|py| {
let model = py
.eval(
c_str!(
"type('CustomFillModel', (), {\
'is_limit_filled': lambda self: \
(_ for _ in ()).throw(RuntimeError('boom')), \
'is_slipped': lambda self: False\
})()"
),
None,
None,
)
.unwrap();
let mut handle = pyobject_to_fill_model_handle(&model).unwrap();
let error = handle.is_limit_filled().unwrap_err().to_string();
assert!(error.contains("Python FillModel.is_limit_filled failed"));
assert!(error.contains("boom"));
});
}
}