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