1use std::collections::HashMap;
19
20use nautilus_core::python::{
21 clone_py_object, to_pynotimplemented_err, to_pyruntime_err, to_pytype_err,
22};
23use nautilus_model::{
24 fees::MakerTakerFeeRates,
25 identifiers::InstrumentId,
26 instruments::InstrumentAny,
27 orders::OrderAny,
28 python::{
29 instruments::{instrument_any_to_pyobject, pyobject_to_instrument_any},
30 orders::{order_any_to_pyobject, pyobject_to_order_any},
31 },
32 types::{Money, Price, Quantity},
33};
34use pyo3::{
35 IntoPyObject, PyClass,
36 prelude::*,
37 types::{PyDict, PyTuple},
38};
39use rust_decimal::Decimal;
40
41use crate::models::fee::{
42 CappedOptionFeeModel, FeeModel, FeeModelAny, FeeModelHandle, FixedFeeModel, MakerTakerFeeModel,
43 PerContractFeeModel, ProbabilityPriceFeeModel, TieredNotionalOptionFeeModel,
44};
45
46#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")]
47#[pyclass(
48 module = "nautilus_trader.execution",
49 name = "FeeModel",
50 subclass,
51 unsendable
52)]
53#[derive(Debug)]
54pub struct PyFeeModel;
55
56#[pyo3_stub_gen::derive::gen_stub_pymethods]
57#[pymethods]
58impl PyFeeModel {
59 #[new]
60 #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
61 #[pyo3(signature = (*_args, **_kwargs))]
62 fn py_new(_args: &Bound<'_, PyTuple>, _kwargs: Option<&Bound<'_, PyDict>>) -> Self {
63 Self
64 }
65
66 fn get_commission(
67 &self,
68 _order: &Bound<'_, PyAny>,
69 _fill_quantity: Quantity,
70 _fill_px: Price,
71 _instrument: &Bound<'_, PyAny>,
72 ) -> PyResult<Money> {
73 Err(to_pynotimplemented_err(
74 "Method 'get_commission' must be implemented in a subclass.",
75 ))
76 }
77
78 #[pyo3(signature = (order, fill_quantity, fill_px, instrument, _underlying_px = None))]
79 fn get_commission_with_context(
80 slf: PyRef<'_, Self>,
81 order: &Bound<'_, PyAny>,
82 fill_quantity: Quantity,
83 fill_px: Price,
84 instrument: &Bound<'_, PyAny>,
85 _underlying_px: Option<Price>,
86 ) -> PyResult<Money> {
87 let py = slf.py();
88 let obj = match slf.into_pyobject(py) {
89 Ok(obj) => obj,
90 Err(e) => match e {},
91 };
92 obj.as_any()
93 .call_method1(
94 "get_commission",
95 (order.clone(), fill_quantity, fill_px, instrument.clone()),
96 )?
97 .extract()
98 .map_err(to_pyruntime_err)
99 }
100}
101
102fn fee_args_to_any(
103 py: Python<'_>,
104 order: &Bound<'_, PyAny>,
105 instrument: &Bound<'_, PyAny>,
106) -> PyResult<(OrderAny, InstrumentAny)> {
107 let instrument_any =
108 pyobject_to_instrument_any(py, instrument.clone().unbind()).map_err(|_| {
109 let type_name = instrument
110 .get_type()
111 .name()
112 .map_or_else(|_| "unknown".to_string(), |name| name.to_string());
113 to_pytype_err(format!(
114 "`instrument` must be an `Instrument`, was `{type_name}`"
115 ))
116 })?;
117 let order_any = pyobject_to_order_any(py, order.clone().unbind()).map_err(|_| {
118 let type_name = order
119 .get_type()
120 .name()
121 .map_or_else(|_| "unknown".to_string(), |name| name.to_string());
122 to_pytype_err(format!("`order` must be an `Order`, was `{type_name}`"))
123 })?;
124 Ok((order_any, instrument_any))
125}
126
127fn call_fee_get_commission<M: FeeModel>(
128 model: &M,
129 py: Python<'_>,
130 order: &Bound<'_, PyAny>,
131 fill_quantity: Quantity,
132 fill_px: Price,
133 instrument: &Bound<'_, PyAny>,
134) -> PyResult<Money> {
135 let (order_any, instrument_any) = fee_args_to_any(py, order, instrument)?;
136 model
137 .get_commission(&order_any, fill_quantity, fill_px, &instrument_any)
138 .map_err(to_pyruntime_err)
139}
140
141fn call_fee_get_commission_with_context<M: FeeModel>(
142 model: &M,
143 py: Python<'_>,
144 order: &Bound<'_, PyAny>,
145 fill_quantity: Quantity,
146 fill_px: Price,
147 instrument: &Bound<'_, PyAny>,
148 underlying_px: Option<Price>,
149) -> PyResult<Money> {
150 let (order_any, instrument_any) = fee_args_to_any(py, order, instrument)?;
151 model
152 .get_commission_with_context(
153 &order_any,
154 fill_quantity,
155 fill_px,
156 &instrument_any,
157 underlying_px,
158 )
159 .map_err(to_pyruntime_err)
160}
161
162#[derive(Debug)]
163pub struct PythonFeeModel {
164 obj: Py<PyAny>,
165}
166
167impl Clone for PythonFeeModel {
168 fn clone(&self) -> Self {
169 Self::new(clone_py_object(&self.obj))
170 }
171}
172
173impl PythonFeeModel {
174 pub fn new(obj: Py<PyAny>) -> Self {
175 Self { obj }
176 }
177}
178
179impl FeeModel for PythonFeeModel {
180 fn get_commission(
181 &self,
182 order: &OrderAny,
183 fill_quantity: Quantity,
184 fill_px: Price,
185 instrument: &InstrumentAny,
186 ) -> anyhow::Result<Money> {
187 Python::attach(|py| -> anyhow::Result<Money> {
188 let order = order_any_to_pyobject(py, order.clone())?;
189 let instrument = instrument_any_to_pyobject(py, instrument.clone())?;
190 self.obj
191 .bind(py)
192 .call_method1(
193 "get_commission",
194 (order, fill_quantity, fill_px, instrument),
195 )?
196 .extract()
197 .map_err(|e| anyhow::anyhow!("{e}"))
198 })
199 .map_err(|e| anyhow::anyhow!("Python FeeModel.get_commission failed: {e}"))
200 }
201
202 fn get_commission_with_context(
203 &self,
204 order: &OrderAny,
205 fill_quantity: Quantity,
206 fill_px: Price,
207 instrument: &InstrumentAny,
208 underlying_px: Option<Price>,
209 ) -> anyhow::Result<Money> {
210 Python::attach(|py| -> anyhow::Result<Money> {
211 let obj = self.obj.bind(py);
212 if !has_method_override_before_base(py, obj, "get_commission_with_context")? {
213 let order = order_any_to_pyobject(py, order.clone())?;
214 let instrument = instrument_any_to_pyobject(py, instrument.clone())?;
215 return obj
216 .call_method1(
217 "get_commission",
218 (order, fill_quantity, fill_px, instrument),
219 )?
220 .extract()
221 .map_err(|e| anyhow::anyhow!("{e}"));
222 }
223
224 let order = order_any_to_pyobject(py, order.clone())?;
225 let instrument = instrument_any_to_pyobject(py, instrument.clone())?;
226 obj.call_method1(
227 "get_commission_with_context",
228 (order, fill_quantity, fill_px, instrument, underlying_px),
229 )?
230 .extract()
231 .map_err(|e| anyhow::anyhow!("{e}"))
232 })
233 .map_err(|e| anyhow::anyhow!("Python FeeModel.get_commission_with_context failed: {e}"))
234 }
235}
236
237fn has_method_override_before_base(
238 py: Python<'_>,
239 obj: &Bound<'_, PyAny>,
240 method_name: &str,
241) -> PyResult<bool> {
242 let base_type = py.get_type::<PyFeeModel>();
243 for cls in obj.get_type().getattr("__mro__")?.try_iter()? {
244 let cls = cls?;
245 if cls.is(base_type.as_any()) {
246 return Ok(false);
247 }
248
249 if cls.getattr("__dict__")?.contains(method_name)? {
250 return Ok(true);
251 }
252 }
253
254 Ok(false)
255}
256
257#[pyo3_stub_gen::derive::gen_stub_pymethods]
258#[pymethods]
259#[expect(
260 clippy::use_self,
261 reason = "`Self` breaks pyo3-stub-gen derive for subclass pyclasses"
262)]
263impl FixedFeeModel {
264 #[new]
270 #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
271 #[pyo3(signature = (commission, charge_commission_once=None, change_commission_once=None))]
272 fn py_new(
273 commission: Money,
274 charge_commission_once: Option<bool>,
275 change_commission_once: Option<bool>,
276 ) -> PyResult<PyClassInitializer<FixedFeeModel>> {
277 let charge_commission_once = resolve_fixed_fee_charge_commission_once(
278 charge_commission_once,
279 change_commission_once,
280 )?;
281 let model = Self::new(commission, charge_commission_once).map_err(to_pyruntime_err)?;
282 Ok(PyClassInitializer::from(PyFeeModel).add_subclass(model))
283 }
284
285 fn __repr__(&self) -> String {
286 format!("{self:?}")
287 }
288
289 fn get_commission(
290 &self,
291 order: &Bound<'_, PyAny>,
292 fill_quantity: Quantity,
293 fill_px: Price,
294 instrument: &Bound<'_, PyAny>,
295 ) -> PyResult<Money> {
296 call_fee_get_commission(self, order.py(), order, fill_quantity, fill_px, instrument)
297 }
298}
299
300fn resolve_fixed_fee_charge_commission_once(
301 charge_commission_once: Option<bool>,
302 change_commission_once: Option<bool>,
303) -> PyResult<Option<bool>> {
304 if charge_commission_once.is_some() && change_commission_once.is_some() {
305 return Err(to_pytype_err(
306 "Provide only one of `charge_commission_once` or `change_commission_once`",
307 ));
308 }
309
310 Ok(charge_commission_once.or(change_commission_once))
311}
312
313#[pyo3_stub_gen::derive::gen_stub_pymethods]
314#[pymethods]
315#[expect(
316 clippy::use_self,
317 reason = "`Self` breaks pyo3-stub-gen derive for subclass pyclasses"
318)]
319impl MakerTakerFeeModel {
320 #[new]
325 #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
326 #[pyo3(signature = (maker_rate, taker_rate, overrides=None))]
327 fn py_new(
328 maker_rate: Decimal,
329 taker_rate: Decimal,
330 overrides: Option<HashMap<InstrumentId, (Decimal, Decimal)>>,
331 ) -> PyClassInitializer<MakerTakerFeeModel> {
332 let mut model = MakerTakerFeeModel::new(maker_rate, taker_rate);
333
334 if let Some(overrides) = overrides {
335 for (instrument_id, (maker, taker)) in overrides {
336 model.set_override(instrument_id, MakerTakerFeeRates::new(maker, taker));
337 }
338 }
339
340 PyClassInitializer::from(PyFeeModel).add_subclass(model)
341 }
342
343 fn __repr__(&self) -> String {
344 format!("{self:?}")
345 }
346
347 fn get_commission(
348 &self,
349 order: &Bound<'_, PyAny>,
350 fill_quantity: Quantity,
351 fill_px: Price,
352 instrument: &Bound<'_, PyAny>,
353 ) -> PyResult<Money> {
354 call_fee_get_commission(self, order.py(), order, fill_quantity, fill_px, instrument)
355 }
356}
357
358#[pyo3_stub_gen::derive::gen_stub_pymethods]
359#[pymethods]
360#[expect(
361 clippy::use_self,
362 reason = "`Self` breaks pyo3-stub-gen derive for subclass pyclasses"
363)]
364impl PerContractFeeModel {
365 #[new]
371 #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
372 fn py_new(commission: Money) -> PyResult<PyClassInitializer<PerContractFeeModel>> {
373 let model = Self::new(commission).map_err(to_pyruntime_err)?;
374 Ok(PyClassInitializer::from(PyFeeModel).add_subclass(model))
375 }
376
377 fn __repr__(&self) -> String {
378 format!("{self:?}")
379 }
380
381 fn get_commission(
382 &self,
383 order: &Bound<'_, PyAny>,
384 fill_quantity: Quantity,
385 fill_px: Price,
386 instrument: &Bound<'_, PyAny>,
387 ) -> PyResult<Money> {
388 call_fee_get_commission(self, order.py(), order, fill_quantity, fill_px, instrument)
389 }
390}
391
392#[pyo3_stub_gen::derive::gen_stub_pymethods]
393#[pymethods]
394#[expect(
395 clippy::use_self,
396 reason = "`Self` breaks pyo3-stub-gen derive for subclass pyclasses"
397)]
398impl ProbabilityPriceFeeModel {
399 #[new]
410 #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
411 #[pyo3(signature = (maker_rate, taker_rate, overrides=None))]
412 fn py_new(
413 maker_rate: Decimal,
414 taker_rate: Decimal,
415 overrides: Option<HashMap<InstrumentId, (Decimal, Decimal)>>,
416 ) -> PyClassInitializer<ProbabilityPriceFeeModel> {
417 let mut model = ProbabilityPriceFeeModel::new(maker_rate, taker_rate);
418
419 if let Some(overrides) = overrides {
420 for (instrument_id, (maker, taker)) in overrides {
421 model.set_override(instrument_id, MakerTakerFeeRates::new(maker, taker));
422 }
423 }
424
425 PyClassInitializer::from(PyFeeModel).add_subclass(model)
426 }
427
428 fn __repr__(&self) -> String {
429 format!("{self:?}")
430 }
431
432 fn get_commission(
433 &self,
434 order: &Bound<'_, PyAny>,
435 fill_quantity: Quantity,
436 fill_px: Price,
437 instrument: &Bound<'_, PyAny>,
438 ) -> PyResult<Money> {
439 call_fee_get_commission(self, order.py(), order, fill_quantity, fill_px, instrument)
440 }
441}
442
443#[pyo3_stub_gen::derive::gen_stub_pymethods]
444#[pymethods]
445#[expect(
446 clippy::use_self,
447 reason = "`Self` breaks pyo3-stub-gen derive for subclass pyclasses"
448)]
449impl CappedOptionFeeModel {
450 #[new]
456 #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
457 #[pyo3(signature = (maker_rate, taker_rate, cap_rate=None, overrides=None))]
458 fn py_new(
459 maker_rate: Decimal,
460 taker_rate: Decimal,
461 cap_rate: Option<Decimal>,
462 overrides: Option<HashMap<InstrumentId, (Decimal, Decimal)>>,
463 ) -> PyResult<PyClassInitializer<CappedOptionFeeModel>> {
464 let mut model = Self::new(maker_rate, taker_rate, cap_rate).map_err(to_pyruntime_err)?;
465
466 if let Some(overrides) = overrides {
467 for (instrument_id, (maker, taker)) in overrides {
468 model
469 .set_override(instrument_id, MakerTakerFeeRates::new(maker, taker))
470 .map_err(to_pyruntime_err)?;
471 }
472 }
473
474 Ok(PyClassInitializer::from(PyFeeModel).add_subclass(model))
475 }
476
477 fn __repr__(&self) -> String {
478 format!("{self:?}")
479 }
480
481 fn get_commission(
482 &self,
483 order: &Bound<'_, PyAny>,
484 fill_quantity: Quantity,
485 fill_px: Price,
486 instrument: &Bound<'_, PyAny>,
487 ) -> PyResult<Money> {
488 call_fee_get_commission(self, order.py(), order, fill_quantity, fill_px, instrument)
489 }
490
491 #[pyo3(signature = (order, fill_quantity, fill_px, instrument, underlying_px = None))]
492 fn get_commission_with_context(
493 &self,
494 order: &Bound<'_, PyAny>,
495 fill_quantity: Quantity,
496 fill_px: Price,
497 instrument: &Bound<'_, PyAny>,
498 underlying_px: Option<Price>,
499 ) -> PyResult<Money> {
500 call_fee_get_commission_with_context(
501 self,
502 order.py(),
503 order,
504 fill_quantity,
505 fill_px,
506 instrument,
507 underlying_px,
508 )
509 }
510}
511
512#[pyo3_stub_gen::derive::gen_stub_pymethods]
513#[pymethods]
514#[expect(
515 clippy::use_self,
516 reason = "`Self` breaks pyo3-stub-gen derive for subclass pyclasses"
517)]
518impl TieredNotionalOptionFeeModel {
519 #[new]
525 #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
526 #[pyo3(signature = (maker_rate, taker_rate, overrides=None))]
527 fn py_new(
528 maker_rate: Decimal,
529 taker_rate: Decimal,
530 overrides: Option<HashMap<InstrumentId, (Decimal, Decimal)>>,
531 ) -> PyResult<PyClassInitializer<TieredNotionalOptionFeeModel>> {
532 let mut model = Self::new(maker_rate, taker_rate).map_err(to_pyruntime_err)?;
533
534 if let Some(overrides) = overrides {
535 for (instrument_id, (maker, taker)) in overrides {
536 model
537 .set_override(instrument_id, MakerTakerFeeRates::new(maker, taker))
538 .map_err(to_pyruntime_err)?;
539 }
540 }
541
542 Ok(PyClassInitializer::from(PyFeeModel).add_subclass(model))
543 }
544
545 fn __repr__(&self) -> String {
546 format!("{self:?}")
547 }
548
549 fn get_commission(
550 &self,
551 order: &Bound<'_, PyAny>,
552 fill_quantity: Quantity,
553 fill_px: Price,
554 instrument: &Bound<'_, PyAny>,
555 ) -> PyResult<Money> {
556 call_fee_get_commission(self, order.py(), order, fill_quantity, fill_px, instrument)
557 }
558}
559
560pub fn pyobject_to_fee_model_any(obj: &Bound<'_, PyAny>) -> PyResult<FeeModelAny> {
567 if let Ok(m) = obj.extract::<PyRef<'_, FixedFeeModel>>() {
568 return Ok(FeeModelAny::Fixed((*m).clone()));
569 }
570
571 if let Ok(m) = obj.extract::<PyRef<'_, MakerTakerFeeModel>>() {
572 return Ok(FeeModelAny::MakerTaker((*m).clone()));
573 }
574
575 if let Ok(m) = obj.extract::<PyRef<'_, PerContractFeeModel>>() {
576 return Ok(FeeModelAny::PerContract((*m).clone()));
577 }
578
579 if let Ok(m) = obj.extract::<PyRef<'_, ProbabilityPriceFeeModel>>() {
580 return Ok(FeeModelAny::ProbabilityPrice((*m).clone()));
581 }
582
583 if let Ok(m) = obj.extract::<PyRef<'_, CappedOptionFeeModel>>() {
584 return Ok(FeeModelAny::CappedOption((*m).clone()));
585 }
586
587 if let Ok(m) = obj.extract::<PyRef<'_, TieredNotionalOptionFeeModel>>() {
588 return Ok(FeeModelAny::TieredNotionalOption((*m).clone()));
589 }
590
591 if !obj.hasattr("get_commission")? {
592 let type_name = obj.get_type().name()?;
593 return Err(to_pytype_err(format!(
594 "Cannot convert {type_name} to FeeModel"
595 )));
596 }
597
598 Ok(FeeModelAny::Python(PythonFeeModel::new(
599 obj.clone().unbind(),
600 )))
601}
602
603pub fn pyobject_to_fee_model_handle(obj: &Bound<'_, PyAny>) -> PyResult<FeeModelHandle> {
610 pyobject_to_fee_model_any(obj).map(Into::into)
611}
612
613fn fee_model_into_py<T>(py: Python<'_>, model: T) -> PyResult<Py<PyAny>>
614where
615 T: PyClass<BaseType = PyFeeModel>,
616{
617 Ok(Py::new(py, PyClassInitializer::from(PyFeeModel).add_subclass(model))?.into_any())
618}
619
620pub fn fee_model_any_to_pyobject(py: Python<'_>, model: &FeeModelAny) -> PyResult<Py<PyAny>> {
626 match model {
627 FeeModelAny::Fixed(model) => fee_model_into_py(py, model.clone()),
628 FeeModelAny::MakerTaker(model) => fee_model_into_py(py, model.clone()),
629 FeeModelAny::PerContract(model) => fee_model_into_py(py, model.clone()),
630 FeeModelAny::ProbabilityPrice(model) => fee_model_into_py(py, model.clone()),
631 FeeModelAny::CappedOption(model) => fee_model_into_py(py, model.clone()),
632 FeeModelAny::TieredNotionalOption(model) => fee_model_into_py(py, model.clone()),
633 FeeModelAny::Python(model) => Ok(model.obj.clone_ref(py)),
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use nautilus_model::{
640 enums::{OrderSide, OrderType},
641 instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
642 orders::{OrderAny, builder::OrderTestBuilder},
643 };
644 use pyo3::{IntoPyObjectExt, ffi::c_str, types::PyDict};
645 use rstest::rstest;
646
647 use super::*;
648
649 #[rstest]
650 fn test_python_fee_model_handle_calls_python_method() {
651 Python::initialize();
652
653 Python::attach(|py| {
654 let expected_commission = Money::from("1.23 USD");
655 let model = fee_model_with_commission(py, expected_commission);
656
657 let handle = pyobject_to_fee_model_handle(&model).unwrap();
658 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
659 let order = OrderTestBuilder::new(OrderType::Market)
660 .instrument_id(instrument.id())
661 .side(OrderSide::Buy)
662 .quantity(Quantity::from(100_000))
663 .build();
664 let commission = handle
665 .get_commission(
666 &order,
667 Quantity::from(100_000),
668 Price::from("0.80000"),
669 &instrument,
670 )
671 .unwrap();
672
673 assert_eq!(commission, expected_commission);
674 });
675 }
676
677 #[rstest]
678 fn test_python_fee_model_any_clones_and_retains_python_model() {
679 Python::initialize();
680
681 Python::attach(|py| {
682 let expected_commission = Money::from("1.23 USD");
683 let model = fee_model_with_commission(py, expected_commission);
684 let fee_model = pyobject_to_fee_model_any(&model).unwrap();
685 let cloned_fee_model = fee_model.clone();
686 let original = fee_model_any_to_pyobject(py, &fee_model).unwrap();
687 let retained = fee_model_any_to_pyobject(py, &cloned_fee_model).unwrap();
688 let (instrument, order) = commission_inputs();
689 let commission = cloned_fee_model
690 .get_commission(
691 &order,
692 Quantity::from(100_000),
693 Price::from("0.80000"),
694 &instrument,
695 )
696 .unwrap();
697
698 assert!(original.bind(py).is(&model));
699 assert!(retained.bind(py).is(&model));
700 assert_eq!(commission, expected_commission);
701 });
702 }
703
704 #[rstest]
705 fn test_python_fee_model_any_rejects_object_without_get_commission() {
706 Python::initialize();
707
708 Python::attach(|py| {
709 let model = PyDict::new(py);
710 let error = pyobject_to_fee_model_any(model.as_any()).unwrap_err();
711
712 assert_eq!(
713 error.to_string(),
714 "TypeError: Cannot convert dict to FeeModel"
715 );
716 });
717 }
718
719 #[rstest]
720 fn test_python_fee_model_context_falls_back_to_get_commission() {
721 Python::initialize();
722
723 Python::attach(|py| {
724 let expected_commission = Money::from("1.23 USD");
725 let locals = PyDict::new(py);
726 locals
727 .set_item("FeeModel", py.get_type::<PyFeeModel>())
728 .unwrap();
729 let model = py
730 .eval(
731 c_str!(
732 "type('CustomFeeModel', (FeeModel,), {\
733 'get_commission': \
734 lambda self, order, fill_quantity, fill_px, instrument: self.commission\
735 })()"
736 ),
737 None,
738 Some(&locals),
739 )
740 .unwrap();
741 model
742 .setattr("commission", expected_commission.into_py_any(py).unwrap())
743 .unwrap();
744
745 let handle = pyobject_to_fee_model_handle(&model).unwrap();
746 let (instrument, order) = commission_inputs();
747 let commission = handle
748 .get_commission_with_context(
749 &order,
750 Quantity::from(100_000),
751 Price::from("0.80000"),
752 &instrument,
753 Some(Price::from("0.70000")),
754 )
755 .unwrap();
756
757 assert_eq!(commission, expected_commission);
758 });
759 }
760
761 #[rstest]
762 fn test_python_fee_model_context_calls_python_override() {
763 Python::initialize();
764
765 Python::attach(|py| {
766 let expected_commission = Money::from("2.34 USD");
767 let locals = PyDict::new(py);
768 locals
769 .set_item("FeeModel", py.get_type::<PyFeeModel>())
770 .unwrap();
771 let model = py
772 .eval(
773 c_str!(
774 "type('CustomFeeModel', (FeeModel,), {\
775 'get_commission': \
776 lambda self, order, fill_quantity, fill_px, instrument: self.base_commission, \
777 'get_commission_with_context': \
778 lambda self, order, fill_quantity, fill_px, instrument, underlying_px=None: self.context_commission\
779 })()"
780 ),
781 None,
782 Some(&locals),
783 )
784 .unwrap();
785 model
786 .setattr(
787 "base_commission",
788 Money::from("1.23 USD").into_py_any(py).unwrap(),
789 )
790 .unwrap();
791 model
792 .setattr(
793 "context_commission",
794 expected_commission.into_py_any(py).unwrap(),
795 )
796 .unwrap();
797
798 let handle = pyobject_to_fee_model_handle(&model).unwrap();
799 let (instrument, order) = commission_inputs();
800 let commission = handle
801 .get_commission_with_context(
802 &order,
803 Quantity::from(100_000),
804 Price::from("0.80000"),
805 &instrument,
806 Some(Price::from("0.70000")),
807 )
808 .unwrap();
809
810 assert_eq!(commission, expected_commission);
811 });
812 }
813
814 #[rstest]
815 fn test_python_fee_model_context_propagates_python_error() {
816 Python::initialize();
817
818 Python::attach(|py| {
819 let locals = PyDict::new(py);
820 locals
821 .set_item("FeeModel", py.get_type::<PyFeeModel>())
822 .unwrap();
823 let model = py
824 .eval(
825 c_str!(
826 "type('CustomFeeModel', (FeeModel,), {\
827 'get_commission': lambda self, order, fill_quantity, fill_px, instrument: \
828 (_ for _ in ()).throw(RuntimeError('boom'))\
829 })()"
830 ),
831 None,
832 Some(&locals),
833 )
834 .unwrap();
835
836 let handle = pyobject_to_fee_model_handle(&model).unwrap();
837 let (instrument, order) = commission_inputs();
838 let error = handle
839 .get_commission_with_context(
840 &order,
841 Quantity::from(100_000),
842 Price::from("0.80000"),
843 &instrument,
844 None,
845 )
846 .unwrap_err();
847 let error = error.to_string();
848
849 assert!(error.contains("Python FeeModel.get_commission_with_context failed"));
850 assert!(error.contains("boom"));
851 });
852 }
853
854 fn commission_inputs() -> (InstrumentAny, OrderAny) {
855 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
856 let order = OrderTestBuilder::new(OrderType::Market)
857 .instrument_id(instrument.id())
858 .side(OrderSide::Buy)
859 .quantity(Quantity::from(100_000))
860 .build();
861
862 (instrument, order)
863 }
864
865 fn fee_model_with_commission(py: Python<'_>, commission: Money) -> Bound<'_, PyAny> {
866 let locals = PyDict::new(py);
867 locals
868 .set_item("FeeModel", py.get_type::<PyFeeModel>())
869 .unwrap();
870 let model = py
871 .eval(
872 c_str!(
873 "type('CustomFeeModel', (FeeModel,), {\
874 'get_commission': \
875 lambda self, order, fill_quantity, fill_px, instrument: self.commission\
876 })()"
877 ),
878 None,
879 Some(&locals),
880 )
881 .unwrap();
882 model
883 .setattr("commission", commission.into_py_any(py).unwrap())
884 .unwrap();
885
886 model
887 }
888}