Skip to main content

nautilus_execution/python/
fee.rs

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