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}