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