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