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.raw < 0 {
83            anyhow::bail!("Commission must be greater than or equal to zero")
84        }
85        let zero_commission = Money::zero(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
149    use super::{FeeModel, FixedFeeModel, MakerTakerFeeModel};
150
151    #[rstest]
152    fn test_fixed_model_single_fill() {
153        let expected_commission = Money::new(1.0, Currency::USD());
154        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
155        let fee_model = FixedFeeModel::new(expected_commission, None).unwrap();
156        let market_order = OrderTestBuilder::new(OrderType::Market)
157            .instrument_id(aud_usd.id())
158            .side(OrderSide::Buy)
159            .quantity(Quantity::from(100_000))
160            .build();
161        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
162        let commission = fee_model
163            .get_commission(
164                &accepted_order,
165                Quantity::from(100_000),
166                Price::from("1.0"),
167                &aud_usd,
168            )
169            .unwrap();
170        assert_eq!(commission, expected_commission);
171    }
172
173    #[rstest]
174    #[case(OrderSide::Buy, true, Money::from("1 USD"), Money::from("0 USD"))]
175    #[case(OrderSide::Sell, true, Money::from("1 USD"), Money::from("0 USD"))]
176    #[case(OrderSide::Buy, false, Money::from("1 USD"), Money::from("1 USD"))]
177    #[case(OrderSide::Sell, false, Money::from("1 USD"), Money::from("1 USD"))]
178    fn test_fixed_model_multiple_fills(
179        #[case] order_side: OrderSide,
180        #[case] charge_commission_once: bool,
181        #[case] expected_first_fill: Money,
182        #[case] expected_next_fill: Money,
183    ) {
184        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
185        let fee_model =
186            FixedFeeModel::new(expected_first_fill, Some(charge_commission_once)).unwrap();
187        let market_order = OrderTestBuilder::new(OrderType::Market)
188            .instrument_id(aud_usd.id())
189            .side(order_side)
190            .quantity(Quantity::from(100_000))
191            .build();
192        let mut accepted_order = TestOrderStubs::make_accepted_order(&market_order);
193        let commission_first_fill = fee_model
194            .get_commission(
195                &accepted_order,
196                Quantity::from(50_000),
197                Price::from("1.0"),
198                &aud_usd,
199            )
200            .unwrap();
201        let fill = TestOrderEventStubs::filled(
202            &accepted_order,
203            &aud_usd,
204            None,
205            None,
206            None,
207            Some(Quantity::from(50_000)),
208            None,
209            None,
210            None,
211            None,
212        );
213        accepted_order.apply(fill).unwrap();
214        let commission_next_fill = fee_model
215            .get_commission(
216                &accepted_order,
217                Quantity::from(50_000),
218                Price::from("1.0"),
219                &aud_usd,
220            )
221            .unwrap();
222        assert_eq!(commission_first_fill, expected_first_fill);
223        assert_eq!(commission_next_fill, expected_next_fill);
224    }
225
226    #[rstest]
227    fn test_maker_taker_fee_model_maker_commission() {
228        let fee_model = MakerTakerFeeModel;
229        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
230        let maker_fee = aud_usd.maker_fee();
231        let price = Price::from("1.0");
232        let limit_order = OrderTestBuilder::new(OrderType::Limit)
233            .instrument_id(aud_usd.id())
234            .side(OrderSide::Sell)
235            .price(price)
236            .quantity(Quantity::from(100_000))
237            .build();
238        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
239        let expected_commission = fill.quantity().as_decimal() * price.as_decimal() * maker_fee;
240        let commission = fee_model
241            .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
242            .unwrap();
243        assert_eq!(commission.as_decimal(), expected_commission);
244    }
245
246    #[rstest]
247    fn test_maker_taker_fee_model_taker_commission() {
248        let fee_model = MakerTakerFeeModel;
249        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
250        let taker_fee = aud_usd.taker_fee();
251        let price = Price::from("1.0");
252        let limit_order = OrderTestBuilder::new(OrderType::Limit)
253            .instrument_id(aud_usd.id())
254            .side(OrderSide::Sell)
255            .price(price)
256            .quantity(Quantity::from(100_000))
257            .build();
258
259        let fill = TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Taker);
260        let expected_commission = fill.quantity().as_decimal() * price.as_decimal() * taker_fee;
261        let commission = fee_model
262            .get_commission(&fill, Quantity::from(100_000), Price::from("1.0"), &aud_usd)
263            .unwrap();
264        assert_eq!(commission.as_decimal(), expected_commission);
265    }
266}