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