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 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    /// Creates a new `FixedFeeModel` instance.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error if `commission` is negative.
269    #[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    /// Creates a new `MakerTakerFeeModel` with explicit default rates.
321    ///
322    /// Negative maker rates represent rebates where the venue supports them.
323    /// Use `Self.set_override` for exact per-instrument rates.
324    #[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    /// Creates a new `PerContractFeeModel` instance.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if `commission` is negative.
370    #[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    /// Fee model for probability-priced outcome shares.
400    ///
401    /// Applies `qty * fee_rate * p * (1 - p)` using the account-owned maker or
402    /// taker fee rate. This matches venues that represent outcome shares as
403    /// `InstrumentAny.BinaryOption` instruments quoted on a `[0, 1]`
404    /// probability scale.
405    ///
406    /// This model covers quote-currency match-time exchange fees only.
407    /// Venue-specific rebate programs or non-quote fee assets remain outside the
408    /// core execution layer.
409    #[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    /// Creates a new `CappedOptionFeeModel` instance with explicit rates.
451    ///
452    /// # Errors
453    ///
454    /// Returns an error if any supplied rate is negative.
455    #[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    /// Creates a new `TieredNotionalOptionFeeModel` instance with explicit rates.
520    ///
521    /// # Errors
522    ///
523    /// Returns an error if any supplied rate is negative.
524    #[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
560/// Extracts a Python fee model object into a Rust [`FeeModelAny`].
561///
562/// # Errors
563///
564/// Returns an error if `obj` is neither a supported built-in model nor a Python object with
565/// a `get_commission` method.
566pub 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
603/// Extracts a Python fee model object into a runtime [`FeeModelHandle`].
604///
605/// # Errors
606///
607/// Returns an error if `obj` is neither a supported built-in model nor a Python object with
608/// a `get_commission` method.
609pub 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
620/// Converts a Rust [`FeeModelAny`] into its Python binding object.
621///
622/// # Errors
623///
624/// Returns an error if conversion to a Python object fails.
625pub 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}