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