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, rc::Rc};
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/// Shared runtime handle for a fee model.
59#[derive(Clone)]
60pub struct FeeModelHandle(Rc<dyn FeeModel>);
61
62impl FeeModelHandle {
63    /// Creates a new [`FeeModelHandle`] from a fee model.
64    #[must_use]
65    pub fn new<T>(model: T) -> Self
66    where
67        T: FeeModel + 'static,
68    {
69        Self(Rc::new(model))
70    }
71
72    /// Creates a new [`FeeModelHandle`] from an existing reference-counted model.
73    #[must_use]
74    pub fn from_rc(model: Rc<dyn FeeModel>) -> Self {
75        Self(model)
76    }
77}
78
79impl Debug for FeeModelHandle {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_tuple(stringify!(FeeModelHandle))
82            .field(&"<dyn FeeModel>")
83            .finish()
84    }
85}
86
87impl FeeModel for FeeModelHandle {
88    fn get_commission(
89        &self,
90        order: &OrderAny,
91        fill_quantity: Quantity,
92        fill_px: Price,
93        instrument: &InstrumentAny,
94    ) -> anyhow::Result<Money> {
95        self.0
96            .get_commission(order, fill_quantity, fill_px, instrument)
97    }
98
99    fn get_commission_with_context(
100        &self,
101        order: &OrderAny,
102        fill_quantity: Quantity,
103        fill_px: Price,
104        instrument: &InstrumentAny,
105        underlying_px: Option<Price>,
106    ) -> anyhow::Result<Money> {
107        self.0
108            .get_commission_with_context(order, fill_quantity, fill_px, instrument, underlying_px)
109    }
110}
111
112impl Default for FeeModelHandle {
113    fn default() -> Self {
114        FeeModelAny::default().into()
115    }
116}
117
118impl From<FeeModelAny> for FeeModelHandle {
119    fn from(model: FeeModelAny) -> Self {
120        Self::new(model)
121    }
122}
123
124#[derive(Clone, Debug)]
125pub enum FeeModelAny {
126    Fixed(FixedFeeModel),
127    MakerTaker(MakerTakerFeeModel),
128    PerContract(PerContractFeeModel),
129    ProbabilityPrice(ProbabilityPriceFeeModel),
130    CappedOption(CappedOptionFeeModel),
131    TieredNotionalOption(TieredNotionalOptionFeeModel),
132}
133
134impl FeeModel for FeeModelAny {
135    fn get_commission(
136        &self,
137        order: &OrderAny,
138        fill_quantity: Quantity,
139        fill_px: Price,
140        instrument: &InstrumentAny,
141    ) -> anyhow::Result<Money> {
142        match self {
143            Self::Fixed(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
144            Self::MakerTaker(model) => {
145                model.get_commission(order, fill_quantity, fill_px, instrument)
146            }
147            Self::PerContract(model) => {
148                model.get_commission(order, fill_quantity, fill_px, instrument)
149            }
150            Self::ProbabilityPrice(model) => {
151                model.get_commission(order, fill_quantity, fill_px, instrument)
152            }
153            Self::CappedOption(model) => {
154                model.get_commission(order, fill_quantity, fill_px, instrument)
155            }
156            Self::TieredNotionalOption(model) => {
157                model.get_commission(order, fill_quantity, fill_px, instrument)
158            }
159        }
160    }
161
162    fn get_commission_with_context(
163        &self,
164        order: &OrderAny,
165        fill_quantity: Quantity,
166        fill_px: Price,
167        instrument: &InstrumentAny,
168        underlying_px: Option<Price>,
169    ) -> anyhow::Result<Money> {
170        match self {
171            Self::Fixed(model) => model.get_commission_with_context(
172                order,
173                fill_quantity,
174                fill_px,
175                instrument,
176                underlying_px,
177            ),
178            Self::MakerTaker(model) => model.get_commission_with_context(
179                order,
180                fill_quantity,
181                fill_px,
182                instrument,
183                underlying_px,
184            ),
185            Self::PerContract(model) => model.get_commission_with_context(
186                order,
187                fill_quantity,
188                fill_px,
189                instrument,
190                underlying_px,
191            ),
192            Self::ProbabilityPrice(model) => model.get_commission_with_context(
193                order,
194                fill_quantity,
195                fill_px,
196                instrument,
197                underlying_px,
198            ),
199            Self::CappedOption(model) => model.get_commission_with_context(
200                order,
201                fill_quantity,
202                fill_px,
203                instrument,
204                underlying_px,
205            ),
206            Self::TieredNotionalOption(model) => model.get_commission_with_context(
207                order,
208                fill_quantity,
209                fill_px,
210                instrument,
211                underlying_px,
212            ),
213        }
214    }
215}
216
217impl Default for FeeModelAny {
218    fn default() -> Self {
219        Self::MakerTaker(MakerTakerFeeModel)
220    }
221}
222
223#[derive(Debug, Clone)]
224#[cfg_attr(
225    feature = "python",
226    pyo3::pyclass(
227        module = "nautilus_trader.core.nautilus_pyo3.execution",
228        from_py_object
229    )
230)]
231#[cfg_attr(
232    feature = "python",
233    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
234)]
235pub struct FixedFeeModel {
236    commission: Money,
237    zero_commission: Money,
238    charge_commission_once: bool,
239}
240
241impl FixedFeeModel {
242    /// Creates a new [`FixedFeeModel`] instance.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if `commission` is negative.
247    pub fn new(commission: Money, charge_commission_once: Option<bool>) -> anyhow::Result<Self> {
248        if commission.raw < 0 {
249            anyhow::bail!("Commission must be greater than or equal to zero")
250        }
251        let zero_commission = Money::zero(commission.currency);
252        Ok(Self {
253            commission,
254            zero_commission,
255            charge_commission_once: charge_commission_once.unwrap_or(true),
256        })
257    }
258}
259
260impl FeeModel for FixedFeeModel {
261    fn get_commission(
262        &self,
263        order: &OrderAny,
264        _fill_quantity: Quantity,
265        _fill_px: Price,
266        _instrument: &InstrumentAny,
267    ) -> anyhow::Result<Money> {
268        if !self.charge_commission_once || order.filled_qty().is_zero() {
269            Ok(self.commission)
270        } else {
271            Ok(self.zero_commission)
272        }
273    }
274}
275
276#[derive(Debug, Clone)]
277#[cfg_attr(
278    feature = "python",
279    pyo3::pyclass(
280        module = "nautilus_trader.core.nautilus_pyo3.execution",
281        from_py_object
282    )
283)]
284#[cfg_attr(
285    feature = "python",
286    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
287)]
288pub struct PerContractFeeModel {
289    commission: Money,
290}
291
292impl PerContractFeeModel {
293    /// Creates a new [`PerContractFeeModel`] instance.
294    ///
295    /// # Errors
296    ///
297    /// Returns an error if `commission` is negative.
298    pub fn new(commission: Money) -> anyhow::Result<Self> {
299        if commission.raw < 0 {
300            anyhow::bail!("Commission must be greater than or equal to zero")
301        }
302        Ok(Self { commission })
303    }
304}
305
306impl FeeModel for PerContractFeeModel {
307    fn get_commission(
308        &self,
309        _order: &OrderAny,
310        fill_quantity: Quantity,
311        _fill_px: Price,
312        _instrument: &InstrumentAny,
313    ) -> anyhow::Result<Money> {
314        let total = self.commission.as_decimal() * fill_quantity.as_decimal();
315        Money::from_decimal(total, self.commission.currency).map_err(Into::into)
316    }
317}
318
319#[derive(Debug, Clone)]
320#[cfg_attr(
321    feature = "python",
322    pyo3::pyclass(
323        module = "nautilus_trader.core.nautilus_pyo3.execution",
324        from_py_object
325    )
326)]
327#[cfg_attr(
328    feature = "python",
329    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
330)]
331pub struct MakerTakerFeeModel;
332
333impl FeeModel for MakerTakerFeeModel {
334    fn get_commission(
335        &self,
336        order: &OrderAny,
337        fill_quantity: Quantity,
338        fill_px: Price,
339        instrument: &InstrumentAny,
340    ) -> anyhow::Result<Money> {
341        let notional = instrument.calculate_notional_value(fill_quantity, fill_px, Some(false));
342        let commission = match order.liquidity_side() {
343            Some(LiquiditySide::Maker) => notional * instrument.maker_fee(),
344            Some(LiquiditySide::Taker) => notional * instrument.taker_fee(),
345            Some(LiquiditySide::NoLiquiditySide) | None => anyhow::bail!("Liquidity side not set"),
346        };
347
348        if instrument.is_inverse() {
349            Money::from_decimal(commission, instrument.base_currency().unwrap()).map_err(Into::into)
350        } else {
351            Money::from_decimal(commission, instrument.quote_currency()).map_err(Into::into)
352        }
353    }
354}
355
356/// Fee model for probability-priced outcome shares.
357///
358/// Applies `qty * fee_rate * p * (1 - p)` using the instrument's maker or
359/// taker fee rate. This matches venues that represent outcome shares as
360/// [`InstrumentAny::BinaryOption`] instruments quoted on a `[0, 1]`
361/// probability scale.
362///
363/// This model covers quote-currency match-time exchange fees only.
364/// Venue-specific rebate programs or non-quote fee assets remain outside the
365/// core execution layer.
366#[derive(Debug, Clone)]
367#[cfg_attr(
368    feature = "python",
369    pyo3::pyclass(
370        module = "nautilus_trader.core.nautilus_pyo3.execution",
371        from_py_object
372    )
373)]
374#[cfg_attr(
375    feature = "python",
376    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
377)]
378pub struct ProbabilityPriceFeeModel;
379
380impl FeeModel for ProbabilityPriceFeeModel {
381    fn get_commission(
382        &self,
383        order: &OrderAny,
384        fill_quantity: Quantity,
385        fill_px: Price,
386        instrument: &InstrumentAny,
387    ) -> anyhow::Result<Money> {
388        if !matches!(instrument, InstrumentAny::BinaryOption(_)) {
389            anyhow::bail!("ProbabilityPriceFeeModel requires a binary option instrument");
390        }
391
392        let fill_price = fill_px.as_decimal();
393        if !(Decimal::ZERO..=Decimal::ONE).contains(&fill_price) {
394            anyhow::bail!("ProbabilityPriceFeeModel requires a fill price in [0, 1]");
395        }
396
397        let fee_rate = match order.liquidity_side() {
398            Some(LiquiditySide::Maker) => instrument.maker_fee(),
399            Some(LiquiditySide::Taker) => instrument.taker_fee(),
400            Some(LiquiditySide::NoLiquiditySide) | None => anyhow::bail!("Liquidity side not set"),
401        };
402
403        let commission =
404            (fill_quantity.as_decimal() * fee_rate * fill_price * (Decimal::ONE - fill_price))
405                .round_dp(5);
406
407        Money::from_decimal(commission, instrument.quote_currency()).map_err(Into::into)
408    }
409}
410
411#[derive(Clone)]
412#[cfg_attr(
413    feature = "python",
414    pyo3::pyclass(
415        module = "nautilus_trader.core.nautilus_pyo3.execution",
416        from_py_object
417    )
418)]
419#[cfg_attr(
420    feature = "python",
421    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
422)]
423pub struct CappedOptionFeeModel {
424    maker_rate: Option<Decimal>,
425    taker_rate: Option<Decimal>,
426    cap: Decimal,
427}
428
429impl Debug for CappedOptionFeeModel {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        f.debug_struct(stringify!(CappedOptionFeeModel))
432            .field("maker_rate", &self.maker_rate)
433            .field("taker_rate", &self.taker_rate)
434            .field("cap_rate", &self.cap)
435            .finish()
436    }
437}
438
439impl CappedOptionFeeModel {
440    /// Creates a new [`CappedOptionFeeModel`] instance.
441    ///
442    /// # Errors
443    ///
444    /// Returns an error if any supplied rate is negative.
445    pub fn new(
446        maker_rate: Option<Decimal>,
447        taker_rate: Option<Decimal>,
448        cap_rate: Option<Decimal>,
449    ) -> anyhow::Result<Self> {
450        check_fee_rate(maker_rate, "maker_rate")?;
451        check_fee_rate(taker_rate, "taker_rate")?;
452
453        let cap_rate = cap_rate.unwrap_or(dec!(0.125));
454        check_fee_rate(Some(cap_rate), "cap_rate")?;
455
456        Ok(Self {
457            maker_rate,
458            taker_rate,
459            cap: cap_rate,
460        })
461    }
462}
463
464impl Default for CappedOptionFeeModel {
465    fn default() -> Self {
466        Self::new(None, None, None).unwrap()
467    }
468}
469
470impl FeeModel for CappedOptionFeeModel {
471    fn get_commission(
472        &self,
473        order: &OrderAny,
474        fill_quantity: Quantity,
475        fill_px: Price,
476        instrument: &InstrumentAny,
477    ) -> anyhow::Result<Money> {
478        self.get_commission_with_context(order, fill_quantity, fill_px, instrument, None)
479    }
480
481    fn get_commission_with_context(
482        &self,
483        order: &OrderAny,
484        fill_quantity: Quantity,
485        fill_px: Price,
486        instrument: &InstrumentAny,
487        underlying_px: Option<Price>,
488    ) -> anyhow::Result<Money> {
489        check_option_instrument(instrument, "CappedOptionFeeModel")?;
490        let rate = option_fee_rate(order, instrument, self.maker_rate, self.taker_rate)?;
491        let multiplier = instrument.multiplier().as_decimal();
492        let rate_fee = if instrument.is_inverse() {
493            rate
494        } else {
495            let underlying_px =
496                underlying_px.ok_or_else(|| anyhow::anyhow!("Underlying price is required"))?;
497            rate * underlying_px.as_decimal()
498        };
499        let cap_fee = self.cap * fill_px.as_decimal();
500        let fee_per_contract = rate_fee.min(cap_fee) * multiplier;
501        let total = fee_per_contract * fill_quantity.as_decimal();
502        Money::from_decimal(total, commission_currency(instrument)).map_err(Into::into)
503    }
504}
505
506#[derive(Debug, Clone)]
507#[cfg_attr(
508    feature = "python",
509    pyo3::pyclass(
510        module = "nautilus_trader.core.nautilus_pyo3.execution",
511        from_py_object
512    )
513)]
514#[cfg_attr(
515    feature = "python",
516    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
517)]
518pub struct TieredNotionalOptionFeeModel {
519    maker_rate: Option<Decimal>,
520    taker_rate: Option<Decimal>,
521}
522
523impl TieredNotionalOptionFeeModel {
524    /// Creates a new [`TieredNotionalOptionFeeModel`] instance.
525    ///
526    /// # Errors
527    ///
528    /// Returns an error if any supplied rate is negative.
529    pub fn new(maker_rate: Option<Decimal>, taker_rate: Option<Decimal>) -> anyhow::Result<Self> {
530        check_fee_rate(maker_rate, "maker_rate")?;
531        check_fee_rate(taker_rate, "taker_rate")?;
532
533        Ok(Self {
534            maker_rate,
535            taker_rate,
536        })
537    }
538}
539
540impl Default for TieredNotionalOptionFeeModel {
541    fn default() -> Self {
542        Self::new(None, None).unwrap()
543    }
544}
545
546impl FeeModel for TieredNotionalOptionFeeModel {
547    fn get_commission(
548        &self,
549        order: &OrderAny,
550        fill_quantity: Quantity,
551        fill_px: Price,
552        instrument: &InstrumentAny,
553    ) -> anyhow::Result<Money> {
554        check_option_instrument(instrument, "TieredNotionalOptionFeeModel")?;
555        let rate = option_fee_rate(order, instrument, self.maker_rate, self.taker_rate)?;
556        let notional = instrument.calculate_notional_value(fill_quantity, fill_px, Some(false));
557        let total = notional.as_decimal() * rate;
558        Money::from_decimal(total, notional.currency).map_err(Into::into)
559    }
560}
561
562fn option_fee_rate(
563    order: &OrderAny,
564    instrument: &InstrumentAny,
565    maker_rate: Option<Decimal>,
566    taker_rate: Option<Decimal>,
567) -> anyhow::Result<Decimal> {
568    let rate = match order.liquidity_side() {
569        Some(LiquiditySide::Maker) => maker_rate.unwrap_or_else(|| instrument.maker_fee()),
570        Some(LiquiditySide::Taker) => taker_rate.unwrap_or_else(|| instrument.taker_fee()),
571        Some(LiquiditySide::NoLiquiditySide) | None => anyhow::bail!("Liquidity side not set"),
572    };
573    check_fee_rate(Some(rate), "fee_rate")?;
574    Ok(rate)
575}
576
577fn check_fee_rate(rate: Option<Decimal>, name: &str) -> anyhow::Result<()> {
578    if rate.is_some_and(|rate| rate < Decimal::ZERO) {
579        anyhow::bail!("`{name}` must be greater than or equal to zero");
580    }
581    Ok(())
582}
583
584fn check_option_instrument(instrument: &InstrumentAny, model_name: &str) -> anyhow::Result<()> {
585    if !matches!(
586        instrument,
587        InstrumentAny::CryptoOption(_) | InstrumentAny::OptionContract(_)
588    ) {
589        anyhow::bail!("{model_name} requires an option instrument");
590    }
591    Ok(())
592}
593
594fn commission_currency(instrument: &InstrumentAny) -> Currency {
595    if instrument.is_inverse() {
596        instrument.settlement_currency()
597    } else {
598        instrument.quote_currency()
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use std::{cell::Cell, rc::Rc};
605
606    use nautilus_model::{
607        enums::{LiquiditySide, OrderSide, OrderType},
608        instruments::{
609            BinaryOption, CryptoOption, Instrument, InstrumentAny, OptionContract,
610            stubs::{audusd_sim, binary_option, crypto_option_btc_deribit, option_contract_appl},
611        },
612        orders::{
613            Order, OrderAny,
614            builder::OrderTestBuilder,
615            stubs::{TestOrderEventStubs, TestOrderStubs},
616        },
617        types::{Currency, Money, Price, Quantity},
618    };
619    use rstest::rstest;
620    use rust_decimal::Decimal;
621    use rust_decimal_macros::dec;
622
623    use super::{
624        CappedOptionFeeModel, FeeModel, FeeModelAny, FeeModelHandle, FixedFeeModel,
625        MakerTakerFeeModel, PerContractFeeModel, ProbabilityPriceFeeModel,
626        TieredNotionalOptionFeeModel,
627    };
628
629    #[rstest]
630    fn test_fixed_model_single_fill() {
631        let expected_commission = Money::new(1.0, Currency::USD());
632        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
633        let fee_model = FixedFeeModel::new(expected_commission, None).unwrap();
634        let market_order = OrderTestBuilder::new(OrderType::Market)
635            .instrument_id(aud_usd.id())
636            .side(OrderSide::Buy)
637            .quantity(Quantity::from(100_000))
638            .build();
639        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
640        let commission = fee_model
641            .get_commission(
642                &accepted_order,
643                Quantity::from(100_000),
644                Price::from("1.0"),
645                &aud_usd,
646            )
647            .unwrap();
648        assert_eq!(commission, expected_commission);
649    }
650
651    #[rstest]
652    #[case(OrderSide::Buy, true, Money::from("1 USD"), Money::from("0 USD"))]
653    #[case(OrderSide::Sell, true, Money::from("1 USD"), Money::from("0 USD"))]
654    #[case(OrderSide::Buy, false, Money::from("1 USD"), Money::from("1 USD"))]
655    #[case(OrderSide::Sell, false, Money::from("1 USD"), Money::from("1 USD"))]
656    fn test_fixed_model_multiple_fills(
657        #[case] order_side: OrderSide,
658        #[case] charge_commission_once: bool,
659        #[case] expected_first_fill: Money,
660        #[case] expected_next_fill: Money,
661    ) {
662        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
663        let fee_model =
664            FixedFeeModel::new(expected_first_fill, Some(charge_commission_once)).unwrap();
665        let market_order = OrderTestBuilder::new(OrderType::Market)
666            .instrument_id(aud_usd.id())
667            .side(order_side)
668            .quantity(Quantity::from(100_000))
669            .build();
670        let mut accepted_order = TestOrderStubs::make_accepted_order(&market_order);
671        let commission_first_fill = fee_model
672            .get_commission(
673                &accepted_order,
674                Quantity::from(50_000),
675                Price::from("1.0"),
676                &aud_usd,
677            )
678            .unwrap();
679        let fill = TestOrderEventStubs::filled(
680            &accepted_order,
681            &aud_usd,
682            None,
683            None,
684            None,
685            Some(Quantity::from(50_000)),
686            None,
687            None,
688            None,
689            None,
690        );
691        accepted_order.apply(fill).unwrap();
692        let commission_next_fill = fee_model
693            .get_commission(
694                &accepted_order,
695                Quantity::from(50_000),
696                Price::from("1.0"),
697                &aud_usd,
698            )
699            .unwrap();
700        assert_eq!(commission_first_fill, expected_first_fill);
701        assert_eq!(commission_next_fill, expected_next_fill);
702    }
703
704    #[rstest]
705    fn test_maker_taker_fee_model_maker_commission() {
706        let fee_model = MakerTakerFeeModel;
707        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
708        let maker_fee = aud_usd.maker_fee();
709        let price = Price::from("1.0");
710        let limit_order = OrderTestBuilder::new(OrderType::Limit)
711            .instrument_id(aud_usd.id())
712            .side(OrderSide::Sell)
713            .price(price)
714            .quantity(Quantity::from(100_000))
715            .build();
716        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
717        let expected_commission = fill.quantity().as_decimal() * price.as_decimal() * maker_fee;
718        let commission = fee_model
719            .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
720            .unwrap();
721        assert_eq!(commission.as_decimal(), expected_commission);
722    }
723
724    #[rstest]
725    fn test_maker_taker_fee_model_uses_decimal_rounding() {
726        let fee_model = MakerTakerFeeModel;
727        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
728        let price = Price::from("1.0");
729        let quantity = Quantity::from("117250");
730        let limit_order = OrderTestBuilder::new(OrderType::Limit)
731            .instrument_id(aud_usd.id())
732            .side(OrderSide::Sell)
733            .price(price)
734            .quantity(quantity)
735            .build();
736        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
737
738        let commission = fee_model
739            .get_commission(&fill, quantity, price, &aud_usd)
740            .unwrap();
741
742        assert_eq!(commission, Money::from("2.34 USD"));
743    }
744
745    #[rstest]
746    fn test_maker_taker_fee_model_taker_commission() {
747        let fee_model = MakerTakerFeeModel;
748        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
749        let taker_fee = aud_usd.taker_fee();
750        let price = Price::from("1.0");
751        let limit_order = OrderTestBuilder::new(OrderType::Limit)
752            .instrument_id(aud_usd.id())
753            .side(OrderSide::Sell)
754            .price(price)
755            .quantity(Quantity::from(100_000))
756            .build();
757
758        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Taker);
759        let expected_commission = fill.quantity().as_decimal() * price.as_decimal() * taker_fee;
760        let commission = fee_model
761            .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
762            .unwrap();
763        assert_eq!(commission.as_decimal(), expected_commission);
764    }
765
766    #[rstest]
767    fn test_per_contract_fee_model() {
768        let commission_per_contract = Money::new(0.50, Currency::USD());
769        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
770        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
771        let market_order = OrderTestBuilder::new(OrderType::Market)
772            .instrument_id(aud_usd.id())
773            .side(OrderSide::Buy)
774            .quantity(Quantity::from(100))
775            .build();
776        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
777        let commission = fee_model
778            .get_commission(
779                &accepted_order,
780                Quantity::from(100),
781                Price::from("1.0"),
782                &aud_usd,
783            )
784            .unwrap();
785        assert_eq!(commission, Money::new(50.0, Currency::USD()));
786    }
787
788    #[rstest]
789    fn test_per_contract_fee_model_partial_fill() {
790        let commission_per_contract = Money::new(1.25, Currency::USD());
791        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
792        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
793        let market_order = OrderTestBuilder::new(OrderType::Market)
794            .instrument_id(aud_usd.id())
795            .side(OrderSide::Sell)
796            .quantity(Quantity::from(1000))
797            .build();
798        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
799        let commission = fee_model
800            .get_commission(
801                &accepted_order,
802                Quantity::from(400),
803                Price::from("1.0"),
804                &aud_usd,
805            )
806            .unwrap();
807        assert_eq!(commission, Money::new(500.0, Currency::USD()));
808    }
809
810    #[rstest]
811    fn test_per_contract_fee_model_uses_decimal_rounding() {
812        let commission_per_contract = Money::from("0.50 USD");
813        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
814        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
815        let market_order = OrderTestBuilder::new(OrderType::Market)
816            .instrument_id(aud_usd.id())
817            .side(OrderSide::Buy)
818            .quantity(Quantity::from("5"))
819            .build();
820        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
821
822        let commission = fee_model
823            .get_commission(
824                &accepted_order,
825                Quantity::from("4.69"),
826                Price::from("1.0"),
827                &aud_usd,
828            )
829            .unwrap();
830
831        assert_eq!(commission, Money::from("2.34 USD"));
832    }
833
834    #[rstest]
835    fn test_per_contract_fee_model_negative_commission_fails() {
836        let result = PerContractFeeModel::new(Money::new(-1.0, Currency::USD()));
837        assert!(result.is_err());
838    }
839
840    #[rstest]
841    #[case::crypto_p97("0.072", "0.970", "0.00210")]
842    #[case::sports_p50("0.03", "0.500", "0.00750")]
843    #[case::sports_p30("0.03", "0.300", "0.00630")]
844    fn test_probability_price_fee_model_taker_commission(
845        mut binary_option: BinaryOption,
846        #[case] taker_fee: &str,
847        #[case] price: &str,
848        #[case] expected: &str,
849    ) {
850        binary_option.taker_fee = Decimal::from_str_exact(taker_fee).unwrap();
851        let instrument = InstrumentAny::BinaryOption(binary_option);
852        let fill = binary_option_fill_order(&instrument, LiquiditySide::Taker, price);
853        let fee_model = ProbabilityPriceFeeModel;
854
855        let commission = fee_model
856            .get_commission(
857                &fill,
858                Quantity::from("1.00"),
859                Price::from(price),
860                &instrument,
861            )
862            .unwrap();
863
864        assert_eq!(commission.currency, Currency::USDC());
865        assert_eq!(
866            commission.as_decimal(),
867            Decimal::from_str_exact(expected).unwrap()
868        );
869    }
870
871    #[rstest]
872    fn test_probability_price_fee_model_maker_commission_uses_instrument_rate(
873        mut binary_option: BinaryOption,
874    ) {
875        binary_option.maker_fee = dec!(0.01);
876        let instrument = InstrumentAny::BinaryOption(binary_option);
877        let fill = binary_option_fill_order(&instrument, LiquiditySide::Maker, "0.500");
878        let fee_model = FeeModelAny::ProbabilityPrice(ProbabilityPriceFeeModel);
879
880        let commission = fee_model
881            .get_commission(
882                &fill,
883                Quantity::from("1.00"),
884                Price::from("0.500"),
885                &instrument,
886            )
887            .unwrap();
888
889        assert_eq!(commission, Money::from("0.00250 USDC"));
890    }
891
892    #[rstest]
893    fn test_fee_model_handle_calls_custom_model_without_model_clone() {
894        let calls = Rc::new(Cell::new(0));
895        let expected_commission = Money::from("1.23 USD");
896        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
897        let market_order = OrderTestBuilder::new(OrderType::Market)
898            .instrument_id(aud_usd.id())
899            .side(OrderSide::Buy)
900            .quantity(Quantity::from(100_000))
901            .build();
902        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
903        let fee_model = FeeModelHandle::new(CountingFeeModel {
904            calls: Rc::clone(&calls),
905            commission: expected_commission,
906        });
907        let cloned_fee_model = fee_model.clone();
908        drop(fee_model);
909
910        let commission = cloned_fee_model
911            .get_commission(
912                &accepted_order,
913                Quantity::from(100_000),
914                Price::from("1.0"),
915                &aud_usd,
916            )
917            .unwrap();
918
919        assert_eq!(calls.get(), 1);
920        assert_eq!(commission, expected_commission);
921    }
922
923    #[rstest]
924    fn test_fee_model_handle_from_rc_calls_custom_model() {
925        let calls = Rc::new(Cell::new(0));
926        let expected_commission = Money::from("1.23 USD");
927        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
928        let market_order = OrderTestBuilder::new(OrderType::Market)
929            .instrument_id(aud_usd.id())
930            .side(OrderSide::Buy)
931            .quantity(Quantity::from(100_000))
932            .build();
933        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
934        let model = Rc::new(CountingFeeModel {
935            calls: Rc::clone(&calls),
936            commission: expected_commission,
937        });
938        let fee_model = FeeModelHandle::from_rc(model);
939
940        let commission = fee_model
941            .get_commission(
942                &accepted_order,
943                Quantity::from(100_000),
944                Price::from("1.0"),
945                &aud_usd,
946            )
947            .unwrap();
948
949        assert_eq!(calls.get(), 1);
950        assert_eq!(commission, expected_commission);
951    }
952
953    struct CountingFeeModel {
954        calls: Rc<Cell<u32>>,
955        commission: Money,
956    }
957
958    impl FeeModel for CountingFeeModel {
959        fn get_commission(
960            &self,
961            _order: &OrderAny,
962            _fill_quantity: Quantity,
963            _fill_px: Price,
964            _instrument: &InstrumentAny,
965        ) -> anyhow::Result<Money> {
966            self.calls.set(self.calls.get() + 1);
967            Ok(self.commission)
968        }
969    }
970
971    #[rstest]
972    fn test_probability_price_fee_model_rejects_non_binary_instrument() {
973        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
974        let fill = binary_option_fill_order(&instrument, LiquiditySide::Taker, "0.500");
975        let fee_model = ProbabilityPriceFeeModel;
976
977        let result = fee_model.get_commission(
978            &fill,
979            Quantity::from("1.00"),
980            Price::from("0.500"),
981            &instrument,
982        );
983
984        assert!(result.is_err());
985    }
986
987    #[rstest]
988    #[case::maker(Some(dec!(-0.0001)), Some(dec!(0.0003)), None, "maker_rate")]
989    #[case::taker(Some(dec!(0.0001)), Some(dec!(-0.0003)), None, "taker_rate")]
990    #[case::cap(Some(dec!(0.0001)), Some(dec!(0.0003)), Some(dec!(-0.125)), "cap_rate")]
991    fn test_capped_option_fee_model_negative_rate_fails(
992        #[case] maker_rate: Option<Decimal>,
993        #[case] taker_rate: Option<Decimal>,
994        #[case] cap_rate: Option<Decimal>,
995        #[case] expected_field: &str,
996    ) {
997        let result = CappedOptionFeeModel::new(maker_rate, taker_rate, cap_rate);
998
999        assert_eq!(
1000            result.unwrap_err().to_string(),
1001            format!("`{expected_field}` must be greater than or equal to zero")
1002        );
1003    }
1004
1005    #[rstest]
1006    fn test_capped_option_fee_model_maker_commission_rate_bound(
1007        crypto_option_btc_deribit: CryptoOption,
1008    ) {
1009        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1010        let fill = option_fill_order(&instrument, LiquiditySide::Maker);
1011        let fee_model = FeeModelAny::CappedOption(
1012            CappedOptionFeeModel::new(Some(dec!(0.0001)), Some(dec!(0.0003)), None).unwrap(),
1013        );
1014
1015        let commission = fee_model
1016            .get_commission_with_context(
1017                &fill,
1018                Quantity::from("2.0"),
1019                Price::from("100.00"),
1020                &instrument,
1021                Some(Price::from("50000.00")),
1022            )
1023            .unwrap();
1024
1025        assert_eq!(commission.currency, Currency::USD());
1026        assert_eq!(commission.as_decimal(), dec!(10.00));
1027    }
1028
1029    #[rstest]
1030    fn test_capped_option_fee_model_taker_commission_cap_bound(
1031        crypto_option_btc_deribit: CryptoOption,
1032    ) {
1033        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1034        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
1035        let fee_model =
1036            CappedOptionFeeModel::new(Some(dec!(0.0001)), Some(dec!(0.0003)), None).unwrap();
1037
1038        let commission = fee_model
1039            .get_commission_with_context(
1040                &fill,
1041                Quantity::from("2.0"),
1042                Price::from("10.00"),
1043                &instrument,
1044                Some(Price::from("50000.00")),
1045            )
1046            .unwrap();
1047
1048        assert_eq!(commission.currency, Currency::USD());
1049        assert_eq!(commission.as_decimal(), dec!(2.50));
1050    }
1051
1052    #[rstest]
1053    fn test_capped_option_fee_model_applies_contract_multiplier(
1054        mut option_contract_appl: OptionContract,
1055    ) {
1056        option_contract_appl.multiplier = Quantity::from(100);
1057        let instrument = InstrumentAny::OptionContract(option_contract_appl);
1058        let fill = option_fill_order(&instrument, LiquiditySide::Maker);
1059        let fee_model =
1060            CappedOptionFeeModel::new(Some(dec!(0.0001)), Some(dec!(0.0003)), None).unwrap();
1061
1062        let commission = fee_model
1063            .get_commission_with_context(
1064                &fill,
1065                Quantity::from("2"),
1066                Price::from("2.00"),
1067                &instrument,
1068                Some(Price::from("150.00")),
1069            )
1070            .unwrap();
1071
1072        assert_eq!(commission.currency, Currency::USD());
1073        assert_eq!(commission.as_decimal(), dec!(3.00));
1074    }
1075
1076    #[rstest]
1077    fn test_capped_option_fee_model_inverse_commission_uses_settlement_currency(
1078        mut crypto_option_btc_deribit: CryptoOption,
1079    ) {
1080        crypto_option_btc_deribit.is_inverse = true;
1081        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1082        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
1083        let fee_model =
1084            CappedOptionFeeModel::new(Some(dec!(0.0001)), Some(dec!(0.0003)), None).unwrap();
1085
1086        let commission = fee_model
1087            .get_commission(
1088                &fill,
1089                Quantity::from("2.0"),
1090                Price::from("0.010"),
1091                &instrument,
1092            )
1093            .unwrap();
1094
1095        assert_eq!(commission.currency, Currency::BTC());
1096        assert_eq!(commission.as_decimal(), dec!(0.0006));
1097    }
1098
1099    #[rstest]
1100    fn test_capped_option_fee_model_requires_underlying_price(
1101        crypto_option_btc_deribit: CryptoOption,
1102    ) {
1103        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1104        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
1105        let fee_model = CappedOptionFeeModel::default();
1106
1107        let result = fee_model.get_commission(
1108            &fill,
1109            Quantity::from("1.0"),
1110            Price::from("10.00"),
1111            &instrument,
1112        );
1113
1114        assert!(result.is_err());
1115    }
1116
1117    #[rstest]
1118    fn test_capped_option_fee_model_rejects_non_option_instrument() {
1119        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1120        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
1121        let fee_model = CappedOptionFeeModel::default();
1122
1123        let result = fee_model.get_commission_with_context(
1124            &fill,
1125            Quantity::from("1.0"),
1126            Price::from("10.00"),
1127            &instrument,
1128            Some(Price::from("50000.00")),
1129        );
1130
1131        assert!(result.is_err());
1132    }
1133
1134    #[rstest]
1135    #[case::maker(LiquiditySide::Maker, dec!(0.04))]
1136    #[case::taker(LiquiditySide::Taker, dec!(0.10))]
1137    fn test_tiered_notional_option_fee_model_commission(
1138        crypto_option_btc_deribit: CryptoOption,
1139        #[case] liquidity_side: LiquiditySide,
1140        #[case] expected_commission: Decimal,
1141    ) {
1142        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1143        let fill = option_fill_order(&instrument, liquidity_side);
1144        let fee_model = FeeModelAny::TieredNotionalOption(
1145            TieredNotionalOptionFeeModel::new(Some(dec!(0.0002)), Some(dec!(0.0005))).unwrap(),
1146        );
1147
1148        let commission = fee_model
1149            .get_commission(
1150                &fill,
1151                Quantity::from("2.0"),
1152                Price::from("100.00"),
1153                &instrument,
1154            )
1155            .unwrap();
1156
1157        assert_eq!(commission.currency, Currency::USD());
1158        assert_eq!(commission.as_decimal(), expected_commission);
1159    }
1160
1161    #[rstest]
1162    fn test_tiered_notional_option_fee_model_inverse_commission_uses_base_currency(
1163        mut crypto_option_btc_deribit: CryptoOption,
1164    ) {
1165        crypto_option_btc_deribit.is_inverse = true;
1166        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1167        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
1168        let fee_model =
1169            TieredNotionalOptionFeeModel::new(Some(dec!(0.0002)), Some(dec!(0.0005))).unwrap();
1170
1171        let commission = fee_model
1172            .get_commission(
1173                &fill,
1174                Quantity::from("2.0"),
1175                Price::from("0.010"),
1176                &instrument,
1177            )
1178            .unwrap();
1179
1180        assert_eq!(commission.currency, Currency::BTC());
1181        assert_eq!(commission.as_decimal(), dec!(0.10));
1182    }
1183
1184    #[rstest]
1185    fn test_tiered_notional_option_fee_model_rejects_non_option_instrument() {
1186        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1187        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
1188        let fee_model = TieredNotionalOptionFeeModel::default();
1189
1190        let result = fee_model.get_commission(
1191            &fill,
1192            Quantity::from("1.0"),
1193            Price::from("10.00"),
1194            &instrument,
1195        );
1196
1197        assert!(result.is_err());
1198    }
1199
1200    #[rstest]
1201    #[case::maker(Some(dec!(-0.0002)), Some(dec!(0.0005)), "maker_rate")]
1202    #[case::taker(Some(dec!(0.0002)), Some(dec!(-0.0005)), "taker_rate")]
1203    fn test_tiered_notional_option_fee_model_negative_rate_fails(
1204        #[case] maker_rate: Option<Decimal>,
1205        #[case] taker_rate: Option<Decimal>,
1206        #[case] expected_field: &str,
1207    ) {
1208        let result = TieredNotionalOptionFeeModel::new(maker_rate, taker_rate);
1209
1210        assert_eq!(
1211            result.unwrap_err().to_string(),
1212            format!("`{expected_field}` must be greater than or equal to zero")
1213        );
1214    }
1215
1216    #[rstest]
1217    fn test_tiered_notional_option_fee_model_requires_liquidity_side(
1218        crypto_option_btc_deribit: CryptoOption,
1219    ) {
1220        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1221        let order = OrderTestBuilder::new(OrderType::Limit)
1222            .instrument_id(instrument.id())
1223            .side(OrderSide::Buy)
1224            .price(Price::from("100.00"))
1225            .quantity(Quantity::from("2.0"))
1226            .build();
1227        let fee_model = TieredNotionalOptionFeeModel::default();
1228
1229        let result = fee_model.get_commission(
1230            &order,
1231            Quantity::from("1.0"),
1232            Price::from("10.00"),
1233            &instrument,
1234        );
1235
1236        assert!(result.is_err());
1237    }
1238
1239    fn option_fill_order(instrument: &InstrumentAny, liquidity_side: LiquiditySide) -> OrderAny {
1240        let limit_order = OrderTestBuilder::new(OrderType::Limit)
1241            .instrument_id(instrument.id())
1242            .side(OrderSide::Buy)
1243            .price(Price::from("100.00"))
1244            .quantity(Quantity::from("2.0"))
1245            .build();
1246
1247        TestOrderStubs::make_filled_order(&limit_order, instrument, liquidity_side)
1248    }
1249
1250    fn binary_option_fill_order(
1251        instrument: &InstrumentAny,
1252        liquidity_side: LiquiditySide,
1253        price: &str,
1254    ) -> OrderAny {
1255        let limit_order = OrderTestBuilder::new(OrderType::Limit)
1256            .instrument_id(instrument.id())
1257            .side(OrderSide::Buy)
1258            .price(Price::from(price))
1259            .quantity(Quantity::from("1.00"))
1260            .build();
1261
1262        TestOrderStubs::make_filled_order(&limit_order, instrument, liquidity_side)
1263    }
1264}