nautilus_backtest/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::InstrumentAny,
19    orders::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::{stubs::audusd_sim, InstrumentAny},
131        orders::{
132            builder::OrderTestBuilder,
133            stubs::{TestOrderEventStubs, TestOrderStubs},
134        },
135        types::{Currency, Money, Price, Quantity},
136    };
137    use rstest::rstest;
138    use rust_decimal::prelude::ToPrimitive;
139
140    use crate::models::fee::{FeeModel, FixedFeeModel, MakerTakerFeeModel};
141
142    #[rstest]
143    fn test_fixed_model_single_fill() {
144        let expected_commission = Money::new(1.0, Currency::USD());
145        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
146        let fee_model = FixedFeeModel::new(expected_commission, None).unwrap();
147        let market_order = OrderTestBuilder::new(OrderType::Market)
148            .instrument_id(aud_usd.id())
149            .side(OrderSide::Buy)
150            .quantity(Quantity::from(100_000))
151            .build();
152        let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
153        let commission = fee_model
154            .get_commission(
155                &accepted_order,
156                Quantity::from(100_000),
157                Price::from("1.0"),
158                &aud_usd,
159            )
160            .unwrap();
161        assert_eq!(commission, expected_commission);
162    }
163
164    #[rstest]
165    #[case(OrderSide::Buy, true, Money::from("1 USD"), Money::from("0 USD"))]
166    #[case(OrderSide::Sell, true, Money::from("1 USD"), Money::from("0 USD"))]
167    #[case(OrderSide::Buy, false, Money::from("1 USD"), Money::from("1 USD"))]
168    #[case(OrderSide::Sell, false, Money::from("1 USD"), Money::from("1 USD"))]
169    fn test_fixed_model_multiple_fills(
170        #[case] order_side: OrderSide,
171        #[case] charge_commission_once: bool,
172        #[case] expected_first_fill: Money,
173        #[case] expected_next_fill: Money,
174    ) {
175        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
176        let fee_model =
177            FixedFeeModel::new(expected_first_fill, Some(charge_commission_once)).unwrap();
178        let market_order = OrderTestBuilder::new(OrderType::Market)
179            .instrument_id(aud_usd.id())
180            .side(order_side)
181            .quantity(Quantity::from(100_000))
182            .build();
183        let mut accepted_order = TestOrderStubs::make_accepted_order(&market_order);
184        let commission_first_fill = fee_model
185            .get_commission(
186                &accepted_order,
187                Quantity::from(50_000),
188                Price::from("1.0"),
189                &aud_usd,
190            )
191            .unwrap();
192        let fill = TestOrderEventStubs::order_filled(
193            &accepted_order,
194            &aud_usd,
195            None,
196            None,
197            None,
198            Some(Quantity::from(50_000)),
199            None,
200            None,
201            None,
202            None,
203        );
204        accepted_order.apply(fill).unwrap();
205        let commission_next_fill = fee_model
206            .get_commission(
207                &accepted_order,
208                Quantity::from(50_000),
209                Price::from("1.0"),
210                &aud_usd,
211            )
212            .unwrap();
213        assert_eq!(commission_first_fill, expected_first_fill);
214        assert_eq!(commission_next_fill, expected_next_fill);
215    }
216
217    #[rstest]
218    fn test_maker_taker_fee_model_maker_commission() {
219        let fee_model = MakerTakerFeeModel;
220        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
221        let maker_fee = aud_usd.maker_fee().to_f64().unwrap();
222        let price = Price::from("1.0");
223        let limit_order = OrderTestBuilder::new(OrderType::Limit)
224            .instrument_id(aud_usd.id())
225            .side(OrderSide::Sell)
226            .price(price)
227            .quantity(Quantity::from(100_000))
228            .build();
229        let order_filled =
230            TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Maker);
231        let expected_commission_amount =
232            order_filled.quantity().as_f64() * price.as_f64() * maker_fee;
233        let commission = fee_model
234            .get_commission(
235                &order_filled,
236                Quantity::from(100_000),
237                Price::from("1.0"),
238                &aud_usd,
239            )
240            .unwrap();
241        assert_eq!(commission.as_f64(), expected_commission_amount);
242    }
243
244    #[rstest]
245    fn test_maker_taker_fee_model_taker_commission() {
246        let fee_model = MakerTakerFeeModel;
247        let aud_usd = InstrumentAny::CurrencyPair(audusd_sim());
248        let maker_fee = aud_usd.taker_fee().to_f64().unwrap();
249        let price = Price::from("1.0");
250        let limit_order = OrderTestBuilder::new(OrderType::Limit)
251            .instrument_id(aud_usd.id())
252            .side(OrderSide::Sell)
253            .price(price)
254            .quantity(Quantity::from(100_000))
255            .build();
256
257        let order_filled =
258            TestOrderStubs::make_filled_order(&limit_order, &aud_usd, LiquiditySide::Taker);
259        let expected_commission_amount =
260            order_filled.quantity().as_f64() * price.as_f64() * maker_fee;
261        let commission = fee_model
262            .get_commission(
263                &order_filled,
264                Quantity::from(100_000),
265                Price::from("1.0"),
266                &aud_usd,
267            )
268            .unwrap();
269        assert_eq!(commission.as_f64(), expected_commission_amount);
270    }
271}