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