Skip to main content

nautilus_execution/models/
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
16use std::fmt::Debug;
17
18use nautilus_model::{
19    enums::LiquiditySide,
20    instruments::{Instrument, InstrumentAny},
21    orders::{Order, OrderAny},
22    types::{Currency, Money, Price, Quantity},
23};
24use rust_decimal::Decimal;
25use rust_decimal_macros::dec;
26
27pub trait FeeModel {
28    /// Calculates commission for a fill.
29    ///
30    /// # Errors
31    ///
32    /// Returns an error if commission calculation fails.
33    fn get_commission(
34        &self,
35        order: &OrderAny,
36        fill_quantity: Quantity,
37        fill_px: Price,
38        instrument: &InstrumentAny,
39    ) -> anyhow::Result<Money>;
40
41    /// Calculates commission for a fill with additional pricing context.
42    ///
43    /// # Errors
44    ///
45    /// Returns an error if commission calculation fails.
46    fn get_commission_with_context(
47        &self,
48        order: &OrderAny,
49        fill_quantity: Quantity,
50        fill_px: Price,
51        instrument: &InstrumentAny,
52        _underlying_px: Option<Price>,
53    ) -> anyhow::Result<Money> {
54        self.get_commission(order, fill_quantity, fill_px, instrument)
55    }
56}
57
58#[derive(Clone, Debug)]
59pub enum FeeModelAny {
60    Fixed(FixedFeeModel),
61    MakerTaker(MakerTakerFeeModel),
62    PerContract(PerContractFeeModel),
63    CappedOption(CappedOptionFeeModel),
64    TieredNotionalOption(TieredNotionalOptionFeeModel),
65}
66
67impl FeeModel for FeeModelAny {
68    fn get_commission(
69        &self,
70        order: &OrderAny,
71        fill_quantity: Quantity,
72        fill_px: Price,
73        instrument: &InstrumentAny,
74    ) -> anyhow::Result<Money> {
75        match self {
76            Self::Fixed(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
77            Self::MakerTaker(model) => {
78                model.get_commission(order, fill_quantity, fill_px, instrument)
79            }
80            Self::PerContract(model) => {
81                model.get_commission(order, fill_quantity, fill_px, instrument)
82            }
83            Self::CappedOption(model) => {
84                model.get_commission(order, fill_quantity, fill_px, instrument)
85            }
86            Self::TieredNotionalOption(model) => {
87                model.get_commission(order, fill_quantity, fill_px, instrument)
88            }
89        }
90    }
91
92    fn get_commission_with_context(
93        &self,
94        order: &OrderAny,
95        fill_quantity: Quantity,
96        fill_px: Price,
97        instrument: &InstrumentAny,
98        underlying_px: Option<Price>,
99    ) -> anyhow::Result<Money> {
100        match self {
101            Self::Fixed(model) => model.get_commission_with_context(
102                order,
103                fill_quantity,
104                fill_px,
105                instrument,
106                underlying_px,
107            ),
108            Self::MakerTaker(model) => model.get_commission_with_context(
109                order,
110                fill_quantity,
111                fill_px,
112                instrument,
113                underlying_px,
114            ),
115            Self::PerContract(model) => model.get_commission_with_context(
116                order,
117                fill_quantity,
118                fill_px,
119                instrument,
120                underlying_px,
121            ),
122            Self::CappedOption(model) => model.get_commission_with_context(
123                order,
124                fill_quantity,
125                fill_px,
126                instrument,
127                underlying_px,
128            ),
129            Self::TieredNotionalOption(model) => model.get_commission_with_context(
130                order,
131                fill_quantity,
132                fill_px,
133                instrument,
134                underlying_px,
135            ),
136        }
137    }
138}
139
140impl Default for FeeModelAny {
141    fn default() -> Self {
142        Self::MakerTaker(MakerTakerFeeModel)
143    }
144}
145
146#[derive(Debug, Clone)]
147#[cfg_attr(
148    feature = "python",
149    pyo3::pyclass(
150        module = "nautilus_trader.core.nautilus_pyo3.execution",
151        from_py_object
152    )
153)]
154#[cfg_attr(
155    feature = "python",
156    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
157)]
158pub struct FixedFeeModel {
159    commission: Money,
160    zero_commission: Money,
161    change_commission_once: bool,
162}
163
164impl FixedFeeModel {
165    /// Creates a new [`FixedFeeModel`] instance.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if `commission` is negative.
170    pub fn new(commission: Money, change_commission_once: Option<bool>) -> anyhow::Result<Self> {
171        if commission.raw < 0 {
172            anyhow::bail!("Commission must be greater than or equal to zero")
173        }
174        let zero_commission = Money::zero(commission.currency);
175        Ok(Self {
176            commission,
177            zero_commission,
178            change_commission_once: change_commission_once.unwrap_or(true),
179        })
180    }
181}
182
183impl FeeModel for FixedFeeModel {
184    fn get_commission(
185        &self,
186        order: &OrderAny,
187        _fill_quantity: Quantity,
188        _fill_px: Price,
189        _instrument: &InstrumentAny,
190    ) -> anyhow::Result<Money> {
191        if !self.change_commission_once || order.filled_qty().is_zero() {
192            Ok(self.commission)
193        } else {
194            Ok(self.zero_commission)
195        }
196    }
197}
198
199#[derive(Debug, Clone)]
200#[cfg_attr(
201    feature = "python",
202    pyo3::pyclass(
203        module = "nautilus_trader.core.nautilus_pyo3.execution",
204        from_py_object
205    )
206)]
207#[cfg_attr(
208    feature = "python",
209    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
210)]
211pub struct PerContractFeeModel {
212    commission: Money,
213}
214
215impl PerContractFeeModel {
216    /// Creates a new [`PerContractFeeModel`] instance.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if `commission` is negative.
221    pub fn new(commission: Money) -> anyhow::Result<Self> {
222        if commission.raw < 0 {
223            anyhow::bail!("Commission must be greater than or equal to zero")
224        }
225        Ok(Self { commission })
226    }
227}
228
229impl FeeModel for PerContractFeeModel {
230    fn get_commission(
231        &self,
232        _order: &OrderAny,
233        fill_quantity: Quantity,
234        _fill_px: Price,
235        _instrument: &InstrumentAny,
236    ) -> anyhow::Result<Money> {
237        let total = self.commission.as_decimal() * fill_quantity.as_decimal();
238        Money::from_decimal(total, self.commission.currency).map_err(Into::into)
239    }
240}
241
242#[derive(Debug, Clone)]
243#[cfg_attr(
244    feature = "python",
245    pyo3::pyclass(
246        module = "nautilus_trader.core.nautilus_pyo3.execution",
247        from_py_object
248    )
249)]
250#[cfg_attr(
251    feature = "python",
252    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
253)]
254pub struct MakerTakerFeeModel;
255
256impl FeeModel for MakerTakerFeeModel {
257    fn get_commission(
258        &self,
259        order: &OrderAny,
260        fill_quantity: Quantity,
261        fill_px: Price,
262        instrument: &InstrumentAny,
263    ) -> anyhow::Result<Money> {
264        let notional = instrument.calculate_notional_value(fill_quantity, fill_px, Some(false));
265        let commission = match order.liquidity_side() {
266            Some(LiquiditySide::Maker) => notional * instrument.maker_fee(),
267            Some(LiquiditySide::Taker) => notional * instrument.taker_fee(),
268            Some(LiquiditySide::NoLiquiditySide) | None => anyhow::bail!("Liquidity side not set"),
269        };
270
271        if instrument.is_inverse() {
272            Money::from_decimal(commission, instrument.base_currency().unwrap()).map_err(Into::into)
273        } else {
274            Money::from_decimal(commission, instrument.quote_currency()).map_err(Into::into)
275        }
276    }
277}
278
279#[derive(Clone)]
280#[cfg_attr(
281    feature = "python",
282    pyo3::pyclass(
283        module = "nautilus_trader.core.nautilus_pyo3.execution",
284        from_py_object
285    )
286)]
287#[cfg_attr(
288    feature = "python",
289    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
290)]
291pub struct CappedOptionFeeModel {
292    maker_rate: Option<Decimal>,
293    taker_rate: Option<Decimal>,
294    cap: Decimal,
295}
296
297impl Debug for CappedOptionFeeModel {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        f.debug_struct(stringify!(CappedOptionFeeModel))
300            .field("maker_rate", &self.maker_rate)
301            .field("taker_rate", &self.taker_rate)
302            .field("cap_rate", &self.cap)
303            .finish()
304    }
305}
306
307impl CappedOptionFeeModel {
308    /// Creates a new [`CappedOptionFeeModel`] instance.
309    ///
310    /// # Errors
311    ///
312    /// Returns an error if any supplied rate is negative.
313    pub fn new(
314        maker_rate: Option<Decimal>,
315        taker_rate: Option<Decimal>,
316        cap_rate: Option<Decimal>,
317    ) -> anyhow::Result<Self> {
318        check_fee_rate(maker_rate, "maker_rate")?;
319        check_fee_rate(taker_rate, "taker_rate")?;
320
321        let cap_rate = cap_rate.unwrap_or(dec!(0.125));
322        check_fee_rate(Some(cap_rate), "cap_rate")?;
323
324        Ok(Self {
325            maker_rate,
326            taker_rate,
327            cap: cap_rate,
328        })
329    }
330}
331
332impl Default for CappedOptionFeeModel {
333    fn default() -> Self {
334        Self::new(None, None, None).unwrap()
335    }
336}
337
338impl FeeModel for CappedOptionFeeModel {
339    fn get_commission(
340        &self,
341        order: &OrderAny,
342        fill_quantity: Quantity,
343        fill_px: Price,
344        instrument: &InstrumentAny,
345    ) -> anyhow::Result<Money> {
346        self.get_commission_with_context(order, fill_quantity, fill_px, instrument, None)
347    }
348
349    fn get_commission_with_context(
350        &self,
351        order: &OrderAny,
352        fill_quantity: Quantity,
353        fill_px: Price,
354        instrument: &InstrumentAny,
355        underlying_px: Option<Price>,
356    ) -> anyhow::Result<Money> {
357        check_option_instrument(instrument, "CappedOptionFeeModel")?;
358        let rate = option_fee_rate(order, instrument, self.maker_rate, self.taker_rate)?;
359        let multiplier = instrument.multiplier().as_decimal();
360        let rate_fee = if instrument.is_inverse() {
361            rate
362        } else {
363            let underlying_px =
364                underlying_px.ok_or_else(|| anyhow::anyhow!("Underlying price is required"))?;
365            rate * underlying_px.as_decimal()
366        };
367        let cap_fee = self.cap * fill_px.as_decimal();
368        let fee_per_contract = rate_fee.min(cap_fee) * multiplier;
369        let total = fee_per_contract * fill_quantity.as_decimal();
370        Money::from_decimal(total, commission_currency(instrument)).map_err(Into::into)
371    }
372}
373
374#[derive(Debug, Clone)]
375#[cfg_attr(
376    feature = "python",
377    pyo3::pyclass(
378        module = "nautilus_trader.core.nautilus_pyo3.execution",
379        from_py_object
380    )
381)]
382#[cfg_attr(
383    feature = "python",
384    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
385)]
386pub struct TieredNotionalOptionFeeModel {
387    maker_rate: Option<Decimal>,
388    taker_rate: Option<Decimal>,
389}
390
391impl TieredNotionalOptionFeeModel {
392    /// Creates a new [`TieredNotionalOptionFeeModel`] instance.
393    ///
394    /// # Errors
395    ///
396    /// Returns an error if any supplied rate is negative.
397    pub fn new(maker_rate: Option<Decimal>, taker_rate: Option<Decimal>) -> anyhow::Result<Self> {
398        check_fee_rate(maker_rate, "maker_rate")?;
399        check_fee_rate(taker_rate, "taker_rate")?;
400
401        Ok(Self {
402            maker_rate,
403            taker_rate,
404        })
405    }
406}
407
408impl Default for TieredNotionalOptionFeeModel {
409    fn default() -> Self {
410        Self::new(None, None).unwrap()
411    }
412}
413
414impl FeeModel for TieredNotionalOptionFeeModel {
415    fn get_commission(
416        &self,
417        order: &OrderAny,
418        fill_quantity: Quantity,
419        fill_px: Price,
420        instrument: &InstrumentAny,
421    ) -> anyhow::Result<Money> {
422        check_option_instrument(instrument, "TieredNotionalOptionFeeModel")?;
423        let rate = option_fee_rate(order, instrument, self.maker_rate, self.taker_rate)?;
424        let notional = instrument.calculate_notional_value(fill_quantity, fill_px, Some(false));
425        let total = notional.as_decimal() * rate;
426        Money::from_decimal(total, notional.currency).map_err(Into::into)
427    }
428}
429
430fn option_fee_rate(
431    order: &OrderAny,
432    instrument: &InstrumentAny,
433    maker_rate: Option<Decimal>,
434    taker_rate: Option<Decimal>,
435) -> anyhow::Result<Decimal> {
436    let rate = match order.liquidity_side() {
437        Some(LiquiditySide::Maker) => maker_rate.unwrap_or_else(|| instrument.maker_fee()),
438        Some(LiquiditySide::Taker) => taker_rate.unwrap_or_else(|| instrument.taker_fee()),
439        Some(LiquiditySide::NoLiquiditySide) | None => anyhow::bail!("Liquidity side not set"),
440    };
441    check_fee_rate(Some(rate), "fee_rate")?;
442    Ok(rate)
443}
444
445fn check_fee_rate(rate: Option<Decimal>, name: &str) -> anyhow::Result<()> {
446    if rate.is_some_and(|rate| rate < Decimal::ZERO) {
447        anyhow::bail!("`{name}` must be greater than or equal to zero");
448    }
449    Ok(())
450}
451
452fn check_option_instrument(instrument: &InstrumentAny, model_name: &str) -> anyhow::Result<()> {
453    if !matches!(
454        instrument,
455        InstrumentAny::CryptoOption(_) | InstrumentAny::OptionContract(_)
456    ) {
457        anyhow::bail!("{model_name} requires an option instrument");
458    }
459    Ok(())
460}
461
462fn commission_currency(instrument: &InstrumentAny) -> Currency {
463    if instrument.is_inverse() {
464        instrument.settlement_currency()
465    } else {
466        instrument.quote_currency()
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use nautilus_model::{
473        enums::{LiquiditySide, OrderSide, OrderType},
474        instruments::{
475            CryptoOption, Instrument, InstrumentAny, OptionContract,
476            stubs::{audusd_sim, crypto_option_btc_deribit, option_contract_appl},
477        },
478        orders::{
479            Order, OrderAny,
480            builder::OrderTestBuilder,
481            stubs::{TestOrderEventStubs, TestOrderStubs},
482        },
483        types::{Currency, Money, Price, Quantity},
484    };
485    use rstest::rstest;
486    use rust_decimal::Decimal;
487    use rust_decimal_macros::dec;
488
489    use super::{
490        CappedOptionFeeModel, FeeModel, FeeModelAny, FixedFeeModel, MakerTakerFeeModel,
491        PerContractFeeModel, TieredNotionalOptionFeeModel,
492    };
493
494    #[rstest]
495    fn test_fixed_model_single_fill() {
496        let expected_commission = Money::new(1.0, Currency::USD());
497        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
498        let fee_model = FixedFeeModel::new(expected_commission, None).unwrap();
499        let market_order = OrderTestBuilder::new(OrderType::Market)
500            .instrument_id(aud_usd.id())
501            .side(OrderSide::Buy)
502            .quantity(Quantity::from(100_000))
503            .build();
504        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
505        let commission = fee_model
506            .get_commission(
507                &accepted_order,
508                Quantity::from(100_000),
509                Price::from("1.0"),
510                &aud_usd,
511            )
512            .unwrap();
513        assert_eq!(commission, expected_commission);
514    }
515
516    #[rstest]
517    #[case(OrderSide::Buy, true, Money::from("1 USD"), Money::from("0 USD"))]
518    #[case(OrderSide::Sell, true, Money::from("1 USD"), Money::from("0 USD"))]
519    #[case(OrderSide::Buy, false, Money::from("1 USD"), Money::from("1 USD"))]
520    #[case(OrderSide::Sell, false, Money::from("1 USD"), Money::from("1 USD"))]
521    fn test_fixed_model_multiple_fills(
522        #[case] order_side: OrderSide,
523        #[case] charge_commission_once: bool,
524        #[case] expected_first_fill: Money,
525        #[case] expected_next_fill: Money,
526    ) {
527        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
528        let fee_model =
529            FixedFeeModel::new(expected_first_fill, Some(charge_commission_once)).unwrap();
530        let market_order = OrderTestBuilder::new(OrderType::Market)
531            .instrument_id(aud_usd.id())
532            .side(order_side)
533            .quantity(Quantity::from(100_000))
534            .build();
535        let mut accepted_order = TestOrderStubs::make_accepted_order(&market_order);
536        let commission_first_fill = fee_model
537            .get_commission(
538                &accepted_order,
539                Quantity::from(50_000),
540                Price::from("1.0"),
541                &aud_usd,
542            )
543            .unwrap();
544        let fill = TestOrderEventStubs::filled(
545            &accepted_order,
546            &aud_usd,
547            None,
548            None,
549            None,
550            Some(Quantity::from(50_000)),
551            None,
552            None,
553            None,
554            None,
555        );
556        accepted_order.apply(fill).unwrap();
557        let commission_next_fill = fee_model
558            .get_commission(
559                &accepted_order,
560                Quantity::from(50_000),
561                Price::from("1.0"),
562                &aud_usd,
563            )
564            .unwrap();
565        assert_eq!(commission_first_fill, expected_first_fill);
566        assert_eq!(commission_next_fill, expected_next_fill);
567    }
568
569    #[rstest]
570    fn test_maker_taker_fee_model_maker_commission() {
571        let fee_model = MakerTakerFeeModel;
572        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
573        let maker_fee = aud_usd.maker_fee();
574        let price = Price::from("1.0");
575        let limit_order = OrderTestBuilder::new(OrderType::Limit)
576            .instrument_id(aud_usd.id())
577            .side(OrderSide::Sell)
578            .price(price)
579            .quantity(Quantity::from(100_000))
580            .build();
581        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
582        let expected_commission = fill.quantity().as_decimal() * price.as_decimal() * maker_fee;
583        let commission = fee_model
584            .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
585            .unwrap();
586        assert_eq!(commission.as_decimal(), expected_commission);
587    }
588
589    #[rstest]
590    fn test_maker_taker_fee_model_uses_decimal_rounding() {
591        let fee_model = MakerTakerFeeModel;
592        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
593        let price = Price::from("1.0");
594        let quantity = Quantity::from("117250");
595        let limit_order = OrderTestBuilder::new(OrderType::Limit)
596            .instrument_id(aud_usd.id())
597            .side(OrderSide::Sell)
598            .price(price)
599            .quantity(quantity)
600            .build();
601        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
602
603        let commission = fee_model
604            .get_commission(&fill, quantity, price, &aud_usd)
605            .unwrap();
606
607        assert_eq!(commission, Money::from("2.34 USD"));
608    }
609
610    #[rstest]
611    fn test_maker_taker_fee_model_taker_commission() {
612        let fee_model = MakerTakerFeeModel;
613        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
614        let taker_fee = aud_usd.taker_fee();
615        let price = Price::from("1.0");
616        let limit_order = OrderTestBuilder::new(OrderType::Limit)
617            .instrument_id(aud_usd.id())
618            .side(OrderSide::Sell)
619            .price(price)
620            .quantity(Quantity::from(100_000))
621            .build();
622
623        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Taker);
624        let expected_commission = fill.quantity().as_decimal() * price.as_decimal() * taker_fee;
625        let commission = fee_model
626            .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
627            .unwrap();
628        assert_eq!(commission.as_decimal(), expected_commission);
629    }
630
631    #[rstest]
632    fn test_per_contract_fee_model() {
633        let commission_per_contract = Money::new(0.50, Currency::USD());
634        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
635        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
636        let market_order = OrderTestBuilder::new(OrderType::Market)
637            .instrument_id(aud_usd.id())
638            .side(OrderSide::Buy)
639            .quantity(Quantity::from(100))
640            .build();
641        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
642        let commission = fee_model
643            .get_commission(
644                &accepted_order,
645                Quantity::from(100),
646                Price::from("1.0"),
647                &aud_usd,
648            )
649            .unwrap();
650        assert_eq!(commission, Money::new(50.0, Currency::USD()));
651    }
652
653    #[rstest]
654    fn test_per_contract_fee_model_partial_fill() {
655        let commission_per_contract = Money::new(1.25, Currency::USD());
656        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
657        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
658        let market_order = OrderTestBuilder::new(OrderType::Market)
659            .instrument_id(aud_usd.id())
660            .side(OrderSide::Sell)
661            .quantity(Quantity::from(1000))
662            .build();
663        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
664        let commission = fee_model
665            .get_commission(
666                &accepted_order,
667                Quantity::from(400),
668                Price::from("1.0"),
669                &aud_usd,
670            )
671            .unwrap();
672        assert_eq!(commission, Money::new(500.0, Currency::USD()));
673    }
674
675    #[rstest]
676    fn test_per_contract_fee_model_uses_decimal_rounding() {
677        let commission_per_contract = Money::from("0.50 USD");
678        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
679        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
680        let market_order = OrderTestBuilder::new(OrderType::Market)
681            .instrument_id(aud_usd.id())
682            .side(OrderSide::Buy)
683            .quantity(Quantity::from("5"))
684            .build();
685        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
686
687        let commission = fee_model
688            .get_commission(
689                &accepted_order,
690                Quantity::from("4.69"),
691                Price::from("1.0"),
692                &aud_usd,
693            )
694            .unwrap();
695
696        assert_eq!(commission, Money::from("2.34 USD"));
697    }
698
699    #[rstest]
700    fn test_per_contract_fee_model_negative_commission_fails() {
701        let result = PerContractFeeModel::new(Money::new(-1.0, Currency::USD()));
702        assert!(result.is_err());
703    }
704
705    #[rstest]
706    #[case::maker(Some(dec!(-0.0001)), Some(dec!(0.0003)), None, "maker_rate")]
707    #[case::taker(Some(dec!(0.0001)), Some(dec!(-0.0003)), None, "taker_rate")]
708    #[case::cap(Some(dec!(0.0001)), Some(dec!(0.0003)), Some(dec!(-0.125)), "cap_rate")]
709    fn test_capped_option_fee_model_negative_rate_fails(
710        #[case] maker_rate: Option<Decimal>,
711        #[case] taker_rate: Option<Decimal>,
712        #[case] cap_rate: Option<Decimal>,
713        #[case] expected_field: &str,
714    ) {
715        let result = CappedOptionFeeModel::new(maker_rate, taker_rate, cap_rate);
716
717        assert_eq!(
718            result.unwrap_err().to_string(),
719            format!("`{expected_field}` must be greater than or equal to zero")
720        );
721    }
722
723    #[rstest]
724    fn test_capped_option_fee_model_maker_commission_rate_bound(
725        crypto_option_btc_deribit: CryptoOption,
726    ) {
727        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
728        let fill = option_fill_order(&instrument, LiquiditySide::Maker);
729        let fee_model = FeeModelAny::CappedOption(
730            CappedOptionFeeModel::new(Some(dec!(0.0001)), Some(dec!(0.0003)), None).unwrap(),
731        );
732
733        let commission = fee_model
734            .get_commission_with_context(
735                &fill,
736                Quantity::from("2.0"),
737                Price::from("100.00"),
738                &instrument,
739                Some(Price::from("50000.00")),
740            )
741            .unwrap();
742
743        assert_eq!(commission.currency, Currency::USD());
744        assert_eq!(commission.as_decimal(), dec!(10.00));
745    }
746
747    #[rstest]
748    fn test_capped_option_fee_model_taker_commission_cap_bound(
749        crypto_option_btc_deribit: CryptoOption,
750    ) {
751        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
752        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
753        let fee_model =
754            CappedOptionFeeModel::new(Some(dec!(0.0001)), Some(dec!(0.0003)), None).unwrap();
755
756        let commission = fee_model
757            .get_commission_with_context(
758                &fill,
759                Quantity::from("2.0"),
760                Price::from("10.00"),
761                &instrument,
762                Some(Price::from("50000.00")),
763            )
764            .unwrap();
765
766        assert_eq!(commission.currency, Currency::USD());
767        assert_eq!(commission.as_decimal(), dec!(2.50));
768    }
769
770    #[rstest]
771    fn test_capped_option_fee_model_applies_contract_multiplier(
772        mut option_contract_appl: OptionContract,
773    ) {
774        option_contract_appl.multiplier = Quantity::from(100);
775        let instrument = InstrumentAny::OptionContract(option_contract_appl);
776        let fill = option_fill_order(&instrument, LiquiditySide::Maker);
777        let fee_model =
778            CappedOptionFeeModel::new(Some(dec!(0.0001)), Some(dec!(0.0003)), None).unwrap();
779
780        let commission = fee_model
781            .get_commission_with_context(
782                &fill,
783                Quantity::from("2"),
784                Price::from("2.00"),
785                &instrument,
786                Some(Price::from("150.00")),
787            )
788            .unwrap();
789
790        assert_eq!(commission.currency, Currency::USD());
791        assert_eq!(commission.as_decimal(), dec!(3.00));
792    }
793
794    #[rstest]
795    fn test_capped_option_fee_model_inverse_commission_uses_settlement_currency(
796        mut crypto_option_btc_deribit: CryptoOption,
797    ) {
798        crypto_option_btc_deribit.is_inverse = true;
799        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
800        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
801        let fee_model =
802            CappedOptionFeeModel::new(Some(dec!(0.0001)), Some(dec!(0.0003)), None).unwrap();
803
804        let commission = fee_model
805            .get_commission(
806                &fill,
807                Quantity::from("2.0"),
808                Price::from("0.010"),
809                &instrument,
810            )
811            .unwrap();
812
813        assert_eq!(commission.currency, Currency::BTC());
814        assert_eq!(commission.as_decimal(), dec!(0.0006));
815    }
816
817    #[rstest]
818    fn test_capped_option_fee_model_requires_underlying_price(
819        crypto_option_btc_deribit: CryptoOption,
820    ) {
821        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
822        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
823        let fee_model = CappedOptionFeeModel::default();
824
825        let result = fee_model.get_commission(
826            &fill,
827            Quantity::from("1.0"),
828            Price::from("10.00"),
829            &instrument,
830        );
831
832        assert!(result.is_err());
833    }
834
835    #[rstest]
836    fn test_capped_option_fee_model_rejects_non_option_instrument() {
837        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
838        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
839        let fee_model = CappedOptionFeeModel::default();
840
841        let result = fee_model.get_commission_with_context(
842            &fill,
843            Quantity::from("1.0"),
844            Price::from("10.00"),
845            &instrument,
846            Some(Price::from("50000.00")),
847        );
848
849        assert!(result.is_err());
850    }
851
852    #[rstest]
853    #[case::maker(LiquiditySide::Maker, dec!(0.04))]
854    #[case::taker(LiquiditySide::Taker, dec!(0.10))]
855    fn test_tiered_notional_option_fee_model_commission(
856        crypto_option_btc_deribit: CryptoOption,
857        #[case] liquidity_side: LiquiditySide,
858        #[case] expected_commission: Decimal,
859    ) {
860        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
861        let fill = option_fill_order(&instrument, liquidity_side);
862        let fee_model = FeeModelAny::TieredNotionalOption(
863            TieredNotionalOptionFeeModel::new(Some(dec!(0.0002)), Some(dec!(0.0005))).unwrap(),
864        );
865
866        let commission = fee_model
867            .get_commission(
868                &fill,
869                Quantity::from("2.0"),
870                Price::from("100.00"),
871                &instrument,
872            )
873            .unwrap();
874
875        assert_eq!(commission.currency, Currency::USD());
876        assert_eq!(commission.as_decimal(), expected_commission);
877    }
878
879    #[rstest]
880    fn test_tiered_notional_option_fee_model_inverse_commission_uses_base_currency(
881        mut crypto_option_btc_deribit: CryptoOption,
882    ) {
883        crypto_option_btc_deribit.is_inverse = true;
884        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
885        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
886        let fee_model =
887            TieredNotionalOptionFeeModel::new(Some(dec!(0.0002)), Some(dec!(0.0005))).unwrap();
888
889        let commission = fee_model
890            .get_commission(
891                &fill,
892                Quantity::from("2.0"),
893                Price::from("0.010"),
894                &instrument,
895            )
896            .unwrap();
897
898        assert_eq!(commission.currency, Currency::BTC());
899        assert_eq!(commission.as_decimal(), dec!(0.10));
900    }
901
902    #[rstest]
903    fn test_tiered_notional_option_fee_model_rejects_non_option_instrument() {
904        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
905        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
906        let fee_model = TieredNotionalOptionFeeModel::default();
907
908        let result = fee_model.get_commission(
909            &fill,
910            Quantity::from("1.0"),
911            Price::from("10.00"),
912            &instrument,
913        );
914
915        assert!(result.is_err());
916    }
917
918    #[rstest]
919    #[case::maker(Some(dec!(-0.0002)), Some(dec!(0.0005)), "maker_rate")]
920    #[case::taker(Some(dec!(0.0002)), Some(dec!(-0.0005)), "taker_rate")]
921    fn test_tiered_notional_option_fee_model_negative_rate_fails(
922        #[case] maker_rate: Option<Decimal>,
923        #[case] taker_rate: Option<Decimal>,
924        #[case] expected_field: &str,
925    ) {
926        let result = TieredNotionalOptionFeeModel::new(maker_rate, taker_rate);
927
928        assert_eq!(
929            result.unwrap_err().to_string(),
930            format!("`{expected_field}` must be greater than or equal to zero")
931        );
932    }
933
934    #[rstest]
935    fn test_tiered_notional_option_fee_model_requires_liquidity_side(
936        crypto_option_btc_deribit: CryptoOption,
937    ) {
938        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
939        let order = OrderTestBuilder::new(OrderType::Limit)
940            .instrument_id(instrument.id())
941            .side(OrderSide::Buy)
942            .price(Price::from("100.00"))
943            .quantity(Quantity::from("2.0"))
944            .build();
945        let fee_model = TieredNotionalOptionFeeModel::default();
946
947        let result = fee_model.get_commission(
948            &order,
949            Quantity::from("1.0"),
950            Price::from("10.00"),
951            &instrument,
952        );
953
954        assert!(result.is_err());
955    }
956
957    fn option_fill_order(instrument: &InstrumentAny, liquidity_side: LiquiditySide) -> OrderAny {
958        let limit_order = OrderTestBuilder::new(OrderType::Limit)
959            .instrument_id(instrument.id())
960            .side(OrderSide::Buy)
961            .price(Price::from("100.00"))
962            .quantity(Quantity::from("2.0"))
963            .build();
964
965        TestOrderStubs::make_filled_order(&limit_order, instrument, liquidity_side)
966    }
967}