nautilus_execution/models/
fee.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Posei Systems Pty Ltd. All rights reserved.
3//  https://poseitrader.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use nautilus_model::{
17    enums::LiquiditySide,
18    instruments::{Instrument, InstrumentAny},
19    orders::{Order, OrderAny},
20    types::{Money, Price, Quantity},
21};
22use rust_decimal::prelude::ToPrimitive;
23
24pub trait FeeModel {
25    /// Calculates commission for a fill.
26    ///
27    /// # Errors
28    ///
29    /// Returns an error if commission calculation fails.
30    fn get_commission(
31        &self,
32        order: &OrderAny,
33        fill_quantity: Quantity,
34        fill_px: Price,
35        instrument: &InstrumentAny,
36    ) -> anyhow::Result<Money>;
37}
38
39#[derive(Clone, Debug)]
40pub enum FeeModelAny {
41    Fixed(FixedFeeModel),
42    MakerTaker(MakerTakerFeeModel),
43}
44
45impl FeeModel for FeeModelAny {
46    fn get_commission(
47        &self,
48        order: &OrderAny,
49        fill_quantity: Quantity,
50        fill_px: Price,
51        instrument: &InstrumentAny,
52    ) -> anyhow::Result<Money> {
53        match self {
54            Self::Fixed(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
55            Self::MakerTaker(model) => {
56                model.get_commission(order, fill_quantity, fill_px, instrument)
57            }
58        }
59    }
60}
61
62impl Default for FeeModelAny {
63    fn default() -> Self {
64        Self::MakerTaker(MakerTakerFeeModel)
65    }
66}
67
68#[derive(Debug, Clone)]
69pub struct FixedFeeModel {
70    commission: Money,
71    zero_commission: Money,
72    change_commission_once: bool,
73}
74
75impl FixedFeeModel {
76    /// Creates a new [`FixedFeeModel`] instance.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if `commission` is negative.
81    pub fn new(commission: Money, change_commission_once: Option<bool>) -> anyhow::Result<Self> {
82        if commission.as_f64() < 0.0 {
83            anyhow::bail!("Commission must be greater than or equal to zero.")
84        }
85        let zero_commission = Money::new(0.0, commission.currency);
86        Ok(Self {
87            commission,
88            zero_commission,
89            change_commission_once: change_commission_once.unwrap_or(true),
90        })
91    }
92}
93
94impl FeeModel for FixedFeeModel {
95    fn get_commission(
96        &self,
97        order: &OrderAny,
98        _fill_quantity: Quantity,
99        _fill_px: Price,
100        _instrument: &InstrumentAny,
101    ) -> anyhow::Result<Money> {
102        if !self.change_commission_once || order.filled_qty().is_zero() {
103            Ok(self.commission)
104        } else {
105            Ok(self.zero_commission)
106        }
107    }
108}
109
110#[derive(Debug, Clone)]
111pub struct MakerTakerFeeModel;
112
113impl FeeModel for MakerTakerFeeModel {
114    fn get_commission(
115        &self,
116        order: &OrderAny,
117        fill_quantity: Quantity,
118        fill_px: Price,
119        instrument: &InstrumentAny,
120    ) -> anyhow::Result<Money> {
121        let notional = instrument.calculate_notional_value(fill_quantity, fill_px, Some(false));
122        let commission = match order.liquidity_side() {
123            Some(LiquiditySide::Maker) => notional * instrument.maker_fee().to_f64().unwrap(),
124            Some(LiquiditySide::Taker) => notional * instrument.taker_fee().to_f64().unwrap(),
125            Some(LiquiditySide::NoLiquiditySide) | None => anyhow::bail!("Liquidity side not set."),
126        };
127        if instrument.is_inverse() {
128            Ok(Money::new(commission, instrument.base_currency().unwrap()))
129        } else {
130            Ok(Money::new(commission, instrument.quote_currency()))
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use nautilus_model::{
138        enums::{LiquiditySide, OrderSide, OrderType},
139        instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
140        orders::{
141            Order,
142            builder::OrderTestBuilder,
143            stubs::{TestOrderEventStubs, TestOrderStubs},
144        },
145        types::{Currency, Money, Price, Quantity},
146    };
147    use rstest::rstest;
148    use rust_decimal::prelude::ToPrimitive;
149
150    use super::{FeeModel, FixedFeeModel, MakerTakerFeeModel};
151
152    #[rstest]
153    fn test_fixed_model_single_fill() {
154        let expected_commission = Money::new(1.0, Currency::USD());
155        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
156        let fee_model = FixedFeeModel::new(expected_commission, None).unwrap();
157        let market_order = OrderTestBuilder::new(OrderType::Market)
158            .instrument_id(aud_usd.id())
159            .side(OrderSide::Buy)
160            .quantity(Quantity::from(100_000))
161            .build();
162        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
163        let commission = fee_model
164            .get_commission(
165                &accepted_order,
166                Quantity::from(100_000),
167                Price::from("1.0"),
168                &aud_usd,
169            )
170            .unwrap();
171        assert_eq!(commission, expected_commission);
172    }
173
174    #[rstest]
175    #[case(OrderSide::Buy, true, Money::from("1 USD"), Money::from("0 USD"))]
176    #[case(OrderSide::Sell, true, Money::from("1 USD"), Money::from("0 USD"))]
177    #[case(OrderSide::Buy, false, Money::from("1 USD"), Money::from("1 USD"))]
178    #[case(OrderSide::Sell, false, Money::from("1 USD"), Money::from("1 USD"))]
179    fn test_fixed_model_multiple_fills(
180        #[case] order_side: OrderSide,
181        #[case] charge_commission_once: bool,
182        #[case] expected_first_fill: Money,
183        #[case] expected_next_fill: Money,
184    ) {
185        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
186        let fee_model =
187            FixedFeeModel::new(expected_first_fill, Some(charge_commission_once)).unwrap();
188        let market_order = OrderTestBuilder::new(OrderType::Market)
189            .instrument_id(aud_usd.id())
190            .side(order_side)
191            .quantity(Quantity::from(100_000))
192            .build();
193        let mut accepted_order = TestOrderStubs::make_accepted_order(&market_order);
194        let commission_first_fill = fee_model
195            .get_commission(
196                &accepted_order,
197                Quantity::from(50_000),
198                Price::from("1.0"),
199                &aud_usd,
200            )
201            .unwrap();
202        let fill = TestOrderEventStubs::filled(
203            &accepted_order,
204            &aud_usd,
205            None,
206            None,
207            None,
208            Some(Quantity::from(50_000)),
209            None,
210            None,
211            None,
212            None,
213        );
214        accepted_order.apply(fill).unwrap();
215        let commission_next_fill = fee_model
216            .get_commission(
217                &accepted_order,
218                Quantity::from(50_000),
219                Price::from("1.0"),
220                &aud_usd,
221            )
222            .unwrap();
223        assert_eq!(commission_first_fill, expected_first_fill);
224        assert_eq!(commission_next_fill, expected_next_fill);
225    }
226
227    #[rstest]
228    fn test_maker_taker_fee_model_maker_commission() {
229        let fee_model = MakerTakerFeeModel;
230        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
231        let maker_fee = aud_usd.maker_fee().to_f64().unwrap();
232        let price = Price::from("1.0");
233        let limit_order = OrderTestBuilder::new(OrderType::Limit)
234            .instrument_id(aud_usd.id())
235            .side(OrderSide::Sell)
236            .price(price)
237            .quantity(Quantity::from(100_000))
238            .build();
239        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
240        let expected_commission_amount = fill.quantity().as_f64() * price.as_f64() * maker_fee;
241        let commission = fee_model
242            .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
243            .unwrap();
244        assert_eq!(commission.as_f64(), expected_commission_amount);
245    }
246
247    #[rstest]
248    fn test_maker_taker_fee_model_taker_commission() {
249        let fee_model = MakerTakerFeeModel;
250        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
251        let maker_fee = aud_usd.taker_fee().to_f64().unwrap();
252        let price = Price::from("1.0");
253        let limit_order = OrderTestBuilder::new(OrderType::Limit)
254            .instrument_id(aud_usd.id())
255            .side(OrderSide::Sell)
256            .price(price)
257            .quantity(Quantity::from(100_000))
258            .build();
259
260        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Taker);
261        let expected_commission_amount = fill.quantity().as_f64() * price.as_f64() * maker_fee;
262        let commission = fee_model
263            .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
264            .unwrap();
265        assert_eq!(commission.as_f64(), expected_commission_amount);
266    }
267}