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