1use std::fmt::Debug;
17
18use nautilus_model::{
19 enums::LiquiditySide,
20 instruments::{Instrument, InstrumentAny},
21 orders::{Order, OrderAny},
22 types::{Currency, Money, Price, Quantity},
23};
24use rust_decimal::Decimal;
25use rust_decimal_macros::dec;
26
27pub trait FeeModel {
28 fn get_commission(
34 &self,
35 order: &OrderAny,
36 fill_quantity: Quantity,
37 fill_px: Price,
38 instrument: &InstrumentAny,
39 ) -> anyhow::Result<Money>;
40
41 fn get_commission_with_context(
47 &self,
48 order: &OrderAny,
49 fill_quantity: Quantity,
50 fill_px: Price,
51 instrument: &InstrumentAny,
52 _underlying_px: Option<Price>,
53 ) -> anyhow::Result<Money> {
54 self.get_commission(order, fill_quantity, fill_px, instrument)
55 }
56}
57
58#[derive(Clone, Debug)]
59pub enum FeeModelAny {
60 Fixed(FixedFeeModel),
61 MakerTaker(MakerTakerFeeModel),
62 PerContract(PerContractFeeModel),
63 CappedOption(CappedOptionFeeModel),
64 TieredNotionalOption(TieredNotionalOptionFeeModel),
65}
66
67impl FeeModel for FeeModelAny {
68 fn get_commission(
69 &self,
70 order: &OrderAny,
71 fill_quantity: Quantity,
72 fill_px: Price,
73 instrument: &InstrumentAny,
74 ) -> anyhow::Result<Money> {
75 match self {
76 Self::Fixed(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
77 Self::MakerTaker(model) => {
78 model.get_commission(order, fill_quantity, fill_px, instrument)
79 }
80 Self::PerContract(model) => {
81 model.get_commission(order, fill_quantity, fill_px, instrument)
82 }
83 Self::CappedOption(model) => {
84 model.get_commission(order, fill_quantity, fill_px, instrument)
85 }
86 Self::TieredNotionalOption(model) => {
87 model.get_commission(order, fill_quantity, fill_px, instrument)
88 }
89 }
90 }
91
92 fn get_commission_with_context(
93 &self,
94 order: &OrderAny,
95 fill_quantity: Quantity,
96 fill_px: Price,
97 instrument: &InstrumentAny,
98 underlying_px: Option<Price>,
99 ) -> anyhow::Result<Money> {
100 match self {
101 Self::Fixed(model) => model.get_commission_with_context(
102 order,
103 fill_quantity,
104 fill_px,
105 instrument,
106 underlying_px,
107 ),
108 Self::MakerTaker(model) => model.get_commission_with_context(
109 order,
110 fill_quantity,
111 fill_px,
112 instrument,
113 underlying_px,
114 ),
115 Self::PerContract(model) => model.get_commission_with_context(
116 order,
117 fill_quantity,
118 fill_px,
119 instrument,
120 underlying_px,
121 ),
122 Self::CappedOption(model) => model.get_commission_with_context(
123 order,
124 fill_quantity,
125 fill_px,
126 instrument,
127 underlying_px,
128 ),
129 Self::TieredNotionalOption(model) => model.get_commission_with_context(
130 order,
131 fill_quantity,
132 fill_px,
133 instrument,
134 underlying_px,
135 ),
136 }
137 }
138}
139
140impl Default for FeeModelAny {
141 fn default() -> Self {
142 Self::MakerTaker(MakerTakerFeeModel)
143 }
144}
145
146#[derive(Debug, Clone)]
147#[cfg_attr(
148 feature = "python",
149 pyo3::pyclass(
150 module = "nautilus_trader.core.nautilus_pyo3.execution",
151 from_py_object
152 )
153)]
154#[cfg_attr(
155 feature = "python",
156 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
157)]
158pub struct FixedFeeModel {
159 commission: Money,
160 zero_commission: Money,
161 change_commission_once: bool,
162}
163
164impl FixedFeeModel {
165 pub fn new(commission: Money, change_commission_once: Option<bool>) -> anyhow::Result<Self> {
171 if commission.raw < 0 {
172 anyhow::bail!("Commission must be greater than or equal to zero")
173 }
174 let zero_commission = Money::zero(commission.currency);
175 Ok(Self {
176 commission,
177 zero_commission,
178 change_commission_once: change_commission_once.unwrap_or(true),
179 })
180 }
181}
182
183impl FeeModel for FixedFeeModel {
184 fn get_commission(
185 &self,
186 order: &OrderAny,
187 _fill_quantity: Quantity,
188 _fill_px: Price,
189 _instrument: &InstrumentAny,
190 ) -> anyhow::Result<Money> {
191 if !self.change_commission_once || order.filled_qty().is_zero() {
192 Ok(self.commission)
193 } else {
194 Ok(self.zero_commission)
195 }
196 }
197}
198
199#[derive(Debug, Clone)]
200#[cfg_attr(
201 feature = "python",
202 pyo3::pyclass(
203 module = "nautilus_trader.core.nautilus_pyo3.execution",
204 from_py_object
205 )
206)]
207#[cfg_attr(
208 feature = "python",
209 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
210)]
211pub struct PerContractFeeModel {
212 commission: Money,
213}
214
215impl PerContractFeeModel {
216 pub fn new(commission: Money) -> anyhow::Result<Self> {
222 if commission.raw < 0 {
223 anyhow::bail!("Commission must be greater than or equal to zero")
224 }
225 Ok(Self { commission })
226 }
227}
228
229impl FeeModel for PerContractFeeModel {
230 fn get_commission(
231 &self,
232 _order: &OrderAny,
233 fill_quantity: Quantity,
234 _fill_px: Price,
235 _instrument: &InstrumentAny,
236 ) -> anyhow::Result<Money> {
237 let total = self.commission.as_decimal() * fill_quantity.as_decimal();
238 Money::from_decimal(total, self.commission.currency).map_err(Into::into)
239 }
240}
241
242#[derive(Debug, Clone)]
243#[cfg_attr(
244 feature = "python",
245 pyo3::pyclass(
246 module = "nautilus_trader.core.nautilus_pyo3.execution",
247 from_py_object
248 )
249)]
250#[cfg_attr(
251 feature = "python",
252 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
253)]
254pub struct MakerTakerFeeModel;
255
256impl FeeModel for MakerTakerFeeModel {
257 fn get_commission(
258 &self,
259 order: &OrderAny,
260 fill_quantity: Quantity,
261 fill_px: Price,
262 instrument: &InstrumentAny,
263 ) -> anyhow::Result<Money> {
264 let notional = instrument.calculate_notional_value(fill_quantity, fill_px, Some(false));
265 let commission = match order.liquidity_side() {
266 Some(LiquiditySide::Maker) => notional * instrument.maker_fee(),
267 Some(LiquiditySide::Taker) => notional * instrument.taker_fee(),
268 Some(LiquiditySide::NoLiquiditySide) | None => anyhow::bail!("Liquidity side not set"),
269 };
270
271 if instrument.is_inverse() {
272 Money::from_decimal(commission, instrument.base_currency().unwrap()).map_err(Into::into)
273 } else {
274 Money::from_decimal(commission, instrument.quote_currency()).map_err(Into::into)
275 }
276 }
277}
278
279#[derive(Clone)]
280#[cfg_attr(
281 feature = "python",
282 pyo3::pyclass(
283 module = "nautilus_trader.core.nautilus_pyo3.execution",
284 from_py_object
285 )
286)]
287#[cfg_attr(
288 feature = "python",
289 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
290)]
291pub struct CappedOptionFeeModel {
292 maker_rate: Option<Decimal>,
293 taker_rate: Option<Decimal>,
294 cap: Decimal,
295}
296
297impl Debug for CappedOptionFeeModel {
298 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299 f.debug_struct(stringify!(CappedOptionFeeModel))
300 .field("maker_rate", &self.maker_rate)
301 .field("taker_rate", &self.taker_rate)
302 .field("cap_rate", &self.cap)
303 .finish()
304 }
305}
306
307impl CappedOptionFeeModel {
308 pub fn new(
314 maker_rate: Option<Decimal>,
315 taker_rate: Option<Decimal>,
316 cap_rate: Option<Decimal>,
317 ) -> anyhow::Result<Self> {
318 check_fee_rate(maker_rate, "maker_rate")?;
319 check_fee_rate(taker_rate, "taker_rate")?;
320
321 let cap_rate = cap_rate.unwrap_or(dec!(0.125));
322 check_fee_rate(Some(cap_rate), "cap_rate")?;
323
324 Ok(Self {
325 maker_rate,
326 taker_rate,
327 cap: cap_rate,
328 })
329 }
330}
331
332impl Default for CappedOptionFeeModel {
333 fn default() -> Self {
334 Self::new(None, None, None).unwrap()
335 }
336}
337
338impl FeeModel for CappedOptionFeeModel {
339 fn get_commission(
340 &self,
341 order: &OrderAny,
342 fill_quantity: Quantity,
343 fill_px: Price,
344 instrument: &InstrumentAny,
345 ) -> anyhow::Result<Money> {
346 self.get_commission_with_context(order, fill_quantity, fill_px, instrument, None)
347 }
348
349 fn get_commission_with_context(
350 &self,
351 order: &OrderAny,
352 fill_quantity: Quantity,
353 fill_px: Price,
354 instrument: &InstrumentAny,
355 underlying_px: Option<Price>,
356 ) -> anyhow::Result<Money> {
357 check_option_instrument(instrument, "CappedOptionFeeModel")?;
358 let rate = option_fee_rate(order, instrument, self.maker_rate, self.taker_rate)?;
359 let multiplier = instrument.multiplier().as_decimal();
360 let rate_fee = if instrument.is_inverse() {
361 rate
362 } else {
363 let underlying_px =
364 underlying_px.ok_or_else(|| anyhow::anyhow!("Underlying price is required"))?;
365 rate * underlying_px.as_decimal()
366 };
367 let cap_fee = self.cap * fill_px.as_decimal();
368 let fee_per_contract = rate_fee.min(cap_fee) * multiplier;
369 let total = fee_per_contract * fill_quantity.as_decimal();
370 Money::from_decimal(total, commission_currency(instrument)).map_err(Into::into)
371 }
372}
373
374#[derive(Debug, Clone)]
375#[cfg_attr(
376 feature = "python",
377 pyo3::pyclass(
378 module = "nautilus_trader.core.nautilus_pyo3.execution",
379 from_py_object
380 )
381)]
382#[cfg_attr(
383 feature = "python",
384 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
385)]
386pub struct TieredNotionalOptionFeeModel {
387 maker_rate: Option<Decimal>,
388 taker_rate: Option<Decimal>,
389}
390
391impl TieredNotionalOptionFeeModel {
392 pub fn new(maker_rate: Option<Decimal>, taker_rate: Option<Decimal>) -> anyhow::Result<Self> {
398 check_fee_rate(maker_rate, "maker_rate")?;
399 check_fee_rate(taker_rate, "taker_rate")?;
400
401 Ok(Self {
402 maker_rate,
403 taker_rate,
404 })
405 }
406}
407
408impl Default for TieredNotionalOptionFeeModel {
409 fn default() -> Self {
410 Self::new(None, None).unwrap()
411 }
412}
413
414impl FeeModel for TieredNotionalOptionFeeModel {
415 fn get_commission(
416 &self,
417 order: &OrderAny,
418 fill_quantity: Quantity,
419 fill_px: Price,
420 instrument: &InstrumentAny,
421 ) -> anyhow::Result<Money> {
422 check_option_instrument(instrument, "TieredNotionalOptionFeeModel")?;
423 let rate = option_fee_rate(order, instrument, self.maker_rate, self.taker_rate)?;
424 let notional = instrument.calculate_notional_value(fill_quantity, fill_px, Some(false));
425 let total = notional.as_decimal() * rate;
426 Money::from_decimal(total, notional.currency).map_err(Into::into)
427 }
428}
429
430fn option_fee_rate(
431 order: &OrderAny,
432 instrument: &InstrumentAny,
433 maker_rate: Option<Decimal>,
434 taker_rate: Option<Decimal>,
435) -> anyhow::Result<Decimal> {
436 let rate = match order.liquidity_side() {
437 Some(LiquiditySide::Maker) => maker_rate.unwrap_or_else(|| instrument.maker_fee()),
438 Some(LiquiditySide::Taker) => taker_rate.unwrap_or_else(|| instrument.taker_fee()),
439 Some(LiquiditySide::NoLiquiditySide) | None => anyhow::bail!("Liquidity side not set"),
440 };
441 check_fee_rate(Some(rate), "fee_rate")?;
442 Ok(rate)
443}
444
445fn check_fee_rate(rate: Option<Decimal>, name: &str) -> anyhow::Result<()> {
446 if rate.is_some_and(|rate| rate < Decimal::ZERO) {
447 anyhow::bail!("`{name}` must be greater than or equal to zero");
448 }
449 Ok(())
450}
451
452fn check_option_instrument(instrument: &InstrumentAny, model_name: &str) -> anyhow::Result<()> {
453 if !matches!(
454 instrument,
455 InstrumentAny::CryptoOption(_) | InstrumentAny::OptionContract(_)
456 ) {
457 anyhow::bail!("{model_name} requires an option instrument");
458 }
459 Ok(())
460}
461
462fn commission_currency(instrument: &InstrumentAny) -> Currency {
463 if instrument.is_inverse() {
464 instrument.settlement_currency()
465 } else {
466 instrument.quote_currency()
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use nautilus_model::{
473 enums::{LiquiditySide, OrderSide, OrderType},
474 instruments::{
475 CryptoOption, Instrument, InstrumentAny, OptionContract,
476 stubs::{audusd_sim, crypto_option_btc_deribit, option_contract_appl},
477 },
478 orders::{
479 Order, OrderAny,
480 builder::OrderTestBuilder,
481 stubs::{TestOrderEventStubs, TestOrderStubs},
482 },
483 types::{Currency, Money, Price, Quantity},
484 };
485 use rstest::rstest;
486 use rust_decimal::Decimal;
487 use rust_decimal_macros::dec;
488
489 use super::{
490 CappedOptionFeeModel, FeeModel, FeeModelAny, FixedFeeModel, MakerTakerFeeModel,
491 PerContractFeeModel, TieredNotionalOptionFeeModel,
492 };
493
494 #[rstest]
495 fn test_fixed_model_single_fill() {
496 let expected_commission = Money::new(1.0, Currency::USD());
497 let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
498 let fee_model = FixedFeeModel::new(expected_commission, None).unwrap();
499 let market_order = OrderTestBuilder::new(OrderType::Market)
500 .instrument_id(aud_usd.id())
501 .side(OrderSide::Buy)
502 .quantity(Quantity::from(100_000))
503 .build();
504 let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
505 let commission = fee_model
506 .get_commission(
507 &accepted_order,
508 Quantity::from(100_000),
509 Price::from("1.0"),
510 &aud_usd,
511 )
512 .unwrap();
513 assert_eq!(commission, expected_commission);
514 }
515
516 #[rstest]
517 #[case(OrderSide::Buy, true, Money::from("1 USD"), Money::from("0 USD"))]
518 #[case(OrderSide::Sell, true, Money::from("1 USD"), Money::from("0 USD"))]
519 #[case(OrderSide::Buy, false, Money::from("1 USD"), Money::from("1 USD"))]
520 #[case(OrderSide::Sell, false, Money::from("1 USD"), Money::from("1 USD"))]
521 fn test_fixed_model_multiple_fills(
522 #[case] order_side: OrderSide,
523 #[case] charge_commission_once: bool,
524 #[case] expected_first_fill: Money,
525 #[case] expected_next_fill: Money,
526 ) {
527 let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
528 let fee_model =
529 FixedFeeModel::new(expected_first_fill, Some(charge_commission_once)).unwrap();
530 let market_order = OrderTestBuilder::new(OrderType::Market)
531 .instrument_id(aud_usd.id())
532 .side(order_side)
533 .quantity(Quantity::from(100_000))
534 .build();
535 let mut accepted_order = TestOrderStubs::make_accepted_order(&market_order);
536 let commission_first_fill = fee_model
537 .get_commission(
538 &accepted_order,
539 Quantity::from(50_000),
540 Price::from("1.0"),
541 &aud_usd,
542 )
543 .unwrap();
544 let fill = TestOrderEventStubs::filled(
545 &accepted_order,
546 &aud_usd,
547 None,
548 None,
549 None,
550 Some(Quantity::from(50_000)),
551 None,
552 None,
553 None,
554 None,
555 );
556 accepted_order.apply(fill).unwrap();
557 let commission_next_fill = fee_model
558 .get_commission(
559 &accepted_order,
560 Quantity::from(50_000),
561 Price::from("1.0"),
562 &aud_usd,
563 )
564 .unwrap();
565 assert_eq!(commission_first_fill, expected_first_fill);
566 assert_eq!(commission_next_fill, expected_next_fill);
567 }
568
569 #[rstest]
570 fn test_maker_taker_fee_model_maker_commission() {
571 let fee_model = MakerTakerFeeModel;
572 let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
573 let maker_fee = aud_usd.maker_fee();
574 let price = Price::from("1.0");
575 let limit_order = OrderTestBuilder::new(OrderType::Limit)
576 .instrument_id(aud_usd.id())
577 .side(OrderSide::Sell)
578 .price(price)
579 .quantity(Quantity::from(100_000))
580 .build();
581 let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
582 let expected_commission = fill.quantity().as_decimal() * price.as_decimal() * maker_fee;
583 let commission = fee_model
584 .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
585 .unwrap();
586 assert_eq!(commission.as_decimal(), expected_commission);
587 }
588
589 #[rstest]
590 fn test_maker_taker_fee_model_uses_decimal_rounding() {
591 let fee_model = MakerTakerFeeModel;
592 let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
593 let price = Price::from("1.0");
594 let quantity = Quantity::from("117250");
595 let limit_order = OrderTestBuilder::new(OrderType::Limit)
596 .instrument_id(aud_usd.id())
597 .side(OrderSide::Sell)
598 .price(price)
599 .quantity(quantity)
600 .build();
601 let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
602
603 let commission = fee_model
604 .get_commission(&fill, quantity, price, &aud_usd)
605 .unwrap();
606
607 assert_eq!(commission, Money::from("2.34 USD"));
608 }
609
610 #[rstest]
611 fn test_maker_taker_fee_model_taker_commission() {
612 let fee_model = MakerTakerFeeModel;
613 let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
614 let taker_fee = aud_usd.taker_fee();
615 let price = Price::from("1.0");
616 let limit_order = OrderTestBuilder::new(OrderType::Limit)
617 .instrument_id(aud_usd.id())
618 .side(OrderSide::Sell)
619 .price(price)
620 .quantity(Quantity::from(100_000))
621 .build();
622
623 let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Taker);
624 let expected_commission = fill.quantity().as_decimal() * price.as_decimal() * taker_fee;
625 let commission = fee_model
626 .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
627 .unwrap();
628 assert_eq!(commission.as_decimal(), expected_commission);
629 }
630
631 #[rstest]
632 fn test_per_contract_fee_model() {
633 let commission_per_contract = Money::new(0.50, Currency::USD());
634 let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
635 let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
636 let market_order = OrderTestBuilder::new(OrderType::Market)
637 .instrument_id(aud_usd.id())
638 .side(OrderSide::Buy)
639 .quantity(Quantity::from(100))
640 .build();
641 let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
642 let commission = fee_model
643 .get_commission(
644 &accepted_order,
645 Quantity::from(100),
646 Price::from("1.0"),
647 &aud_usd,
648 )
649 .unwrap();
650 assert_eq!(commission, Money::new(50.0, Currency::USD()));
651 }
652
653 #[rstest]
654 fn test_per_contract_fee_model_partial_fill() {
655 let commission_per_contract = Money::new(1.25, Currency::USD());
656 let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
657 let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
658 let market_order = OrderTestBuilder::new(OrderType::Market)
659 .instrument_id(aud_usd.id())
660 .side(OrderSide::Sell)
661 .quantity(Quantity::from(1000))
662 .build();
663 let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
664 let commission = fee_model
665 .get_commission(
666 &accepted_order,
667 Quantity::from(400),
668 Price::from("1.0"),
669 &aud_usd,
670 )
671 .unwrap();
672 assert_eq!(commission, Money::new(500.0, Currency::USD()));
673 }
674
675 #[rstest]
676 fn test_per_contract_fee_model_uses_decimal_rounding() {
677 let commission_per_contract = Money::from("0.50 USD");
678 let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
679 let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
680 let market_order = OrderTestBuilder::new(OrderType::Market)
681 .instrument_id(aud_usd.id())
682 .side(OrderSide::Buy)
683 .quantity(Quantity::from("5"))
684 .build();
685 let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
686
687 let commission = fee_model
688 .get_commission(
689 &accepted_order,
690 Quantity::from("4.69"),
691 Price::from("1.0"),
692 &aud_usd,
693 )
694 .unwrap();
695
696 assert_eq!(commission, Money::from("2.34 USD"));
697 }
698
699 #[rstest]
700 fn test_per_contract_fee_model_negative_commission_fails() {
701 let result = PerContractFeeModel::new(Money::new(-1.0, Currency::USD()));
702 assert!(result.is_err());
703 }
704
705 #[rstest]
706 #[case::maker(Some(dec!(-0.0001)), Some(dec!(0.0003)), None, "maker_rate")]
707 #[case::taker(Some(dec!(0.0001)), Some(dec!(-0.0003)), None, "taker_rate")]
708 #[case::cap(Some(dec!(0.0001)), Some(dec!(0.0003)), Some(dec!(-0.125)), "cap_rate")]
709 fn test_capped_option_fee_model_negative_rate_fails(
710 #[case] maker_rate: Option<Decimal>,
711 #[case] taker_rate: Option<Decimal>,
712 #[case] cap_rate: Option<Decimal>,
713 #[case] expected_field: &str,
714 ) {
715 let result = CappedOptionFeeModel::new(maker_rate, taker_rate, cap_rate);
716
717 assert_eq!(
718 result.unwrap_err().to_string(),
719 format!("`{expected_field}` must be greater than or equal to zero")
720 );
721 }
722
723 #[rstest]
724 fn test_capped_option_fee_model_maker_commission_rate_bound(
725 crypto_option_btc_deribit: CryptoOption,
726 ) {
727 let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
728 let fill = option_fill_order(&instrument, LiquiditySide::Maker);
729 let fee_model = FeeModelAny::CappedOption(
730 CappedOptionFeeModel::new(Some(dec!(0.0001)), Some(dec!(0.0003)), None).unwrap(),
731 );
732
733 let commission = fee_model
734 .get_commission_with_context(
735 &fill,
736 Quantity::from("2.0"),
737 Price::from("100.00"),
738 &instrument,
739 Some(Price::from("50000.00")),
740 )
741 .unwrap();
742
743 assert_eq!(commission.currency, Currency::USD());
744 assert_eq!(commission.as_decimal(), dec!(10.00));
745 }
746
747 #[rstest]
748 fn test_capped_option_fee_model_taker_commission_cap_bound(
749 crypto_option_btc_deribit: CryptoOption,
750 ) {
751 let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
752 let fill = option_fill_order(&instrument, LiquiditySide::Taker);
753 let fee_model =
754 CappedOptionFeeModel::new(Some(dec!(0.0001)), Some(dec!(0.0003)), None).unwrap();
755
756 let commission = fee_model
757 .get_commission_with_context(
758 &fill,
759 Quantity::from("2.0"),
760 Price::from("10.00"),
761 &instrument,
762 Some(Price::from("50000.00")),
763 )
764 .unwrap();
765
766 assert_eq!(commission.currency, Currency::USD());
767 assert_eq!(commission.as_decimal(), dec!(2.50));
768 }
769
770 #[rstest]
771 fn test_capped_option_fee_model_applies_contract_multiplier(
772 mut option_contract_appl: OptionContract,
773 ) {
774 option_contract_appl.multiplier = Quantity::from(100);
775 let instrument = InstrumentAny::OptionContract(option_contract_appl);
776 let fill = option_fill_order(&instrument, LiquiditySide::Maker);
777 let fee_model =
778 CappedOptionFeeModel::new(Some(dec!(0.0001)), Some(dec!(0.0003)), None).unwrap();
779
780 let commission = fee_model
781 .get_commission_with_context(
782 &fill,
783 Quantity::from("2"),
784 Price::from("2.00"),
785 &instrument,
786 Some(Price::from("150.00")),
787 )
788 .unwrap();
789
790 assert_eq!(commission.currency, Currency::USD());
791 assert_eq!(commission.as_decimal(), dec!(3.00));
792 }
793
794 #[rstest]
795 fn test_capped_option_fee_model_inverse_commission_uses_settlement_currency(
796 mut crypto_option_btc_deribit: CryptoOption,
797 ) {
798 crypto_option_btc_deribit.is_inverse = true;
799 let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
800 let fill = option_fill_order(&instrument, LiquiditySide::Taker);
801 let fee_model =
802 CappedOptionFeeModel::new(Some(dec!(0.0001)), Some(dec!(0.0003)), None).unwrap();
803
804 let commission = fee_model
805 .get_commission(
806 &fill,
807 Quantity::from("2.0"),
808 Price::from("0.010"),
809 &instrument,
810 )
811 .unwrap();
812
813 assert_eq!(commission.currency, Currency::BTC());
814 assert_eq!(commission.as_decimal(), dec!(0.0006));
815 }
816
817 #[rstest]
818 fn test_capped_option_fee_model_requires_underlying_price(
819 crypto_option_btc_deribit: CryptoOption,
820 ) {
821 let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
822 let fill = option_fill_order(&instrument, LiquiditySide::Taker);
823 let fee_model = CappedOptionFeeModel::default();
824
825 let result = fee_model.get_commission(
826 &fill,
827 Quantity::from("1.0"),
828 Price::from("10.00"),
829 &instrument,
830 );
831
832 assert!(result.is_err());
833 }
834
835 #[rstest]
836 fn test_capped_option_fee_model_rejects_non_option_instrument() {
837 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
838 let fill = option_fill_order(&instrument, LiquiditySide::Taker);
839 let fee_model = CappedOptionFeeModel::default();
840
841 let result = fee_model.get_commission_with_context(
842 &fill,
843 Quantity::from("1.0"),
844 Price::from("10.00"),
845 &instrument,
846 Some(Price::from("50000.00")),
847 );
848
849 assert!(result.is_err());
850 }
851
852 #[rstest]
853 #[case::maker(LiquiditySide::Maker, dec!(0.04))]
854 #[case::taker(LiquiditySide::Taker, dec!(0.10))]
855 fn test_tiered_notional_option_fee_model_commission(
856 crypto_option_btc_deribit: CryptoOption,
857 #[case] liquidity_side: LiquiditySide,
858 #[case] expected_commission: Decimal,
859 ) {
860 let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
861 let fill = option_fill_order(&instrument, liquidity_side);
862 let fee_model = FeeModelAny::TieredNotionalOption(
863 TieredNotionalOptionFeeModel::new(Some(dec!(0.0002)), Some(dec!(0.0005))).unwrap(),
864 );
865
866 let commission = fee_model
867 .get_commission(
868 &fill,
869 Quantity::from("2.0"),
870 Price::from("100.00"),
871 &instrument,
872 )
873 .unwrap();
874
875 assert_eq!(commission.currency, Currency::USD());
876 assert_eq!(commission.as_decimal(), expected_commission);
877 }
878
879 #[rstest]
880 fn test_tiered_notional_option_fee_model_inverse_commission_uses_base_currency(
881 mut crypto_option_btc_deribit: CryptoOption,
882 ) {
883 crypto_option_btc_deribit.is_inverse = true;
884 let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
885 let fill = option_fill_order(&instrument, LiquiditySide::Taker);
886 let fee_model =
887 TieredNotionalOptionFeeModel::new(Some(dec!(0.0002)), Some(dec!(0.0005))).unwrap();
888
889 let commission = fee_model
890 .get_commission(
891 &fill,
892 Quantity::from("2.0"),
893 Price::from("0.010"),
894 &instrument,
895 )
896 .unwrap();
897
898 assert_eq!(commission.currency, Currency::BTC());
899 assert_eq!(commission.as_decimal(), dec!(0.10));
900 }
901
902 #[rstest]
903 fn test_tiered_notional_option_fee_model_rejects_non_option_instrument() {
904 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
905 let fill = option_fill_order(&instrument, LiquiditySide::Taker);
906 let fee_model = TieredNotionalOptionFeeModel::default();
907
908 let result = fee_model.get_commission(
909 &fill,
910 Quantity::from("1.0"),
911 Price::from("10.00"),
912 &instrument,
913 );
914
915 assert!(result.is_err());
916 }
917
918 #[rstest]
919 #[case::maker(Some(dec!(-0.0002)), Some(dec!(0.0005)), "maker_rate")]
920 #[case::taker(Some(dec!(0.0002)), Some(dec!(-0.0005)), "taker_rate")]
921 fn test_tiered_notional_option_fee_model_negative_rate_fails(
922 #[case] maker_rate: Option<Decimal>,
923 #[case] taker_rate: Option<Decimal>,
924 #[case] expected_field: &str,
925 ) {
926 let result = TieredNotionalOptionFeeModel::new(maker_rate, taker_rate);
927
928 assert_eq!(
929 result.unwrap_err().to_string(),
930 format!("`{expected_field}` must be greater than or equal to zero")
931 );
932 }
933
934 #[rstest]
935 fn test_tiered_notional_option_fee_model_requires_liquidity_side(
936 crypto_option_btc_deribit: CryptoOption,
937 ) {
938 let instrument = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
939 let order = OrderTestBuilder::new(OrderType::Limit)
940 .instrument_id(instrument.id())
941 .side(OrderSide::Buy)
942 .price(Price::from("100.00"))
943 .quantity(Quantity::from("2.0"))
944 .build();
945 let fee_model = TieredNotionalOptionFeeModel::default();
946
947 let result = fee_model.get_commission(
948 &order,
949 Quantity::from("1.0"),
950 Price::from("10.00"),
951 &instrument,
952 );
953
954 assert!(result.is_err());
955 }
956
957 fn option_fill_order(instrument: &InstrumentAny, liquidity_side: LiquiditySide) -> OrderAny {
958 let limit_order = OrderTestBuilder::new(OrderType::Limit)
959 .instrument_id(instrument.id())
960 .side(OrderSide::Buy)
961 .price(Price::from("100.00"))
962 .quantity(Quantity::from("2.0"))
963 .build();
964
965 TestOrderStubs::make_filled_order(&limit_order, instrument, liquidity_side)
966 }
967}