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