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