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