Skip to main content

nautilus_model/
fees.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
16//! Account-owned fee schedules decoupled from instrument definitions.
17//!
18//! Fee policy belongs to the execution runtime serving an account, not to the
19//! instrument. Two accounts can trade the same instrument with different fees
20//! without constructing different instrument definitions.
21
22use ahash::AHashMap;
23use rust_decimal::Decimal;
24use serde::{Deserialize, Serialize};
25
26use crate::{
27    enums::LiquiditySide,
28    identifiers::InstrumentId,
29    instruments::{Instrument, InstrumentAny},
30    types::{Money, Price, Quantity},
31};
32
33/// Explicit maker/taker fee rates as a fraction of notional value.
34///
35/// Rates are expressed as decimals (for example `0.001` is 0.1%). Negative
36/// maker rates represent rebates where the venue supports them.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub struct MakerTakerFeeRates {
39    /// The maker fee rate.
40    pub maker: Decimal,
41    /// The taker fee rate.
42    pub taker: Decimal,
43}
44
45impl MakerTakerFeeRates {
46    /// Creates new [`MakerTakerFeeRates`] with explicit rates.
47    #[must_use]
48    pub const fn new(maker: Decimal, taker: Decimal) -> Self {
49        Self { maker, taker }
50    }
51
52    /// Returns an explicit zero-fee rate pair.
53    #[must_use]
54    pub const fn zero() -> Self {
55        Self {
56            maker: Decimal::ZERO,
57            taker: Decimal::ZERO,
58        }
59    }
60
61    /// Returns the rate for the given liquidity side.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if `liquidity_side` is [`LiquiditySide::NoLiquiditySide`].
66    pub fn rate_for(&self, liquidity_side: LiquiditySide) -> anyhow::Result<Decimal> {
67        match liquidity_side {
68            LiquiditySide::Maker => Ok(self.maker),
69            LiquiditySide::Taker => Ok(self.taker),
70            LiquiditySide::NoLiquiditySide => {
71                anyhow::bail!("Invalid `LiquiditySide`: {liquidity_side}")
72            }
73        }
74    }
75}
76
77/// Account-owned maker/taker fee schedule with exact instrument overrides.
78///
79/// Resolution is deterministic: an exact [`InstrumentId`] override wins, otherwise
80/// the default applies. Absent configuration is represented by the absence of a
81/// schedule (`None` at the owner), which is distinct from an explicit zero rate.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct MakerTakerFeeSchedule {
84    /// The default rates applied when no override matches.
85    pub default: MakerTakerFeeRates,
86    /// Exact per-instrument rate overrides.
87    pub overrides: AHashMap<InstrumentId, MakerTakerFeeRates>,
88}
89
90impl MakerTakerFeeSchedule {
91    /// Creates a new schedule with explicit default rates and no overrides.
92    #[must_use]
93    pub fn new(maker: Decimal, taker: Decimal) -> Self {
94        Self {
95            default: MakerTakerFeeRates::new(maker, taker),
96            overrides: AHashMap::new(),
97        }
98    }
99
100    /// Creates a new explicit zero-fee schedule.
101    #[must_use]
102    pub fn zero() -> Self {
103        Self {
104            default: MakerTakerFeeRates::zero(),
105            overrides: AHashMap::new(),
106        }
107    }
108
109    /// Adds or replaces an exact instrument override.
110    pub fn set_override(&mut self, instrument_id: InstrumentId, rates: MakerTakerFeeRates) {
111        self.overrides.insert(instrument_id, rates);
112    }
113
114    /// Returns the resolved rates for the given instrument.
115    #[must_use]
116    pub fn rates_for(&self, instrument_id: InstrumentId) -> MakerTakerFeeRates {
117        self.overrides
118            .get(&instrument_id)
119            .copied()
120            .unwrap_or(self.default)
121    }
122
123    /// Returns the resolved rate for the given instrument and liquidity side.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if `liquidity_side` is [`LiquiditySide::NoLiquiditySide`].
128    pub fn rate_for(
129        &self,
130        instrument_id: InstrumentId,
131        liquidity_side: LiquiditySide,
132    ) -> anyhow::Result<Decimal> {
133        self.rates_for(instrument_id).rate_for(liquidity_side)
134    }
135}
136
137/// Calculates maker/taker commission from an explicitly resolved fee rate.
138///
139/// This is the single shared arithmetic for notional-based maker/taker fees used
140/// by both account calculations and execution fee models. Contract terms (notional
141/// and currency) come from the instrument; fee policy (the rate) comes from the
142/// account-owned schedule and must already be resolved by the caller.
143///
144/// The fee rate alone decides the commission sign: a positive rate charges and a
145/// negative rate rebates, including at a negative price.
146///
147/// # Errors
148///
149/// Returns an error if the notional value cannot be calculated, arithmetic
150/// overflows, or the commission cannot be represented in the notional currency.
151pub fn calculate_maker_taker_commission(
152    instrument: &InstrumentAny,
153    last_qty: Quantity,
154    last_px: Price,
155    fee_rate: Decimal,
156    use_quote_for_inverse: Option<bool>,
157) -> anyhow::Result<Money> {
158    let notional =
159        instrument.try_calculate_notional_value(last_qty, last_px, use_quote_for_inverse)?;
160    let commission = notional
161        .as_decimal()
162        .abs()
163        .checked_mul(fee_rate)
164        .ok_or_else(|| anyhow::anyhow!("commission calculation overflow"))?;
165    Money::from_decimal(commission, notional.currency).map_err(Into::into)
166}
167
168#[cfg(test)]
169mod tests {
170    use rstest::rstest;
171    use rust_decimal_macros::dec;
172
173    use super::*;
174    use crate::enums::LiquiditySide;
175
176    #[rstest]
177    fn test_rates_for_liquidity_side() {
178        let rates = MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0005));
179        assert_eq!(rates.rate_for(LiquiditySide::Maker).unwrap(), dec!(0.0002));
180        assert_eq!(rates.rate_for(LiquiditySide::Taker).unwrap(), dec!(0.0005));
181        assert!(rates.rate_for(LiquiditySide::NoLiquiditySide).is_err());
182    }
183
184    #[rstest]
185    fn test_schedule_override_wins_over_default() {
186        let mut schedule = MakerTakerFeeSchedule::new(dec!(0.001), dec!(0.002));
187        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
188        let other_id = InstrumentId::from("ETHUSDT.BINANCE");
189        schedule.set_override(
190            instrument_id,
191            MakerTakerFeeRates::new(dec!(0.0001), dec!(0.0002)),
192        );
193
194        assert_eq!(
195            schedule.rates_for(instrument_id),
196            MakerTakerFeeRates::new(dec!(0.0001), dec!(0.0002))
197        );
198        assert_eq!(
199            schedule.rates_for(other_id),
200            MakerTakerFeeRates::new(dec!(0.001), dec!(0.002))
201        );
202    }
203
204    #[rstest]
205    fn test_explicit_zero_distinguishable_from_default() {
206        let mut schedule = MakerTakerFeeSchedule::new(dec!(0.001), dec!(0.002));
207        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
208        schedule.set_override(instrument_id, MakerTakerFeeRates::zero());
209
210        assert_eq!(
211            schedule
212                .rate_for(instrument_id, LiquiditySide::Taker)
213                .unwrap(),
214            Decimal::ZERO
215        );
216        assert_eq!(
217            schedule
218                .rate_for(InstrumentId::from("ETHUSDT.BINANCE"), LiquiditySide::Taker)
219                .unwrap(),
220            dec!(0.002)
221        );
222    }
223
224    #[rstest]
225    fn test_commission_matches_notional_arithmetic() {
226        use crate::{
227            instruments::{Instrument, stubs::audusd_sim},
228            types::{Price, Quantity},
229        };
230
231        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
232        let commission = calculate_maker_taker_commission(
233            &instrument,
234            Quantity::from(100_000),
235            Price::from("1.0"),
236            dec!(0.0002),
237            None,
238        )
239        .unwrap();
240        let notional = instrument
241            .try_calculate_notional_value(Quantity::from(100_000), Price::from("1.0"), None)
242            .unwrap();
243        assert_eq!(
244            commission.as_decimal(),
245            notional.as_decimal() * dec!(0.0002)
246        );
247        assert_eq!(commission.currency, notional.currency);
248    }
249
250    #[rstest]
251    #[case::charge(dec!(0.0002), "0.80 USD")]
252    #[case::rebate(dec!(-0.0001), "-0.40 USD")]
253    fn test_commission_sign_follows_rate_at_negative_price(
254        #[case] fee_rate: Decimal,
255        #[case] expected: &str,
256    ) {
257        use crate::{
258            instruments::stubs::futures_contract_es,
259            types::{Price, Quantity},
260        };
261
262        // Notional is 100 * -40.00 = -4000.00 USD
263        let instrument = InstrumentAny::FuturesContract(futures_contract_es(None, None));
264        let commission = calculate_maker_taker_commission(
265            &instrument,
266            Quantity::from(100),
267            Price::from("-40.00"),
268            fee_rate,
269            None,
270        )
271        .unwrap();
272
273        assert_eq!(commission, Money::from(expected));
274    }
275}