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    fees::{MakerTakerFeeRates, MakerTakerFeeSchedule, calculate_maker_taker_commission},
21    identifiers::{GENERIC_SPREAD_ID_SEPARATOR, InstrumentId, parse_generic_spread_id_legs},
22    instruments::{Instrument, InstrumentAny},
23    orders::{Order, OrderAny},
24    types::{Currency, Money, Price, Quantity},
25};
26use rust_decimal::Decimal;
27use rust_decimal_macros::dec;
28
29#[cfg(feature = "python")]
30use crate::python::fee::{PyFeeModel, PythonFeeModel};
31
32pub trait FeeModel {
33    /// Calculates commission for a fill.
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if commission calculation fails.
38    fn get_commission(
39        &self,
40        order: &OrderAny,
41        fill_quantity: Quantity,
42        fill_px: Price,
43        instrument: &InstrumentAny,
44    ) -> anyhow::Result<Money>;
45
46    /// Calculates commission for a fill with additional pricing context.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if commission calculation fails.
51    fn get_commission_with_context(
52        &self,
53        order: &OrderAny,
54        fill_quantity: Quantity,
55        fill_px: Price,
56        instrument: &InstrumentAny,
57        _underlying_px: Option<Price>,
58    ) -> anyhow::Result<Money> {
59        self.get_commission(order, fill_quantity, fill_px, instrument)
60    }
61}
62
63/// Shared runtime handle for a fee model.
64#[derive(Clone)]
65pub struct FeeModelHandle(Rc<dyn FeeModel>);
66
67impl FeeModelHandle {
68    /// Creates a new [`FeeModelHandle`] from a fee model.
69    #[must_use]
70    pub fn new<T>(model: T) -> Self
71    where
72        T: FeeModel + 'static,
73    {
74        Self(Rc::new(model))
75    }
76
77    /// Creates a new [`FeeModelHandle`] from an existing reference-counted model.
78    #[must_use]
79    pub fn from_rc(model: Rc<dyn FeeModel>) -> Self {
80        Self(model)
81    }
82}
83
84impl Debug for FeeModelHandle {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_tuple(stringify!(FeeModelHandle))
87            .field(&"<dyn FeeModel>")
88            .finish()
89    }
90}
91
92impl FeeModel for FeeModelHandle {
93    fn get_commission(
94        &self,
95        order: &OrderAny,
96        fill_quantity: Quantity,
97        fill_px: Price,
98        instrument: &InstrumentAny,
99    ) -> anyhow::Result<Money> {
100        self.0
101            .get_commission(order, fill_quantity, fill_px, instrument)
102    }
103
104    fn get_commission_with_context(
105        &self,
106        order: &OrderAny,
107        fill_quantity: Quantity,
108        fill_px: Price,
109        instrument: &InstrumentAny,
110        underlying_px: Option<Price>,
111    ) -> anyhow::Result<Money> {
112        self.0
113            .get_commission_with_context(order, fill_quantity, fill_px, instrument, underlying_px)
114    }
115}
116
117impl From<FeeModelAny> for FeeModelHandle {
118    fn from(model: FeeModelAny) -> Self {
119        Self::new(model)
120    }
121}
122
123#[derive(Clone, Debug)]
124pub enum FeeModelAny {
125    Fixed(FixedFeeModel),
126    MakerTaker(MakerTakerFeeModel),
127    PerContract(PerContractFeeModel),
128    ProbabilityPrice(ProbabilityPriceFeeModel),
129    CappedOption(CappedOptionFeeModel),
130    TieredNotionalOption(TieredNotionalOptionFeeModel),
131    #[cfg(feature = "python")]
132    Python(PythonFeeModel),
133}
134
135impl FeeModel for FeeModelAny {
136    #[rustfmt::skip]
137    fn get_commission(
138        &self,
139        order: &OrderAny,
140        fill_quantity: Quantity,
141        fill_px: Price,
142        instrument: &InstrumentAny,
143    ) -> anyhow::Result<Money> {
144        match self {
145            Self::Fixed(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
146            Self::MakerTaker(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
147            Self::PerContract(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
148            Self::ProbabilityPrice(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
149            Self::CappedOption(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
150            Self::TieredNotionalOption(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
151            #[cfg(feature = "python")]
152            Self::Python(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
153        }
154    }
155
156    #[rustfmt::skip]
157    fn get_commission_with_context(
158        &self,
159        order: &OrderAny,
160        fill_quantity: Quantity,
161        fill_px: Price,
162        instrument: &InstrumentAny,
163        underlying_px: Option<Price>,
164    ) -> anyhow::Result<Money> {
165        match self {
166            Self::Fixed(model) => model.get_commission_with_context(order, fill_quantity, fill_px, instrument, underlying_px),
167            Self::MakerTaker(model) => model.get_commission_with_context(order, fill_quantity, fill_px, instrument, underlying_px),
168            Self::PerContract(model) => model.get_commission_with_context(order, fill_quantity, fill_px, instrument, underlying_px),
169            Self::ProbabilityPrice(model) => model.get_commission_with_context(order, fill_quantity, fill_px, instrument, underlying_px),
170            Self::CappedOption(model) => model.get_commission_with_context(order, fill_quantity, fill_px, instrument, underlying_px),
171            Self::TieredNotionalOption(model) => model.get_commission_with_context(order, fill_quantity, fill_px, instrument, underlying_px),
172            #[cfg(feature = "python")]
173            Self::Python(model) => model.get_commission_with_context(order, fill_quantity, fill_px, instrument, underlying_px),
174        }
175    }
176}
177
178#[derive(Debug, Clone)]
179#[cfg_attr(
180    feature = "python",
181    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
182)]
183#[cfg_attr(
184    feature = "python",
185    pyo3::pyclass(
186        module = "nautilus_trader.execution",
187        extends = PyFeeModel,
188        skip_from_py_object
189    )
190)]
191pub struct FixedFeeModel {
192    commission: Money,
193    zero_commission: Money,
194    charge_commission_once: bool,
195}
196
197impl FixedFeeModel {
198    /// Creates a new [`FixedFeeModel`] instance.
199    ///
200    /// # Errors
201    ///
202    /// Returns an error if `commission` is negative.
203    pub fn new(commission: Money, charge_commission_once: Option<bool>) -> anyhow::Result<Self> {
204        if commission.is_negative() {
205            anyhow::bail!("Commission must be greater than or equal to zero")
206        }
207        let zero_commission = Money::zero(commission.currency);
208        Ok(Self {
209            commission,
210            zero_commission,
211            charge_commission_once: charge_commission_once.unwrap_or(true),
212        })
213    }
214}
215
216impl FeeModel for FixedFeeModel {
217    fn get_commission(
218        &self,
219        order: &OrderAny,
220        _fill_quantity: Quantity,
221        _fill_px: Price,
222        _instrument: &InstrumentAny,
223    ) -> anyhow::Result<Money> {
224        if !self.charge_commission_once || order.filled_qty().is_zero() {
225            Ok(self.commission)
226        } else {
227            Ok(self.zero_commission)
228        }
229    }
230}
231
232#[derive(Debug, Clone)]
233#[cfg_attr(
234    feature = "python",
235    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
236)]
237#[cfg_attr(
238    feature = "python",
239    pyo3::pyclass(
240        module = "nautilus_trader.execution",
241        extends = PyFeeModel,
242        skip_from_py_object
243    )
244)]
245pub struct PerContractFeeModel {
246    commission: Money,
247}
248
249impl PerContractFeeModel {
250    /// Creates a new [`PerContractFeeModel`] instance.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if `commission` is negative.
255    pub fn new(commission: Money) -> anyhow::Result<Self> {
256        if commission.is_negative() {
257            anyhow::bail!("Commission must be greater than or equal to zero")
258        }
259        Ok(Self { commission })
260    }
261}
262
263fn mul_checked(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
264    lhs.checked_mul(rhs)
265        .ok_or_else(|| anyhow::anyhow!("commission calculation overflow"))
266}
267
268impl FeeModel for PerContractFeeModel {
269    fn get_commission(
270        &self,
271        _order: &OrderAny,
272        fill_quantity: Quantity,
273        _fill_px: Price,
274        instrument: &InstrumentAny,
275    ) -> anyhow::Result<Money> {
276        let contracts = spread_contract_count(instrument)?;
277        let total = mul_checked(self.commission.as_decimal(), fill_quantity.as_decimal())
278            .and_then(|v| mul_checked(v, contracts))?;
279        Money::from_decimal(total, self.commission.currency).map_err(Into::into)
280    }
281}
282
283fn spread_contract_count(instrument: &InstrumentAny) -> anyhow::Result<Decimal> {
284    let instrument_id = instrument.id();
285    let symbol = instrument_id.symbol.as_str();
286    if !instrument.is_spread() || !symbol.contains(GENERIC_SPREAD_ID_SEPARATOR) {
287        return Ok(Decimal::ONE);
288    }
289
290    let mut total = 0_i64;
291
292    for (_, ratio) in parse_generic_spread_id_legs(&instrument_id)? {
293        total = total.checked_add(ratio.abs()).ok_or_else(|| {
294            anyhow::anyhow!("Generic spread contract count overflowed for {symbol}")
295        })?;
296    }
297
298    Ok(total.into())
299}
300
301#[derive(Debug, Clone)]
302#[cfg_attr(
303    feature = "python",
304    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
305)]
306#[cfg_attr(
307    feature = "python",
308    pyo3::pyclass(
309        module = "nautilus_trader.execution",
310        extends = PyFeeModel,
311        skip_from_py_object
312    )
313)]
314pub struct MakerTakerFeeModel {
315    schedule: MakerTakerFeeSchedule,
316}
317
318impl MakerTakerFeeModel {
319    /// Creates a new [`MakerTakerFeeModel`] with explicit default rates.
320    ///
321    /// Negative maker rates represent rebates where the venue supports them.
322    /// Use [`Self::set_override`] for exact per-instrument rates.
323    #[must_use]
324    pub fn new(maker_rate: Decimal, taker_rate: Decimal) -> Self {
325        Self {
326            schedule: MakerTakerFeeSchedule::new(maker_rate, taker_rate),
327        }
328    }
329
330    /// Creates a new explicit zero-fee [`MakerTakerFeeModel`].
331    #[must_use]
332    pub fn zero() -> Self {
333        Self {
334            schedule: MakerTakerFeeSchedule::zero(),
335        }
336    }
337
338    /// Adds or replaces an exact instrument override.
339    pub fn set_override(&mut self, instrument_id: InstrumentId, rates: MakerTakerFeeRates) {
340        self.schedule.set_override(instrument_id, rates);
341    }
342
343    /// Returns the owned fee schedule.
344    #[must_use]
345    pub fn schedule(&self) -> &MakerTakerFeeSchedule {
346        &self.schedule
347    }
348}
349
350impl FeeModel for MakerTakerFeeModel {
351    fn get_commission(
352        &self,
353        order: &OrderAny,
354        fill_quantity: Quantity,
355        fill_px: Price,
356        instrument: &InstrumentAny,
357    ) -> anyhow::Result<Money> {
358        let liquidity_side = order
359            .liquidity_side()
360            .ok_or_else(|| anyhow::anyhow!("Liquidity side not set"))?;
361        if liquidity_side == LiquiditySide::NoLiquiditySide {
362            anyhow::bail!("Liquidity side not set");
363        }
364
365        let rate = self.schedule.rate_for(instrument.id(), liquidity_side)?;
366        calculate_maker_taker_commission(instrument, fill_quantity, fill_px, rate, Some(false))
367    }
368}
369
370/// Fee model for probability-priced outcome shares.
371///
372/// Applies `qty * fee_rate * p * (1 - p)` using the account-owned maker or
373/// taker fee rate. This matches venues that represent outcome shares as
374/// [`InstrumentAny::BinaryOption`] instruments quoted on a `[0, 1]`
375/// probability scale.
376///
377/// This model covers quote-currency match-time exchange fees only.
378/// Venue-specific rebate programs or non-quote fee assets remain outside the
379/// core execution layer.
380#[derive(Debug, Clone)]
381#[cfg_attr(
382    feature = "python",
383    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
384)]
385#[cfg_attr(
386    feature = "python",
387    pyo3::pyclass(
388        module = "nautilus_trader.execution",
389        extends = PyFeeModel,
390        skip_from_py_object
391    )
392)]
393pub struct ProbabilityPriceFeeModel {
394    schedule: MakerTakerFeeSchedule,
395}
396
397impl ProbabilityPriceFeeModel {
398    /// Creates a new [`ProbabilityPriceFeeModel`] with explicit default rates.
399    #[must_use]
400    pub fn new(maker_rate: Decimal, taker_rate: Decimal) -> Self {
401        Self {
402            schedule: MakerTakerFeeSchedule::new(maker_rate, taker_rate),
403        }
404    }
405
406    /// Adds or replaces an exact instrument override.
407    pub fn set_override(&mut self, instrument_id: InstrumentId, rates: MakerTakerFeeRates) {
408        self.schedule.set_override(instrument_id, rates);
409    }
410
411    /// Returns the owned fee schedule.
412    #[must_use]
413    pub fn schedule(&self) -> &MakerTakerFeeSchedule {
414        &self.schedule
415    }
416}
417
418impl FeeModel for ProbabilityPriceFeeModel {
419    fn get_commission(
420        &self,
421        order: &OrderAny,
422        fill_quantity: Quantity,
423        fill_px: Price,
424        instrument: &InstrumentAny,
425    ) -> anyhow::Result<Money> {
426        if !matches!(instrument, InstrumentAny::BinaryOption(_)) {
427            anyhow::bail!("ProbabilityPriceFeeModel requires a binary option instrument");
428        }
429
430        let fill_price = fill_px.as_decimal();
431        if !(Decimal::ZERO..=Decimal::ONE).contains(&fill_price) {
432            anyhow::bail!("ProbabilityPriceFeeModel requires a fill price in [0, 1]");
433        }
434
435        let liquidity_side = order
436            .liquidity_side()
437            .ok_or_else(|| anyhow::anyhow!("Liquidity side not set"))?;
438        if liquidity_side == LiquiditySide::NoLiquiditySide {
439            anyhow::bail!("Liquidity side not set");
440        }
441
442        let fee_rate = self.schedule.rate_for(instrument.id(), liquidity_side)?;
443
444        let one_minus_p = Decimal::ONE - fill_price;
445        let commission = mul_checked(fill_quantity.as_decimal(), fee_rate)
446            .and_then(|v| mul_checked(v, fill_price))
447            .and_then(|v| mul_checked(v, one_minus_p))
448            .map(|v| v.round_dp(5))?;
449
450        Money::from_decimal(commission, instrument.quote_currency()).map_err(Into::into)
451    }
452}
453
454#[derive(Debug, Clone)]
455#[cfg_attr(
456    feature = "python",
457    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
458)]
459#[cfg_attr(
460    feature = "python",
461    pyo3::pyclass(
462        module = "nautilus_trader.execution",
463        extends = PyFeeModel,
464        skip_from_py_object
465    )
466)]
467pub struct CappedOptionFeeModel {
468    schedule: MakerTakerFeeSchedule,
469    cap: Decimal,
470}
471
472impl CappedOptionFeeModel {
473    /// Creates a new [`CappedOptionFeeModel`] instance with explicit rates.
474    ///
475    /// # Errors
476    ///
477    /// Returns an error if any supplied rate is negative.
478    pub fn new(
479        maker_rate: Decimal,
480        taker_rate: Decimal,
481        cap_rate: Option<Decimal>,
482    ) -> anyhow::Result<Self> {
483        check_fee_rate(Some(maker_rate), "maker_rate")?;
484        check_fee_rate(Some(taker_rate), "taker_rate")?;
485
486        let cap_rate = cap_rate.unwrap_or(dec!(0.125));
487        check_fee_rate(Some(cap_rate), "cap_rate")?;
488
489        Ok(Self {
490            schedule: MakerTakerFeeSchedule::new(maker_rate, taker_rate),
491            cap: cap_rate,
492        })
493    }
494
495    /// Adds or replaces an exact instrument override.
496    ///
497    /// # Errors
498    ///
499    /// Returns an error if any override rate is negative.
500    pub fn set_override(
501        &mut self,
502        instrument_id: InstrumentId,
503        rates: MakerTakerFeeRates,
504    ) -> anyhow::Result<()> {
505        check_fee_rate(Some(rates.maker), "maker_rate")?;
506        check_fee_rate(Some(rates.taker), "taker_rate")?;
507        self.schedule.set_override(instrument_id, rates);
508        Ok(())
509    }
510
511    /// Returns the owned fee schedule.
512    #[must_use]
513    pub fn schedule(&self) -> &MakerTakerFeeSchedule {
514        &self.schedule
515    }
516}
517
518impl FeeModel for CappedOptionFeeModel {
519    fn get_commission(
520        &self,
521        order: &OrderAny,
522        fill_quantity: Quantity,
523        fill_px: Price,
524        instrument: &InstrumentAny,
525    ) -> anyhow::Result<Money> {
526        self.get_commission_with_context(order, fill_quantity, fill_px, instrument, None)
527    }
528
529    fn get_commission_with_context(
530        &self,
531        order: &OrderAny,
532        fill_quantity: Quantity,
533        fill_px: Price,
534        instrument: &InstrumentAny,
535        underlying_px: Option<Price>,
536    ) -> anyhow::Result<Money> {
537        check_option_instrument(instrument, "CappedOptionFeeModel")?;
538        let liquidity_side = order
539            .liquidity_side()
540            .ok_or_else(|| anyhow::anyhow!("Liquidity side not set"))?;
541        if liquidity_side == LiquiditySide::NoLiquiditySide {
542            anyhow::bail!("Liquidity side not set");
543        }
544
545        let rate = self.schedule.rate_for(instrument.id(), liquidity_side)?;
546        check_fee_rate(Some(rate), "fee_rate")?;
547        let multiplier = instrument.multiplier().as_decimal();
548        let rate_fee = if instrument.is_inverse() {
549            rate
550        } else {
551            let underlying_px =
552                underlying_px.ok_or_else(|| anyhow::anyhow!("Underlying price is required"))?;
553            mul_checked(rate, underlying_px.as_decimal())?
554        };
555        let cap_fee = mul_checked(self.cap, fill_px.as_decimal())?;
556        let fee_per_contract = mul_checked(rate_fee.min(cap_fee), multiplier)?;
557        let total = mul_checked(fee_per_contract, fill_quantity.as_decimal())?;
558        Money::from_decimal(total, commission_currency(instrument)).map_err(Into::into)
559    }
560}
561
562#[derive(Debug, Clone)]
563#[cfg_attr(
564    feature = "python",
565    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
566)]
567#[cfg_attr(
568    feature = "python",
569    pyo3::pyclass(
570        module = "nautilus_trader.execution",
571        extends = PyFeeModel,
572        skip_from_py_object
573    )
574)]
575pub struct TieredNotionalOptionFeeModel {
576    schedule: MakerTakerFeeSchedule,
577}
578
579impl TieredNotionalOptionFeeModel {
580    /// Creates a new [`TieredNotionalOptionFeeModel`] instance with explicit rates.
581    ///
582    /// # Errors
583    ///
584    /// Returns an error if any supplied rate is negative.
585    pub fn new(maker_rate: Decimal, taker_rate: Decimal) -> anyhow::Result<Self> {
586        check_fee_rate(Some(maker_rate), "maker_rate")?;
587        check_fee_rate(Some(taker_rate), "taker_rate")?;
588
589        Ok(Self {
590            schedule: MakerTakerFeeSchedule::new(maker_rate, taker_rate),
591        })
592    }
593
594    /// Adds or replaces an exact instrument override.
595    ///
596    /// # Errors
597    ///
598    /// Returns an error if any override rate is negative.
599    pub fn set_override(
600        &mut self,
601        instrument_id: InstrumentId,
602        rates: MakerTakerFeeRates,
603    ) -> anyhow::Result<()> {
604        check_fee_rate(Some(rates.maker), "maker_rate")?;
605        check_fee_rate(Some(rates.taker), "taker_rate")?;
606        self.schedule.set_override(instrument_id, rates);
607        Ok(())
608    }
609
610    /// Returns the owned fee schedule.
611    #[must_use]
612    pub fn schedule(&self) -> &MakerTakerFeeSchedule {
613        &self.schedule
614    }
615}
616
617impl FeeModel for TieredNotionalOptionFeeModel {
618    fn get_commission(
619        &self,
620        order: &OrderAny,
621        fill_quantity: Quantity,
622        fill_px: Price,
623        instrument: &InstrumentAny,
624    ) -> anyhow::Result<Money> {
625        check_option_instrument(instrument, "TieredNotionalOptionFeeModel")?;
626        let liquidity_side = order
627            .liquidity_side()
628            .ok_or_else(|| anyhow::anyhow!("Liquidity side not set"))?;
629        if liquidity_side == LiquiditySide::NoLiquiditySide {
630            anyhow::bail!("Liquidity side not set");
631        }
632
633        let rate = self.schedule.rate_for(instrument.id(), liquidity_side)?;
634        check_fee_rate(Some(rate), "fee_rate")?;
635        let notional =
636            instrument.try_calculate_notional_value(fill_quantity, fill_px, Some(false))?;
637        let total = mul_checked(notional.as_decimal(), rate)?;
638        Money::from_decimal(total, notional.currency).map_err(Into::into)
639    }
640}
641
642fn check_fee_rate(rate: Option<Decimal>, name: &str) -> anyhow::Result<()> {
643    if rate.is_some_and(|rate| rate < Decimal::ZERO) {
644        anyhow::bail!("`{name}` must be greater than or equal to zero");
645    }
646    Ok(())
647}
648
649fn check_option_instrument(instrument: &InstrumentAny, model_name: &str) -> anyhow::Result<()> {
650    if !matches!(
651        instrument,
652        InstrumentAny::CryptoOption(_) | InstrumentAny::OptionContract(_)
653    ) {
654        anyhow::bail!("{model_name} requires an option instrument");
655    }
656    Ok(())
657}
658
659fn commission_currency(instrument: &InstrumentAny) -> Currency {
660    if instrument.is_inverse() {
661        instrument.settlement_currency()
662    } else {
663        instrument.quote_currency()
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use std::{cell::Cell, rc::Rc};
670
671    use nautilus_model::{
672        enums::{LiquiditySide, OrderSide, OrderType},
673        identifiers::InstrumentId,
674        instruments::{
675            BinaryOption, CryptoOption, Instrument, InstrumentAny, OptionContract,
676            stubs::{
677                audusd_sim, binary_option, crypto_option_btc_deribit, option_contract_appl,
678                option_spread,
679            },
680        },
681        orders::{
682            Order, OrderAny,
683            builder::OrderTestBuilder,
684            stubs::{TestOrderEventStubs, TestOrderStubs},
685        },
686        types::{Currency, Money, Price, Quantity},
687    };
688    use rstest::rstest;
689    use rust_decimal::Decimal;
690    use rust_decimal_macros::dec;
691
692    use super::{
693        CappedOptionFeeModel, FeeModel, FeeModelAny, FeeModelHandle, FixedFeeModel,
694        MakerTakerFeeModel, PerContractFeeModel, ProbabilityPriceFeeModel,
695        TieredNotionalOptionFeeModel,
696    };
697
698    #[rstest]
699    fn test_fixed_model_single_fill() {
700        let expected_commission = Money::new(1.0, Currency::USD());
701        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
702        let fee_model = FixedFeeModel::new(expected_commission, None).unwrap();
703        let market_order = OrderTestBuilder::new(OrderType::Market)
704            .instrument_id(aud_usd.id())
705            .side(OrderSide::Buy)
706            .quantity(Quantity::from(100_000))
707            .build();
708        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
709        let commission = fee_model
710            .get_commission(
711                &accepted_order,
712                Quantity::from(100_000),
713                Price::from("1.0"),
714                &aud_usd,
715            )
716            .unwrap();
717        assert_eq!(commission, expected_commission);
718    }
719
720    #[rstest]
721    #[case(OrderSide::Buy, true, Money::from("1 USD"), Money::from("0 USD"))]
722    #[case(OrderSide::Sell, true, Money::from("1 USD"), Money::from("0 USD"))]
723    #[case(OrderSide::Buy, false, Money::from("1 USD"), Money::from("1 USD"))]
724    #[case(OrderSide::Sell, false, Money::from("1 USD"), Money::from("1 USD"))]
725    fn test_fixed_model_multiple_fills(
726        #[case] order_side: OrderSide,
727        #[case] charge_commission_once: bool,
728        #[case] expected_first_fill: Money,
729        #[case] expected_next_fill: Money,
730    ) {
731        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
732        let fee_model =
733            FixedFeeModel::new(expected_first_fill, Some(charge_commission_once)).unwrap();
734        let market_order = OrderTestBuilder::new(OrderType::Market)
735            .instrument_id(aud_usd.id())
736            .side(order_side)
737            .quantity(Quantity::from(100_000))
738            .build();
739        let mut accepted_order = TestOrderStubs::make_accepted_order(&market_order);
740        let commission_first_fill = fee_model
741            .get_commission(
742                &accepted_order,
743                Quantity::from(50_000),
744                Price::from("1.0"),
745                &aud_usd,
746            )
747            .unwrap();
748        let fill = TestOrderEventStubs::filled(
749            &accepted_order,
750            &aud_usd,
751            None,
752            None,
753            None,
754            Some(Quantity::from(50_000)),
755            None,
756            None,
757            None,
758            None,
759        );
760        accepted_order.apply(fill).unwrap();
761        let commission_next_fill = fee_model
762            .get_commission(
763                &accepted_order,
764                Quantity::from(50_000),
765                Price::from("1.0"),
766                &aud_usd,
767            )
768            .unwrap();
769        assert_eq!(commission_first_fill, expected_first_fill);
770        assert_eq!(commission_next_fill, expected_next_fill);
771    }
772
773    #[rstest]
774    fn test_maker_taker_fee_model_maker_commission() {
775        let fee_model = MakerTakerFeeModel::new(dec!(0.00002), dec!(0.00002));
776        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
777        let maker_fee = dec!(0.00002);
778        let price = Price::from("1.0");
779        let limit_order = OrderTestBuilder::new(OrderType::Limit)
780            .instrument_id(aud_usd.id())
781            .side(OrderSide::Sell)
782            .price(price)
783            .quantity(Quantity::from(100_000))
784            .build();
785        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
786        let expected_commission = fill.quantity().as_decimal() * price.as_decimal() * maker_fee;
787        let commission = fee_model
788            .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
789            .unwrap();
790        assert_eq!(commission.as_decimal(), expected_commission);
791    }
792
793    #[rstest]
794    fn test_maker_taker_fee_model_same_instrument_two_schedules() {
795        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
796        let schedule_a = MakerTakerFeeModel::new(dec!(0.0001), dec!(0.0002));
797        let schedule_b = MakerTakerFeeModel::new(dec!(0.0003), dec!(0.0004));
798        let order = OrderTestBuilder::new(OrderType::Market)
799            .instrument_id(instrument.id())
800            .side(OrderSide::Buy)
801            .quantity(Quantity::from(100_000))
802            .build();
803        let fill = TestOrderStubs::make_filled_order(&order, &instrument, LiquiditySide::Taker);
804
805        let commission_a = schedule_a
806            .get_commission(
807                &fill,
808                Quantity::from(100_000),
809                Price::from("1.0"),
810                &instrument,
811            )
812            .unwrap();
813        let commission_b = schedule_b
814            .get_commission(
815                &fill,
816                Quantity::from(100_000),
817                Price::from("1.0"),
818                &instrument,
819            )
820            .unwrap();
821
822        assert_eq!(commission_a, Money::from("20 USD"));
823        assert_eq!(commission_b, Money::from("40 USD"));
824    }
825
826    #[rstest]
827    fn test_maker_taker_fee_model_negative_maker_rate_is_rebate() {
828        let fee_model = MakerTakerFeeModel::new(dec!(-0.00025), dec!(0.00075));
829        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
830        let order = OrderTestBuilder::new(OrderType::Limit)
831            .instrument_id(instrument.id())
832            .side(OrderSide::Sell)
833            .price(Price::from("1.0"))
834            .quantity(Quantity::from(100_000))
835            .build();
836        let fill = TestOrderStubs::make_filled_order(&order, &instrument, LiquiditySide::Maker);
837
838        let commission = fee_model
839            .get_commission(
840                &fill,
841                Quantity::from(100_000),
842                Price::from("1.0"),
843                &instrument,
844            )
845            .unwrap();
846
847        assert_eq!(commission, Money::from("-25 USD"));
848    }
849
850    #[rstest]
851    fn test_maker_taker_fee_model_uses_decimal_rounding() {
852        let fee_model = MakerTakerFeeModel::new(dec!(0.00002), dec!(0.00002));
853        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
854        let price = Price::from("1.0");
855        let quantity = Quantity::from("117250");
856        let limit_order = OrderTestBuilder::new(OrderType::Limit)
857            .instrument_id(aud_usd.id())
858            .side(OrderSide::Sell)
859            .price(price)
860            .quantity(quantity)
861            .build();
862        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
863
864        let commission = fee_model
865            .get_commission(&fill, quantity, price, &aud_usd)
866            .unwrap();
867
868        assert_eq!(commission, Money::from("2.34 USD"));
869    }
870
871    #[rstest]
872    fn test_per_contract_fee_model_decimal_overflow_returns_error() {
873        let commission = Money::from("9000000000 USD");
874        let fee_model = PerContractFeeModel::new(commission).unwrap();
875        let mut spread = option_spread();
876        spread.id = InstrumentId::from("((1000000000))SPY C410___(1)SPY C400.SMART");
877        let instrument = InstrumentAny::OptionSpread(spread);
878        let market_order = OrderTestBuilder::new(OrderType::Market)
879            .instrument_id(instrument.id())
880            .side(OrderSide::Buy)
881            .quantity(Quantity::from("9000000000"))
882            .build();
883        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
884        let result = fee_model.get_commission(
885            &accepted_order,
886            Quantity::from("9000000000"),
887            Price::from("1.0"),
888            &instrument,
889        );
890        assert_eq!(
891            result.unwrap_err().to_string(),
892            "commission calculation overflow"
893        );
894    }
895
896    #[rstest]
897    fn test_maker_taker_fee_model_decimal_overflow_returns_error() {
898        let fee_model = MakerTakerFeeModel::new(Decimal::MAX, dec!(0.00002));
899        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
900        let order = OrderTestBuilder::new(OrderType::Limit)
901            .instrument_id(instrument.id())
902            .side(OrderSide::Sell)
903            .price(Price::from("1.0"))
904            .quantity(Quantity::from("2"))
905            .build();
906        let fill = TestOrderStubs::make_filled_order(&order, &instrument, LiquiditySide::Maker);
907
908        let result =
909            fee_model.get_commission(&fill, Quantity::from("2"), Price::from("1.0"), &instrument);
910
911        assert_eq!(
912            result.unwrap_err().to_string(),
913            "commission calculation overflow"
914        );
915    }
916
917    #[rstest]
918    fn test_maker_taker_fee_model_taker_commission() {
919        let fee_model = MakerTakerFeeModel::new(dec!(0.00002), dec!(0.00002));
920        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
921        let taker_fee = dec!(0.00002);
922        let price = Price::from("1.0");
923        let limit_order = OrderTestBuilder::new(OrderType::Limit)
924            .instrument_id(aud_usd.id())
925            .side(OrderSide::Sell)
926            .price(price)
927            .quantity(Quantity::from(100_000))
928            .build();
929
930        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Taker);
931        let expected_commission = fill.quantity().as_decimal() * price.as_decimal() * taker_fee;
932        let commission = fee_model
933            .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
934            .unwrap();
935        assert_eq!(commission.as_decimal(), expected_commission);
936    }
937
938    #[rstest]
939    fn test_per_contract_fee_model() {
940        let commission_per_contract = Money::new(0.50, Currency::USD());
941        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
942        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
943        let market_order = OrderTestBuilder::new(OrderType::Market)
944            .instrument_id(aud_usd.id())
945            .side(OrderSide::Buy)
946            .quantity(Quantity::from(100))
947            .build();
948        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
949        let commission = fee_model
950            .get_commission(
951                &accepted_order,
952                Quantity::from(100),
953                Price::from("1.0"),
954                &aud_usd,
955            )
956            .unwrap();
957        assert_eq!(commission, Money::new(50.0, Currency::USD()));
958    }
959
960    #[rstest]
961    fn test_per_contract_fee_model_non_spread_symbol_with_separator_charges_one_contract() {
962        let commission_per_contract = Money::from("1.25 USD");
963        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
964        let mut aud_usd = audusd_sim();
965        aud_usd.id = InstrumentId::from("AUD___USD.SIM");
966        let instrument = InstrumentAny::CurrencyPair(aud_usd);
967        let market_order = OrderTestBuilder::new(OrderType::Market)
968            .instrument_id(instrument.id())
969            .side(OrderSide::Buy)
970            .quantity(Quantity::from(2))
971            .build();
972        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
973
974        let commission = fee_model
975            .get_commission(
976                &accepted_order,
977                Quantity::from(2),
978                Price::from("1.0"),
979                &instrument,
980            )
981            .unwrap();
982
983        assert_eq!(commission, Money::from("2.50 USD"));
984    }
985
986    #[rstest]
987    fn test_per_contract_fee_model_option_spread_charges_each_contract() {
988        let commission_per_contract = Money::from("1.25 USD");
989        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
990        let spread_id = InstrumentId::from("((2))SPY C410___(1)SPY C400.SMART");
991        let mut option_spread = option_spread();
992        option_spread.id = spread_id;
993        let instrument = InstrumentAny::OptionSpread(option_spread);
994        let market_order = OrderTestBuilder::new(OrderType::Market)
995            .instrument_id(instrument.id())
996            .side(OrderSide::Buy)
997            .quantity(Quantity::from(2))
998            .build();
999        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
1000
1001        let commission = fee_model
1002            .get_commission(
1003                &accepted_order,
1004                Quantity::from(2),
1005                Price::from("1.0"),
1006                &instrument,
1007            )
1008            .unwrap();
1009
1010        assert_eq!(commission, Money::from("7.50 USD"));
1011    }
1012
1013    #[rstest]
1014    fn test_per_contract_fee_model_non_generic_option_spread_charges_one_contract() {
1015        let commission_per_contract = Money::from("1.25 USD");
1016        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
1017        let instrument = InstrumentAny::OptionSpread(option_spread());
1018        let market_order = OrderTestBuilder::new(OrderType::Market)
1019            .instrument_id(instrument.id())
1020            .side(OrderSide::Buy)
1021            .quantity(Quantity::from(2))
1022            .build();
1023        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
1024
1025        let commission = fee_model
1026            .get_commission(
1027                &accepted_order,
1028                Quantity::from(2),
1029                Price::from("1.0"),
1030                &instrument,
1031            )
1032            .unwrap();
1033
1034        assert_eq!(commission, Money::from("2.50 USD"));
1035    }
1036
1037    #[rstest]
1038    fn test_per_contract_fee_model_malformed_generic_spread_fails() {
1039        let commission_per_contract = Money::from("1.25 USD");
1040        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
1041        let spread_id = InstrumentId::from("(1)SPY C400___SPY C410.SMART");
1042        let mut option_spread = option_spread();
1043        option_spread.id = spread_id;
1044        let instrument = InstrumentAny::OptionSpread(option_spread);
1045        let market_order = OrderTestBuilder::new(OrderType::Market)
1046            .instrument_id(instrument.id())
1047            .side(OrderSide::Buy)
1048            .quantity(Quantity::from(2))
1049            .build();
1050        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
1051
1052        let result = fee_model.get_commission(
1053            &accepted_order,
1054            Quantity::from(2),
1055            Price::from("1.0"),
1056            &instrument,
1057        );
1058
1059        assert_eq!(
1060            result.unwrap_err().to_string(),
1061            "Invalid generic spread leg component: SPY C410"
1062        );
1063    }
1064
1065    #[rstest]
1066    fn test_per_contract_fee_model_generic_spread_contract_count_overflow_fails() {
1067        let commission_per_contract = Money::from("1.25 USD");
1068        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
1069        let max_ratio = i64::MAX;
1070        let spread_symbol = format!("({max_ratio})SPY C400___({max_ratio})SPY C410");
1071        let spread_id = InstrumentId::from(format!("{spread_symbol}.SMART"));
1072        let mut option_spread = option_spread();
1073        option_spread.id = spread_id;
1074        let instrument = InstrumentAny::OptionSpread(option_spread);
1075        let market_order = OrderTestBuilder::new(OrderType::Market)
1076            .instrument_id(instrument.id())
1077            .side(OrderSide::Buy)
1078            .quantity(Quantity::from(2))
1079            .build();
1080        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
1081
1082        let result = fee_model.get_commission(
1083            &accepted_order,
1084            Quantity::from(2),
1085            Price::from("1.0"),
1086            &instrument,
1087        );
1088
1089        assert_eq!(
1090            result.unwrap_err().to_string(),
1091            format!("Generic spread contract count overflowed for {spread_symbol}")
1092        );
1093    }
1094
1095    #[rstest]
1096    fn test_per_contract_fee_model_partial_fill() {
1097        let commission_per_contract = Money::new(1.25, Currency::USD());
1098        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
1099        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
1100        let market_order = OrderTestBuilder::new(OrderType::Market)
1101            .instrument_id(aud_usd.id())
1102            .side(OrderSide::Sell)
1103            .quantity(Quantity::from(1000))
1104            .build();
1105        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
1106        let commission = fee_model
1107            .get_commission(
1108                &accepted_order,
1109                Quantity::from(400),
1110                Price::from("1.0"),
1111                &aud_usd,
1112            )
1113            .unwrap();
1114        assert_eq!(commission, Money::new(500.0, Currency::USD()));
1115    }
1116
1117    #[rstest]
1118    fn test_per_contract_fee_model_uses_decimal_rounding() {
1119        let commission_per_contract = Money::from("0.50 USD");
1120        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
1121        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
1122        let market_order = OrderTestBuilder::new(OrderType::Market)
1123            .instrument_id(aud_usd.id())
1124            .side(OrderSide::Buy)
1125            .quantity(Quantity::from("5"))
1126            .build();
1127        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
1128
1129        let commission = fee_model
1130            .get_commission(
1131                &accepted_order,
1132                Quantity::from("4.69"),
1133                Price::from("1.0"),
1134                &aud_usd,
1135            )
1136            .unwrap();
1137
1138        assert_eq!(commission, Money::from("2.34 USD"));
1139    }
1140
1141    #[rstest]
1142    fn test_per_contract_fee_model_negative_commission_fails() {
1143        let result = PerContractFeeModel::new(Money::new(-1.0, Currency::USD()));
1144        assert!(result.is_err());
1145    }
1146
1147    #[rstest]
1148    #[case::crypto_p97("0.072", "0.970", "0.00210")]
1149    #[case::sports_p50("0.03", "0.500", "0.00750")]
1150    #[case::sports_p30("0.03", "0.300", "0.00630")]
1151    fn test_probability_price_fee_model_taker_commission(
1152        binary_option: BinaryOption,
1153        #[case] taker_fee: &str,
1154        #[case] price: &str,
1155        #[case] expected: &str,
1156    ) {
1157        let instrument = InstrumentAny::BinaryOption(binary_option);
1158        let fill = binary_option_fill_order(&instrument, LiquiditySide::Taker, price);
1159
1160        let fee_model = ProbabilityPriceFeeModel::new(
1161            Decimal::ZERO,
1162            Decimal::from_str_exact(taker_fee).unwrap(),
1163        );
1164
1165        let commission = fee_model
1166            .get_commission(
1167                &fill,
1168                Quantity::from("1.00"),
1169                Price::from(price),
1170                &instrument,
1171            )
1172            .unwrap();
1173
1174        assert_eq!(commission.currency, Currency::USDC());
1175        assert_eq!(
1176            commission.as_decimal(),
1177            Decimal::from_str_exact(expected).unwrap()
1178        );
1179    }
1180
1181    #[rstest]
1182    fn test_probability_price_fee_model_maker_commission_uses_explicit_rate(
1183        binary_option: BinaryOption,
1184    ) {
1185        let instrument = InstrumentAny::BinaryOption(binary_option);
1186        let fill = binary_option_fill_order(&instrument, LiquiditySide::Maker, "0.500");
1187        let fee_model =
1188            FeeModelAny::ProbabilityPrice(ProbabilityPriceFeeModel::new(dec!(0.01), dec!(0.02)));
1189
1190        let commission = fee_model
1191            .get_commission(
1192                &fill,
1193                Quantity::from("1.00"),
1194                Price::from("0.500"),
1195                &instrument,
1196            )
1197            .unwrap();
1198
1199        assert_eq!(commission, Money::from("0.00250 USDC"));
1200    }
1201
1202    #[rstest]
1203    fn test_probability_price_fee_model_decimal_overflow_returns_error(
1204        binary_option: BinaryOption,
1205    ) {
1206        let instrument = InstrumentAny::BinaryOption(binary_option);
1207        let fill = binary_option_fill_order(&instrument, LiquiditySide::Maker, "0.500");
1208        let fee_model = ProbabilityPriceFeeModel::new(Decimal::MAX, dec!(0.02));
1209
1210        let result = fee_model.get_commission(
1211            &fill,
1212            Quantity::from("5.00"),
1213            Price::from("0.500"),
1214            &instrument,
1215        );
1216
1217        assert_eq!(
1218            result.unwrap_err().to_string(),
1219            "commission calculation overflow"
1220        );
1221    }
1222
1223    #[rstest]
1224    fn test_fee_model_handle_calls_custom_model_without_model_clone() {
1225        let calls = Rc::new(Cell::new(0));
1226        let expected_commission = Money::from("1.23 USD");
1227        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
1228        let market_order = OrderTestBuilder::new(OrderType::Market)
1229            .instrument_id(aud_usd.id())
1230            .side(OrderSide::Buy)
1231            .quantity(Quantity::from(100_000))
1232            .build();
1233        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
1234        let fee_model = FeeModelHandle::new(CountingFeeModel {
1235            calls: Rc::clone(&calls),
1236            commission: expected_commission,
1237        });
1238        let cloned_fee_model = fee_model.clone();
1239        drop(fee_model);
1240
1241        let commission = cloned_fee_model
1242            .get_commission(
1243                &accepted_order,
1244                Quantity::from(100_000),
1245                Price::from("1.0"),
1246                &aud_usd,
1247            )
1248            .unwrap();
1249
1250        assert_eq!(calls.get(), 1);
1251        assert_eq!(commission, expected_commission);
1252    }
1253
1254    #[rstest]
1255    fn test_fee_model_handle_from_rc_calls_custom_model() {
1256        let calls = Rc::new(Cell::new(0));
1257        let expected_commission = Money::from("1.23 USD");
1258        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
1259        let market_order = OrderTestBuilder::new(OrderType::Market)
1260            .instrument_id(aud_usd.id())
1261            .side(OrderSide::Buy)
1262            .quantity(Quantity::from(100_000))
1263            .build();
1264        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
1265        let model = Rc::new(CountingFeeModel {
1266            calls: Rc::clone(&calls),
1267            commission: expected_commission,
1268        });
1269        let fee_model = FeeModelHandle::from_rc(model);
1270
1271        let commission = fee_model
1272            .get_commission(
1273                &accepted_order,
1274                Quantity::from(100_000),
1275                Price::from("1.0"),
1276                &aud_usd,
1277            )
1278            .unwrap();
1279
1280        assert_eq!(calls.get(), 1);
1281        assert_eq!(commission, expected_commission);
1282    }
1283
1284    struct CountingFeeModel {
1285        calls: Rc<Cell<u32>>,
1286        commission: Money,
1287    }
1288
1289    impl FeeModel for CountingFeeModel {
1290        fn get_commission(
1291            &self,
1292            _order: &OrderAny,
1293            _fill_quantity: Quantity,
1294            _fill_px: Price,
1295            _instrument: &InstrumentAny,
1296        ) -> anyhow::Result<Money> {
1297            self.calls.set(self.calls.get() + 1);
1298            Ok(self.commission)
1299        }
1300    }
1301
1302    #[rstest]
1303    fn test_probability_price_fee_model_rejects_non_binary_instrument() {
1304        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1305        let fill = binary_option_fill_order(&instrument, LiquiditySide::Taker, "0.500");
1306        let fee_model = ProbabilityPriceFeeModel::new(dec!(0.01), dec!(0.02));
1307
1308        let result = fee_model.get_commission(
1309            &fill,
1310            Quantity::from("1.00"),
1311            Price::from("0.500"),
1312            &instrument,
1313        );
1314
1315        assert!(result.is_err());
1316    }
1317
1318    #[rstest]
1319    fn test_probability_price_fee_model_rejects_fill_price_out_of_range(
1320        binary_option: BinaryOption,
1321    ) {
1322        let instrument = InstrumentAny::BinaryOption(binary_option);
1323        let fill = binary_option_fill_order(&instrument, LiquiditySide::Taker, "0.500");
1324        let fee_model = ProbabilityPriceFeeModel::new(dec!(0.01), dec!(0.02));
1325
1326        let result = fee_model.get_commission(
1327            &fill,
1328            Quantity::from("1.00"),
1329            Price::from("1.5"),
1330            &instrument,
1331        );
1332
1333        assert_eq!(
1334            result.unwrap_err().to_string(),
1335            "ProbabilityPriceFeeModel requires a fill price in [0, 1]"
1336        );
1337    }
1338
1339    #[rstest]
1340    #[case::maker(dec!(-0.0001), dec!(0.0003), None, "maker_rate")]
1341    #[case::taker(dec!(0.0001), dec!(-0.0003), None, "taker_rate")]
1342    #[case::cap(dec!(0.0001), dec!(0.0003), Some(dec!(-0.125)), "cap_rate")]
1343    fn test_capped_option_fee_model_negative_rate_fails(
1344        #[case] maker_rate: Decimal,
1345        #[case] taker_rate: Decimal,
1346        #[case] cap_rate: Option<Decimal>,
1347        #[case] expected_field: &str,
1348    ) {
1349        let result = CappedOptionFeeModel::new(maker_rate, taker_rate, cap_rate);
1350
1351        assert_eq!(
1352            result.unwrap_err().to_string(),
1353            format!("`{expected_field}` must be greater than or equal to zero")
1354        );
1355    }
1356
1357    #[rstest]
1358    fn test_capped_option_fee_model_maker_commission_rate_bound(
1359        crypto_option_btc_deribit: CryptoOption,
1360    ) {
1361        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1362        let fill = option_fill_order(&instrument, LiquiditySide::Maker);
1363        let fee_model = FeeModelAny::CappedOption(
1364            CappedOptionFeeModel::new(dec!(0.0001), dec!(0.0003), None).unwrap(),
1365        );
1366
1367        let commission = fee_model
1368            .get_commission_with_context(
1369                &fill,
1370                Quantity::from("2.0"),
1371                Price::from("100.00"),
1372                &instrument,
1373                Some(Price::from("50000.00")),
1374            )
1375            .unwrap();
1376
1377        assert_eq!(commission.currency, Currency::USD());
1378        assert_eq!(commission.as_decimal(), dec!(10.00));
1379    }
1380
1381    #[rstest]
1382    fn test_capped_option_fee_model_decimal_overflow_returns_error(
1383        crypto_option_btc_deribit: CryptoOption,
1384    ) {
1385        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1386        let fill = option_fill_order(&instrument, LiquiditySide::Maker);
1387        let fee_model = CappedOptionFeeModel::new(Decimal::MAX, dec!(0.0003), None).unwrap();
1388
1389        let result = fee_model.get_commission_with_context(
1390            &fill,
1391            Quantity::from("2.0"),
1392            Price::from("100.00"),
1393            &instrument,
1394            Some(Price::from("50000.00")),
1395        );
1396
1397        assert_eq!(
1398            result.unwrap_err().to_string(),
1399            "commission calculation overflow"
1400        );
1401    }
1402
1403    #[rstest]
1404    fn test_capped_option_fee_model_taker_commission_cap_bound(
1405        crypto_option_btc_deribit: CryptoOption,
1406    ) {
1407        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1408        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
1409        let fee_model = CappedOptionFeeModel::new(dec!(0.0001), dec!(0.0003), None).unwrap();
1410
1411        let commission = fee_model
1412            .get_commission_with_context(
1413                &fill,
1414                Quantity::from("2.0"),
1415                Price::from("10.00"),
1416                &instrument,
1417                Some(Price::from("50000.00")),
1418            )
1419            .unwrap();
1420
1421        assert_eq!(commission.currency, Currency::USD());
1422        assert_eq!(commission.as_decimal(), dec!(2.50));
1423    }
1424
1425    #[rstest]
1426    fn test_capped_option_fee_model_applies_contract_multiplier(
1427        mut option_contract_appl: OptionContract,
1428    ) {
1429        option_contract_appl.multiplier = Quantity::from(100);
1430        let instrument = InstrumentAny::OptionContract(option_contract_appl);
1431        let fill = option_fill_order(&instrument, LiquiditySide::Maker);
1432        let fee_model = CappedOptionFeeModel::new(dec!(0.0001), dec!(0.0003), None).unwrap();
1433
1434        let commission = fee_model
1435            .get_commission_with_context(
1436                &fill,
1437                Quantity::from("2"),
1438                Price::from("2.00"),
1439                &instrument,
1440                Some(Price::from("150.00")),
1441            )
1442            .unwrap();
1443
1444        assert_eq!(commission.currency, Currency::USD());
1445        assert_eq!(commission.as_decimal(), dec!(3.00));
1446    }
1447
1448    #[rstest]
1449    fn test_capped_option_fee_model_inverse_commission_uses_settlement_currency(
1450        mut crypto_option_btc_deribit: CryptoOption,
1451    ) {
1452        crypto_option_btc_deribit.is_inverse = true;
1453        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1454        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
1455        let fee_model = CappedOptionFeeModel::new(dec!(0.0001), dec!(0.0003), None).unwrap();
1456
1457        let commission = fee_model
1458            .get_commission(
1459                &fill,
1460                Quantity::from("2.0"),
1461                Price::from("0.010"),
1462                &instrument,
1463            )
1464            .unwrap();
1465
1466        assert_eq!(commission.currency, Currency::BTC());
1467        assert_eq!(commission.as_decimal(), dec!(0.0006));
1468    }
1469
1470    #[rstest]
1471    fn test_capped_option_fee_model_requires_underlying_price(
1472        crypto_option_btc_deribit: CryptoOption,
1473    ) {
1474        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1475        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
1476        let fee_model = CappedOptionFeeModel::new(dec!(0.0001), dec!(0.0003), None).unwrap();
1477
1478        let result = fee_model.get_commission(
1479            &fill,
1480            Quantity::from("1.0"),
1481            Price::from("10.00"),
1482            &instrument,
1483        );
1484
1485        assert!(result.is_err());
1486    }
1487
1488    #[rstest]
1489    fn test_capped_option_fee_model_rejects_non_option_instrument() {
1490        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1491        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
1492        let fee_model = CappedOptionFeeModel::new(dec!(0.0001), dec!(0.0003), None).unwrap();
1493
1494        let result = fee_model.get_commission_with_context(
1495            &fill,
1496            Quantity::from("1.0"),
1497            Price::from("10.00"),
1498            &instrument,
1499            Some(Price::from("50000.00")),
1500        );
1501
1502        assert!(result.is_err());
1503    }
1504
1505    #[rstest]
1506    #[case::maker(LiquiditySide::Maker, dec!(0.04))]
1507    #[case::taker(LiquiditySide::Taker, dec!(0.10))]
1508    fn test_tiered_notional_option_fee_model_commission(
1509        crypto_option_btc_deribit: CryptoOption,
1510        #[case] liquidity_side: LiquiditySide,
1511        #[case] expected_commission: Decimal,
1512    ) {
1513        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1514        let fill = option_fill_order(&instrument, liquidity_side);
1515        let fee_model = FeeModelAny::TieredNotionalOption(
1516            TieredNotionalOptionFeeModel::new(dec!(0.0002), dec!(0.0005)).unwrap(),
1517        );
1518
1519        let commission = fee_model
1520            .get_commission(
1521                &fill,
1522                Quantity::from("2.0"),
1523                Price::from("100.00"),
1524                &instrument,
1525            )
1526            .unwrap();
1527
1528        assert_eq!(commission.currency, Currency::USD());
1529        assert_eq!(commission.as_decimal(), expected_commission);
1530    }
1531
1532    #[rstest]
1533    fn test_tiered_notional_option_fee_model_decimal_overflow_returns_error(
1534        crypto_option_btc_deribit: CryptoOption,
1535    ) {
1536        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1537        let fill = option_fill_order(&instrument, LiquiditySide::Maker);
1538        let fee_model = TieredNotionalOptionFeeModel::new(Decimal::MAX, dec!(0.0005)).unwrap();
1539
1540        let result = fee_model.get_commission(
1541            &fill,
1542            Quantity::from("2.0"),
1543            Price::from("100.00"),
1544            &instrument,
1545        );
1546
1547        assert_eq!(
1548            result.unwrap_err().to_string(),
1549            "commission calculation overflow"
1550        );
1551    }
1552
1553    #[rstest]
1554    fn test_tiered_notional_option_fee_model_inverse_commission_uses_base_currency(
1555        mut crypto_option_btc_deribit: CryptoOption,
1556    ) {
1557        crypto_option_btc_deribit.is_inverse = true;
1558        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1559        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
1560        let fee_model = TieredNotionalOptionFeeModel::new(dec!(0.0002), dec!(0.0005)).unwrap();
1561
1562        let commission = fee_model
1563            .get_commission(
1564                &fill,
1565                Quantity::from("2.0"),
1566                Price::from("0.010"),
1567                &instrument,
1568            )
1569            .unwrap();
1570
1571        assert_eq!(commission.currency, Currency::BTC());
1572        assert_eq!(commission.as_decimal(), dec!(0.00001));
1573    }
1574
1575    #[rstest]
1576    fn test_tiered_notional_option_fee_model_rejects_non_option_instrument() {
1577        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1578        let fill = option_fill_order(&instrument, LiquiditySide::Taker);
1579        let fee_model = TieredNotionalOptionFeeModel::new(dec!(0.0002), dec!(0.0005)).unwrap();
1580
1581        let result = fee_model.get_commission(
1582            &fill,
1583            Quantity::from("1.0"),
1584            Price::from("10.00"),
1585            &instrument,
1586        );
1587
1588        assert!(result.is_err());
1589    }
1590
1591    #[rstest]
1592    #[case::maker(dec!(-0.0002), dec!(0.0005), "maker_rate")]
1593    #[case::taker(dec!(0.0002), dec!(-0.0005), "taker_rate")]
1594    fn test_tiered_notional_option_fee_model_negative_rate_fails(
1595        #[case] maker_rate: Decimal,
1596        #[case] taker_rate: Decimal,
1597        #[case] expected_field: &str,
1598    ) {
1599        let result = TieredNotionalOptionFeeModel::new(maker_rate, taker_rate);
1600
1601        assert_eq!(
1602            result.unwrap_err().to_string(),
1603            format!("`{expected_field}` must be greater than or equal to zero")
1604        );
1605    }
1606
1607    #[rstest]
1608    fn test_tiered_notional_option_fee_model_requires_liquidity_side(
1609        crypto_option_btc_deribit: CryptoOption,
1610    ) {
1611        let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
1612        let order = OrderTestBuilder::new(OrderType::Limit)
1613            .instrument_id(instrument.id())
1614            .side(OrderSide::Buy)
1615            .price(Price::from("100.00"))
1616            .quantity(Quantity::from("2.0"))
1617            .build();
1618        let fee_model = TieredNotionalOptionFeeModel::new(dec!(0.0002), dec!(0.0005)).unwrap();
1619
1620        let result = fee_model.get_commission(
1621            &order,
1622            Quantity::from("1.0"),
1623            Price::from("10.00"),
1624            &instrument,
1625        );
1626
1627        assert!(result.is_err());
1628    }
1629
1630    fn option_fill_order(instrument: &InstrumentAny, liquidity_side: LiquiditySide) -> OrderAny {
1631        let limit_order = OrderTestBuilder::new(OrderType::Limit)
1632            .instrument_id(instrument.id())
1633            .side(OrderSide::Buy)
1634            .price(Price::from("100.00"))
1635            .quantity(Quantity::from("2.0"))
1636            .build();
1637
1638        TestOrderStubs::make_filled_order(&limit_order, instrument, liquidity_side)
1639    }
1640
1641    fn binary_option_fill_order(
1642        instrument: &InstrumentAny,
1643        liquidity_side: LiquiditySide,
1644        price: &str,
1645    ) -> OrderAny {
1646        let limit_order = OrderTestBuilder::new(OrderType::Limit)
1647            .instrument_id(instrument.id())
1648            .side(OrderSide::Buy)
1649            .price(Price::from(price))
1650            .quantity(Quantity::from("1.00"))
1651            .build();
1652
1653        TestOrderStubs::make_filled_order(&limit_order, instrument, liquidity_side)
1654    }
1655}