Skip to main content

rustrade_backtest/
fees.rs

1//! Fee models applied to every simulated fill in the backtest engine.
2//!
3//! Fees are charged against the trade's *notional* (fill price × size).
4//! For Phase 4a the maker/taker distinction is exposed but every
5//! market order is treated as taker — limit orders aren't simulated yet.
6
7use serde::{Deserialize, Serialize};
8
9/// Pluggable fee schedule.
10///
11/// # Example
12///
13/// ```
14/// use rustrade_backtest::FeeModel;
15///
16/// // 5 bps flat fee.
17/// let f = FeeModel::Flat(0.0005);
18/// assert!((f.fee_for(100.0, 10.0, true) - 0.5).abs() < 1e-9);
19///
20/// // Different maker / taker rates.
21/// let mt = FeeModel::MakerTaker { maker: 0.0002, taker: 0.0006 };
22/// assert!((mt.fee_for(100.0, 1.0, true) - 0.06).abs() < 1e-9);
23/// assert!((mt.fee_for(100.0, 1.0, false) - 0.02).abs() < 1e-9);
24/// ```
25#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
26pub enum FeeModel {
27    /// Zero fees — useful for sanity-checking PnL against a pure
28    /// price-difference benchmark.
29    Zero,
30    /// Flat rate applied to every fill, as a fraction of notional.
31    /// `0.001` = 10 bps = 0.1%.
32    Flat(f64),
33    /// Different rates for maker vs taker fills. The engine charges the
34    /// taker rate for market / IOC / FOK orders and closes, and the maker
35    /// rate for limit / post-only orders that rest before filling.
36    MakerTaker {
37        /// Fraction-of-notional rate when the fill is a maker.
38        maker: f64,
39        /// Fraction-of-notional rate when the fill is a taker.
40        taker: f64,
41    },
42}
43
44impl Default for FeeModel {
45    fn default() -> Self {
46        // Sensible crypto-futures-ish default: 5 bps round trip.
47        Self::Flat(0.0005)
48    }
49}
50
51impl FeeModel {
52    /// Compute the fee in quote currency for a fill of `size` units at
53    /// `fill_price`. The boolean `is_taker` is ignored unless the model
54    /// is `MakerTaker`.
55    pub fn fee_for(self, fill_price: f64, size: f64, is_taker: bool) -> f64 {
56        let notional = fill_price * size;
57        match self {
58            Self::Zero => 0.0,
59            Self::Flat(rate) => notional * rate,
60            Self::MakerTaker { maker, taker } => notional * if is_taker { taker } else { maker },
61        }
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn zero_returns_zero() {
71        assert_eq!(FeeModel::Zero.fee_for(100.0, 1.0, true), 0.0);
72    }
73
74    #[test]
75    fn flat_proportional_to_notional() {
76        let f = FeeModel::Flat(0.001);
77        assert!((f.fee_for(100.0, 1.0, true) - 0.1).abs() < 1e-9);
78        assert!((f.fee_for(100.0, 2.0, true) - 0.2).abs() < 1e-9);
79        assert!((f.fee_for(50.0, 4.0, true) - 0.2).abs() < 1e-9);
80    }
81
82    #[test]
83    fn maker_taker_distinguishes() {
84        let f = FeeModel::MakerTaker {
85            maker: 0.0002,
86            taker: 0.0006,
87        };
88        let maker = f.fee_for(100.0, 1.0, false);
89        let taker = f.fee_for(100.0, 1.0, true);
90        assert!((maker - 0.02).abs() < 1e-9);
91        assert!((taker - 0.06).abs() < 1e-9);
92    }
93}