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