nautilus_execution/models/
fee.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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    fn get_commission(
26        &self,
27        order: &OrderAny,
28        fill_quantity: Quantity,
29        fill_px: Price,
30        instrument: &InstrumentAny,
31    ) -> anyhow::Result<Money>;
32}
33
34#[derive(Clone, Debug)]
35pub enum FeeModelAny {
36    Fixed(FixedFeeModel),
37    MakerTaker(MakerTakerFeeModel),
38}
39
40impl FeeModel for FeeModelAny {
41    fn get_commission(
42        &self,
43        order: &OrderAny,
44        fill_quantity: Quantity,
45        fill_px: Price,
46        instrument: &InstrumentAny,
47    ) -> anyhow::Result<Money> {
48        match self {
49            Self::Fixed(model) => model.get_commission(order, fill_quantity, fill_px, instrument),
50            Self::MakerTaker(model) => {
51                model.get_commission(order, fill_quantity, fill_px, instrument)
52            }
53        }
54    }
55}
56
57impl Default for FeeModelAny {
58    fn default() -> Self {
59        Self::MakerTaker(MakerTakerFeeModel)
60    }
61}
62
63#[derive(Debug, Clone)]
64pub struct FixedFeeModel {
65    commission: Money,
66    zero_commission: Money,
67    change_commission_once: bool,
68}
69
70impl FixedFeeModel {
71    /// Creates a new [`FixedFeeModel`] instance.
72    pub fn new(commission: Money, change_commission_once: Option<bool>) -> anyhow::Result<Self> {
73        if commission.as_f64() < 0.0 {
74            anyhow::bail!("Commission must be greater than or equal to zero.")
75        }
76        let zero_commission = Money::new(0.0, commission.currency);
77        Ok(Self {
78            commission,
79            zero_commission,
80            change_commission_once: change_commission_once.unwrap_or(true),
81        })
82    }
83}
84
85impl FeeModel for FixedFeeModel {
86    fn get_commission(
87        &self,
88        order: &OrderAny,
89        _fill_quantity: Quantity,
90        _fill_px: Price,
91        _instrument: &InstrumentAny,
92    ) -> anyhow::Result<Money> {
93        if !self.change_commission_once || order.filled_qty().is_zero() {
94            Ok(self.commission)
95        } else {
96            Ok(self.zero_commission)
97        }
98    }
99}
100
101#[derive(Debug, Clone)]
102pub struct MakerTakerFeeModel;
103
104impl FeeModel for MakerTakerFeeModel {
105    fn get_commission(
106        &self,
107        order: &OrderAny,
108        fill_quantity: Quantity,
109        fill_px: Price,
110        instrument: &InstrumentAny,
111    ) -> anyhow::Result<Money> {
112        let notional = instrument.calculate_notional_value(fill_quantity, fill_px, Some(false));
113        let commission = match order.liquidity_side() {
114            Some(LiquiditySide::Maker) => notional * instrument.maker_fee().to_f64().unwrap(),
115            Some(LiquiditySide::Taker) => notional * instrument.taker_fee().to_f64().unwrap(),
116            Some(LiquiditySide::NoLiquiditySide) | None => anyhow::bail!("Liquidity side not set."),
117        };
118        if instrument.is_inverse() {
119            Ok(Money::new(commission, instrument.base_currency().unwrap()))
120        } else {
121            Ok(Money::new(commission, instrument.quote_currency()))
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use nautilus_model::{
129        enums::{LiquiditySide, OrderSide, OrderType},
130        instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
131        orders::{
132            Order,
133            builder::OrderTestBuilder,
134            stubs::{TestOrderEventStubs, TestOrderStubs},
135        },
136        types::{Currency, Money, Price, Quantity},
137    };
138    use rstest::rstest;
139    use rust_decimal::prelude::ToPrimitive;
140
141    use super::{FeeModel, FixedFeeModel, MakerTakerFeeModel};
142
143    #[rstest]
144    fn test_fixed_model_single_fill() {
145        let expected_commission = Money::new(1.0, Currency::USD());
146        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
147        let fee_model = FixedFeeModel::new(expected_commission, None).unwrap();
148        let market_order = OrderTestBuilder::new(OrderType::Market)
149            .instrument_id(aud_usd.id())
150            .side(OrderSide::Buy)
151            .quantity(Quantity::from(100_000))
152            .build();
153        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
154        let commission = fee_model
155            .get_commission(
156                &accepted_order,
157                Quantity::from(100_000),
158                Price::from("1.0"),
159                &aud_usd,
160            )
161            .unwrap();
162        assert_eq!(commission, expected_commission);
163    }
164
165    #[rstest]
166    #[case(OrderSide::Buy, true, Money::from("1 USD"), Money::from("0 USD"))]
167    #[case(OrderSide::Sell, true, Money::from("1 USD"), Money::from("0 USD"))]
168    #[case(OrderSide::Buy, false, Money::from("1 USD"), Money::from("1 USD"))]
169    #[case(OrderSide::Sell, false, Money::from("1 USD"), Money::from("1 USD"))]
170    fn test_fixed_model_multiple_fills(
171        #[case] order_side: OrderSide,
172        #[case] charge_commission_once: bool,
173        #[case] expected_first_fill: Money,
174        #[case] expected_next_fill: Money,
175    ) {
176        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
177        let fee_model =
178            FixedFeeModel::new(expected_first_fill, Some(charge_commission_once)).unwrap();
179        let market_order = OrderTestBuilder::new(OrderType::Market)
180            .instrument_id(aud_usd.id())
181            .side(order_side)
182            .quantity(Quantity::from(100_000))
183            .build();
184        let mut accepted_order = TestOrderStubs::make_accepted_order(&market_order);
185        let commission_first_fill = fee_model
186            .get_commission(
187                &accepted_order,
188                Quantity::from(50_000),
189                Price::from("1.0"),
190                &aud_usd,
191            )
192            .unwrap();
193        let fill = TestOrderEventStubs::filled(
194            &accepted_order,
195            &aud_usd,
196            None,
197            None,
198            None,
199            Some(Quantity::from(50_000)),
200            None,
201            None,
202            None,
203            None,
204        );
205        accepted_order.apply(fill).unwrap();
206        let commission_next_fill = fee_model
207            .get_commission(
208                &accepted_order,
209                Quantity::from(50_000),
210                Price::from("1.0"),
211                &aud_usd,
212            )
213            .unwrap();
214        assert_eq!(commission_first_fill, expected_first_fill);
215        assert_eq!(commission_next_fill, expected_next_fill);
216    }
217
218    #[rstest]
219    fn test_maker_taker_fee_model_maker_commission() {
220        let fee_model = MakerTakerFeeModel;
221        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
222        let maker_fee = aud_usd.maker_fee().to_f64().unwrap();
223        let price = Price::from("1.0");
224        let limit_order = OrderTestBuilder::new(OrderType::Limit)
225            .instrument_id(aud_usd.id())
226            .side(OrderSide::Sell)
227            .price(price)
228            .quantity(Quantity::from(100_000))
229            .build();
230        let order_filled =
231            TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
232        let expected_commission_amount =
233            order_filled.quantity().as_f64() * price.as_f64() * maker_fee;
234        let commission = fee_model
235            .get_commission(
236                &order_filled,
237                Quantity::from(100_000),
238                Price::from("1.0"),
239                &aud_usd,
240            )
241            .unwrap();
242        assert_eq!(commission.as_f64(), expected_commission_amount);
243    }
244
245    #[rstest]
246    fn test_maker_taker_fee_model_taker_commission() {
247        let fee_model = MakerTakerFeeModel;
248        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
249        let maker_fee = aud_usd.taker_fee().to_f64().unwrap();
250        let price = Price::from("1.0");
251        let limit_order = OrderTestBuilder::new(OrderType::Limit)
252            .instrument_id(aud_usd.id())
253            .side(OrderSide::Sell)
254            .price(price)
255            .quantity(Quantity::from(100_000))
256            .build();
257
258        let order_filled =
259            TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Taker);
260        let expected_commission_amount =
261            order_filled.quantity().as_f64() * price.as_f64() * maker_fee;
262        let commission = fee_model
263            .get_commission(
264                &order_filled,
265                Quantity::from(100_000),
266                Price::from("1.0"),
267                &aud_usd,
268            )
269            .unwrap();
270        assert_eq!(commission.as_f64(), expected_commission_amount);
271    }
272}