Skip to main content

nautilus_execution/models/
fee.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.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    PerContract(PerContractFeeModel),
44}
45
46impl FeeModel for FeeModelAny {
47    fn get_commission(
48        &self,
49        order: &OrderAny,
50        fill_quantity: Quantity,
51        fill_px: Price,
52        instrument: &InstrumentAny,
53    ) -> anyhow::Result<Money> {
54        match self {
55            Self::Fixed(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
56            Self::MakerTaker(model) => {
57                model.get_commission(order, fill_quantity, fill_px, instrument)
58            }
59            Self::PerContract(model) => {
60                model.get_commission(order, fill_quantity, fill_px, instrument)
61            }
62        }
63    }
64}
65
66impl Default for FeeModelAny {
67    fn default() -> Self {
68        Self::MakerTaker(MakerTakerFeeModel)
69    }
70}
71
72#[derive(Debug, Clone)]
73#[cfg_attr(
74    feature = "python",
75    pyo3::pyclass(
76        module = "nautilus_trader.core.nautilus_pyo3.execution",
77        from_py_object
78    )
79)]
80#[cfg_attr(
81    feature = "python",
82    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
83)]
84pub struct FixedFeeModel {
85    commission: Money,
86    zero_commission: Money,
87    change_commission_once: bool,
88}
89
90impl FixedFeeModel {
91    /// Creates a new [`FixedFeeModel`] instance.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if `commission` is negative.
96    pub fn new(commission: Money, change_commission_once: Option<bool>) -> anyhow::Result<Self> {
97        if commission.raw < 0 {
98            anyhow::bail!("Commission must be greater than or equal to zero")
99        }
100        let zero_commission = Money::zero(commission.currency);
101        Ok(Self {
102            commission,
103            zero_commission,
104            change_commission_once: change_commission_once.unwrap_or(true),
105        })
106    }
107}
108
109impl FeeModel for FixedFeeModel {
110    fn get_commission(
111        &self,
112        order: &OrderAny,
113        _fill_quantity: Quantity,
114        _fill_px: Price,
115        _instrument: &InstrumentAny,
116    ) -> anyhow::Result<Money> {
117        if !self.change_commission_once || order.filled_qty().is_zero() {
118            Ok(self.commission)
119        } else {
120            Ok(self.zero_commission)
121        }
122    }
123}
124
125#[derive(Debug, Clone)]
126#[cfg_attr(
127    feature = "python",
128    pyo3::pyclass(
129        module = "nautilus_trader.core.nautilus_pyo3.execution",
130        from_py_object
131    )
132)]
133#[cfg_attr(
134    feature = "python",
135    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
136)]
137pub struct PerContractFeeModel {
138    commission: Money,
139}
140
141impl PerContractFeeModel {
142    /// Creates a new [`PerContractFeeModel`] instance.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if `commission` is negative.
147    pub fn new(commission: Money) -> anyhow::Result<Self> {
148        if commission.raw < 0 {
149            anyhow::bail!("Commission must be greater than or equal to zero")
150        }
151        Ok(Self { commission })
152    }
153}
154
155impl FeeModel for PerContractFeeModel {
156    fn get_commission(
157        &self,
158        _order: &OrderAny,
159        fill_quantity: Quantity,
160        _fill_px: Price,
161        _instrument: &InstrumentAny,
162    ) -> anyhow::Result<Money> {
163        let total = self.commission.as_f64() * fill_quantity.as_f64();
164        Ok(Money::new(total, self.commission.currency))
165    }
166}
167
168#[derive(Debug, Clone)]
169#[cfg_attr(
170    feature = "python",
171    pyo3::pyclass(
172        module = "nautilus_trader.core.nautilus_pyo3.execution",
173        from_py_object
174    )
175)]
176#[cfg_attr(
177    feature = "python",
178    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.execution")
179)]
180pub struct MakerTakerFeeModel;
181
182impl FeeModel for MakerTakerFeeModel {
183    fn get_commission(
184        &self,
185        order: &OrderAny,
186        fill_quantity: Quantity,
187        fill_px: Price,
188        instrument: &InstrumentAny,
189    ) -> anyhow::Result<Money> {
190        let notional = instrument.calculate_notional_value(fill_quantity, fill_px, Some(false));
191        let commission = match order.liquidity_side() {
192            Some(LiquiditySide::Maker) => notional * instrument.maker_fee().to_f64().unwrap(),
193            Some(LiquiditySide::Taker) => notional * instrument.taker_fee().to_f64().unwrap(),
194            Some(LiquiditySide::NoLiquiditySide) | None => anyhow::bail!("Liquidity side not set"),
195        };
196
197        if instrument.is_inverse() {
198            Ok(Money::new(commission, instrument.base_currency().unwrap()))
199        } else {
200            Ok(Money::new(commission, instrument.quote_currency()))
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use nautilus_model::{
208        enums::{LiquiditySide, OrderSide, OrderType},
209        instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
210        orders::{
211            Order,
212            builder::OrderTestBuilder,
213            stubs::{TestOrderEventStubs, TestOrderStubs},
214        },
215        types::{Currency, Money, Price, Quantity},
216    };
217    use rstest::rstest;
218
219    use super::{FeeModel, FixedFeeModel, MakerTakerFeeModel, PerContractFeeModel};
220
221    #[rstest]
222    fn test_fixed_model_single_fill() {
223        let expected_commission = Money::new(1.0, Currency::USD());
224        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
225        let fee_model = FixedFeeModel::new(expected_commission, None).unwrap();
226        let market_order = OrderTestBuilder::new(OrderType::Market)
227            .instrument_id(aud_usd.id())
228            .side(OrderSide::Buy)
229            .quantity(Quantity::from(100_000))
230            .build();
231        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
232        let commission = fee_model
233            .get_commission(
234                &accepted_order,
235                Quantity::from(100_000),
236                Price::from("1.0"),
237                &aud_usd,
238            )
239            .unwrap();
240        assert_eq!(commission, expected_commission);
241    }
242
243    #[rstest]
244    #[case(OrderSide::Buy, true, Money::from("1 USD"), Money::from("0 USD"))]
245    #[case(OrderSide::Sell, true, Money::from("1 USD"), Money::from("0 USD"))]
246    #[case(OrderSide::Buy, false, Money::from("1 USD"), Money::from("1 USD"))]
247    #[case(OrderSide::Sell, false, Money::from("1 USD"), Money::from("1 USD"))]
248    fn test_fixed_model_multiple_fills(
249        #[case] order_side: OrderSide,
250        #[case] charge_commission_once: bool,
251        #[case] expected_first_fill: Money,
252        #[case] expected_next_fill: Money,
253    ) {
254        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
255        let fee_model =
256            FixedFeeModel::new(expected_first_fill, Some(charge_commission_once)).unwrap();
257        let market_order = OrderTestBuilder::new(OrderType::Market)
258            .instrument_id(aud_usd.id())
259            .side(order_side)
260            .quantity(Quantity::from(100_000))
261            .build();
262        let mut accepted_order = TestOrderStubs::make_accepted_order(&market_order);
263        let commission_first_fill = fee_model
264            .get_commission(
265                &accepted_order,
266                Quantity::from(50_000),
267                Price::from("1.0"),
268                &aud_usd,
269            )
270            .unwrap();
271        let fill = TestOrderEventStubs::filled(
272            &accepted_order,
273            &aud_usd,
274            None,
275            None,
276            None,
277            Some(Quantity::from(50_000)),
278            None,
279            None,
280            None,
281            None,
282        );
283        accepted_order.apply(fill).unwrap();
284        let commission_next_fill = fee_model
285            .get_commission(
286                &accepted_order,
287                Quantity::from(50_000),
288                Price::from("1.0"),
289                &aud_usd,
290            )
291            .unwrap();
292        assert_eq!(commission_first_fill, expected_first_fill);
293        assert_eq!(commission_next_fill, expected_next_fill);
294    }
295
296    #[rstest]
297    fn test_maker_taker_fee_model_maker_commission() {
298        let fee_model = MakerTakerFeeModel;
299        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
300        let maker_fee = aud_usd.maker_fee();
301        let price = Price::from("1.0");
302        let limit_order = OrderTestBuilder::new(OrderType::Limit)
303            .instrument_id(aud_usd.id())
304            .side(OrderSide::Sell)
305            .price(price)
306            .quantity(Quantity::from(100_000))
307            .build();
308        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
309        let expected_commission = fill.quantity().as_decimal() * price.as_decimal() * maker_fee;
310        let commission = fee_model
311            .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
312            .unwrap();
313        assert_eq!(commission.as_decimal(), expected_commission);
314    }
315
316    #[rstest]
317    fn test_maker_taker_fee_model_taker_commission() {
318        let fee_model = MakerTakerFeeModel;
319        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
320        let taker_fee = aud_usd.taker_fee();
321        let price = Price::from("1.0");
322        let limit_order = OrderTestBuilder::new(OrderType::Limit)
323            .instrument_id(aud_usd.id())
324            .side(OrderSide::Sell)
325            .price(price)
326            .quantity(Quantity::from(100_000))
327            .build();
328
329        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Taker);
330        let expected_commission = fill.quantity().as_decimal() * price.as_decimal() * taker_fee;
331        let commission = fee_model
332            .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
333            .unwrap();
334        assert_eq!(commission.as_decimal(), expected_commission);
335    }
336
337    #[rstest]
338    fn test_per_contract_fee_model() {
339        let commission_per_contract = Money::new(0.50, Currency::USD());
340        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
341        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
342        let market_order = OrderTestBuilder::new(OrderType::Market)
343            .instrument_id(aud_usd.id())
344            .side(OrderSide::Buy)
345            .quantity(Quantity::from(100))
346            .build();
347        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
348        let commission = fee_model
349            .get_commission(
350                &accepted_order,
351                Quantity::from(100),
352                Price::from("1.0"),
353                &aud_usd,
354            )
355            .unwrap();
356        assert_eq!(commission, Money::new(50.0, Currency::USD()));
357    }
358
359    #[rstest]
360    fn test_per_contract_fee_model_partial_fill() {
361        let commission_per_contract = Money::new(1.25, Currency::USD());
362        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
363        let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
364        let market_order = OrderTestBuilder::new(OrderType::Market)
365            .instrument_id(aud_usd.id())
366            .side(OrderSide::Sell)
367            .quantity(Quantity::from(1000))
368            .build();
369        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
370        let commission = fee_model
371            .get_commission(
372                &accepted_order,
373                Quantity::from(400),
374                Price::from("1.0"),
375                &aud_usd,
376            )
377            .unwrap();
378        assert_eq!(commission, Money::new(500.0, Currency::USD()));
379    }
380
381    #[rstest]
382    fn test_per_contract_fee_model_negative_commission_fails() {
383        let result = PerContractFeeModel::new(Money::new(-1.0, Currency::USD()));
384        assert!(result.is_err());
385    }
386}