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