Skip to main content

rustrade_execution/
fee.rs

1use rust_decimal::Decimal;
2use serde::{Deserialize, Serialize};
3
4/// Which side of the trade a fill was on: the one that supplied liquidity, or the one that took it.
5///
6/// Venues price these differently, and usually by a wide margin — a maker rebate against a taker
7/// fee is the whole economics of quoting. A fee model that cannot tell them apart charges the taker
8/// rate for everything, which systematically overstates the cost of exactly the strategies that
9/// rest orders in order to earn the maker side.
10#[derive(
11    Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
12)]
13pub enum Liquidity {
14    /// The fill took liquidity already resting on the book: a market order, or a limit order
15    /// marketable on arrival. The default, because it is the conservative assumption — a model that
16    /// guesses wrong this way overstates costs rather than inventing profit.
17    #[default]
18    Taker,
19    /// The fill supplied liquidity: an order that rested on the book and was matched by someone
20    /// else's incoming order.
21    Maker,
22}
23
24/// Computes the trading fee for a single fill.
25///
26/// # Arguments
27/// * `price` - Execution price per unit of the underlying.
28/// * `quantity` - Number of contracts (or shares/units) filled.
29/// * `contract_size` - Multiplier converting contracts to underlying units
30///   (e.g. 100 for standard equity options). Use `Decimal::ONE` for spot.
31/// * `liquidity` - Whether the fill made or took liquidity; see [`Liquidity`].
32pub trait FeeModel {
33    fn compute_fee(
34        &self,
35        price: Decimal,
36        quantity: Decimal,
37        contract_size: Decimal,
38        liquidity: Liquidity,
39    ) -> Decimal;
40}
41
42/// Zero-fee model. Useful for backtests where fees are excluded.
43#[derive(
44    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
45)]
46pub struct ZeroFeeModel;
47
48impl FeeModel for ZeroFeeModel {
49    fn compute_fee(
50        &self,
51        _price: Decimal,
52        _quantity: Decimal,
53        _contract_size: Decimal,
54        _liquidity: Liquidity,
55    ) -> Decimal {
56        Decimal::ZERO
57    }
58}
59
60/// Flat commission charged per contract filled.
61///
62/// `total_fee = commission_per_contract * quantity.abs()`
63///
64/// This matches the typical Alpaca/IBKR per-contract options pricing.
65/// `contract_size` is accepted but not used; the fee is per contract unit,
66/// not per underlying share.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
68pub struct PerContractFeeModel {
69    #[serde(with = "rust_decimal::serde::str")]
70    pub commission_per_contract: Decimal,
71}
72
73impl FeeModel for PerContractFeeModel {
74    /// `liquidity` is ignored: a per-contract commission is a flat brokerage charge, and the
75    /// brokers this models (Alpaca, IBKR options) do not vary it by who supplied liquidity.
76    fn compute_fee(
77        &self,
78        _price: Decimal,
79        quantity: Decimal,
80        _contract_size: Decimal,
81        _liquidity: Liquidity,
82    ) -> Decimal {
83        self.commission_per_contract * quantity.abs()
84    }
85}
86
87/// Percentage-of-notional fee model for spot, futures and CFD exchanges.
88///
89/// `total_fee = rate * price * quantity.abs() * contract_size`
90///
91/// Common for crypto spot/futures exchanges (e.g. Binance 0.1% taker fee).
92///
93/// # Why `contract_size` is applied
94///
95/// The notional a percentage is charged on is the *underlying* exposure, not the contract count.
96/// For `Spot` the multiplier is `Decimal::ONE` and the term is inert, but a €25-per-point index
97/// CFD at 5000 has a notional of €125,000, not €5,000 — dropping the multiplier would understate
98/// the fee by exactly that factor, silently, on every fill. The same applies to any `Future` or
99/// `Perpetual` whose `contract_size` is not one.
100///
101/// Contrast [`PerContractFeeModel`], which ignores the multiplier because its fee genuinely is
102/// per contract rather than per underlying unit.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
104pub struct PercentageFeeModel {
105    /// Fee rate charged when the fill **took** liquidity, and when no [`maker_rate`](Self::maker_rate)
106    /// is configured. Typical range is `[0, 1]`:
107    /// - `0.001` = 0.1% (common taker fee)
108    /// - `0.0005` = 0.05% (common maker fee)
109    ///
110    /// No validation is performed; values outside `[0, 1]` are accepted
111    /// but produce unusual fee amounts.
112    #[serde(with = "rust_decimal::serde::str")]
113    pub rate: Decimal,
114
115    /// Fee rate charged when the fill **made** liquidity. `None` charges [`rate`](Self::rate) on
116    /// both sides.
117    ///
118    /// Absent by default so a configuration written before this field existed deserialises and
119    /// prices exactly as it did. Set it to model a venue's maker schedule — commonly a fraction of
120    /// the taker rate, and on some venues a rebate, which this represents as a negative rate.
121    #[serde(
122        default,
123        skip_serializing_if = "Option::is_none",
124        with = "rust_decimal::serde::str_option"
125    )]
126    pub maker_rate: Option<Decimal>,
127}
128
129impl PercentageFeeModel {
130    /// One rate, charged on both sides of the book.
131    pub fn new(rate: Decimal) -> Self {
132        Self {
133            rate,
134            maker_rate: None,
135        }
136    }
137
138    /// Distinct maker and taker rates, as a venue's fee schedule quotes them.
139    ///
140    /// A maker *rebate* is a negative `maker_rate`.
141    pub fn maker_taker(maker_rate: Decimal, taker_rate: Decimal) -> Self {
142        Self {
143            rate: taker_rate,
144            maker_rate: Some(maker_rate),
145        }
146    }
147
148    /// The rate charged for `liquidity`: [`maker_rate`](Self::maker_rate) when it is configured and
149    /// the fill made liquidity, otherwise [`rate`](Self::rate).
150    pub fn rate_for(&self, liquidity: Liquidity) -> Decimal {
151        match liquidity {
152            Liquidity::Maker => self.maker_rate.unwrap_or(self.rate),
153            Liquidity::Taker => self.rate,
154        }
155    }
156}
157
158impl FeeModel for PercentageFeeModel {
159    fn compute_fee(
160        &self,
161        price: Decimal,
162        quantity: Decimal,
163        contract_size: Decimal,
164        liquidity: Liquidity,
165    ) -> Decimal {
166        self.rate_for(liquidity) * price * quantity.abs() * contract_size
167    }
168}
169
170/// Enum-dispatched fee model for use in types that require `Clone`, `PartialEq`,
171/// `Serialize`, and `Deserialize` (e.g. `InstrumentState`).
172///
173/// Prefer this over `Box<dyn FeeModel>` when the field must be part of a derived
174/// `serde` struct. Defaults to [`ZeroFeeModel`].
175///
176/// # Double-counting warning
177///
178/// Only enable a non-[`Zero`](FeeModelConfig::Zero) fee model when the `ExecutionClient`
179/// reports `Trade.fees.fees = 0` for fills (i.e., commission is not already embedded in
180/// fill reports). If the client already includes fees in `fees.fees` and a fee model
181/// is also active, fees will be counted twice.
182///
183/// # Variants
184///
185/// - [`Zero`](FeeModelConfig::Zero): No fees (backtests where fees are excluded).
186/// - [`PerContract`](FeeModelConfig::PerContract): Flat per-contract commission (options).
187/// - [`Percentage`](FeeModelConfig::Percentage): Percentage of notional (spot/futures).
188#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
189pub enum FeeModelConfig {
190    Zero(ZeroFeeModel),
191    PerContract(PerContractFeeModel),
192    Percentage(PercentageFeeModel),
193}
194
195impl Default for FeeModelConfig {
196    fn default() -> Self {
197        Self::Zero(ZeroFeeModel)
198    }
199}
200
201impl FeeModel for FeeModelConfig {
202    fn compute_fee(
203        &self,
204        price: Decimal,
205        quantity: Decimal,
206        contract_size: Decimal,
207        liquidity: Liquidity,
208    ) -> Decimal {
209        match self {
210            FeeModelConfig::Zero(model) => {
211                model.compute_fee(price, quantity, contract_size, liquidity)
212            }
213            FeeModelConfig::PerContract(model) => {
214                model.compute_fee(price, quantity, contract_size, liquidity)
215            }
216            FeeModelConfig::Percentage(model) => {
217                model.compute_fee(price, quantity, contract_size, liquidity)
218            }
219        }
220    }
221}
222
223#[cfg(test)]
224#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
225mod tests {
226    use super::*;
227
228    fn d(s: &str) -> Decimal {
229        s.parse().unwrap()
230    }
231
232    #[test]
233    fn zero_fee_model_always_returns_zero() {
234        assert_eq!(
235            ZeroFeeModel.compute_fee(d("100"), d("5"), d("100"), Liquidity::Taker),
236            Decimal::ZERO
237        );
238        assert_eq!(
239            ZeroFeeModel.compute_fee(Decimal::ZERO, Decimal::ZERO, Decimal::ONE, Liquidity::Taker),
240            Decimal::ZERO
241        );
242    }
243
244    #[test]
245    fn per_contract_fee_charges_by_quantity() {
246        let model = PerContractFeeModel {
247            commission_per_contract: d("0.65"),
248        };
249        assert_eq!(
250            model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
251            d("6.5")
252        );
253    }
254
255    #[test]
256    fn per_contract_fee_uses_abs_quantity() {
257        let model = PerContractFeeModel {
258            commission_per_contract: d("0.65"),
259        };
260        // Negative quantity (sell side) should produce the same fee as positive.
261        assert_eq!(
262            model.compute_fee(d("100"), d("-10"), d("100"), Liquidity::Taker),
263            model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
264        );
265    }
266
267    // --- FeeModelConfig enum dispatch ---
268
269    #[test]
270    fn fee_model_config_zero_dispatches() {
271        let cfg = FeeModelConfig::Zero(ZeroFeeModel);
272        assert_eq!(
273            cfg.compute_fee(d("100"), d("5"), d("100"), Liquidity::Taker),
274            Decimal::ZERO
275        );
276    }
277
278    #[test]
279    fn fee_model_config_per_contract_dispatches() {
280        let model = PerContractFeeModel {
281            commission_per_contract: d("0.65"),
282        };
283        let cfg = FeeModelConfig::PerContract(model);
284        assert_eq!(
285            cfg.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
286            model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
287        );
288    }
289
290    #[test]
291    fn fee_model_config_default_is_zero() {
292        assert_eq!(
293            FeeModelConfig::default(),
294            FeeModelConfig::Zero(ZeroFeeModel)
295        );
296    }
297
298    // --- PercentageFeeModel ---
299
300    #[test]
301    fn percentage_fee_computes_rate_times_notional() {
302        // 0.1% fee rate
303        let model = PercentageFeeModel::new(d("0.001"));
304        // 10 units at price 100 = notional 1000, fee = 1000 * 0.001 = 1
305        assert_eq!(
306            model.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
307            d("1")
308        );
309    }
310
311    #[test]
312    fn percentage_fee_uses_abs_quantity() {
313        let model = PercentageFeeModel::new(d("0.001"));
314        assert_eq!(
315            model.compute_fee(d("100"), d("-10"), d("1"), Liquidity::Taker),
316            model.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
317        );
318    }
319
320    #[test]
321    fn percentage_fee_scales_by_contract_size() {
322        // A EUR25-per-point index CFD at 5000: the notional is 125_000, not 5_000, so 0.1% is 125.
323        // Charging 5 here would understate every CFD fill by the multiplier.
324        let model = PercentageFeeModel::new(d("0.001"));
325        assert_eq!(
326            model.compute_fee(d("5000"), d("1"), d("25"), Liquidity::Taker),
327            d("125")
328        );
329    }
330
331    #[test]
332    fn percentage_fee_is_unchanged_for_a_unit_contract_size() {
333        // Spot is the overwhelmingly common case and must be untouched by the multiplier.
334        let model = PercentageFeeModel::new(d("0.001"));
335        assert_eq!(
336            model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Taker),
337            d("1")
338        );
339    }
340
341    #[test]
342    fn per_contract_fee_ignores_contract_size() {
343        // The counterpart to `percentage_fee_scales_by_contract_size`: this fee genuinely is per
344        // contract, so the multiplier must NOT apply. Pinned so the two models cannot drift into
345        // sharing one rule.
346        let model = PerContractFeeModel {
347            commission_per_contract: d("0.65"),
348        };
349        assert_eq!(
350            model.compute_fee(d("5000"), d("10"), d("25"), Liquidity::Taker),
351            model.compute_fee(d("5000"), d("10"), Decimal::ONE, Liquidity::Taker),
352        );
353    }
354
355    #[test]
356    fn fee_model_config_percentage_dispatches() {
357        let model = PercentageFeeModel::new(d("0.001"));
358        let cfg = FeeModelConfig::Percentage(model);
359        assert_eq!(
360            cfg.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
361            model.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
362        );
363    }
364
365    // --- Serde round-trip tests ---
366
367    #[test]
368    fn zero_fee_model_serde_roundtrip() {
369        let cfg = FeeModelConfig::Zero(ZeroFeeModel);
370        let json = serde_json::to_string(&cfg).unwrap();
371        assert_eq!(json, r#"{"Zero":null}"#);
372        let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
373        assert_eq!(parsed, cfg);
374    }
375
376    #[test]
377    fn fee_model_config_default_when_field_omitted() {
378        // Simulates deserializing a struct where fee_model field is absent
379        #[derive(Deserialize)]
380        struct Wrapper {
381            #[serde(default)]
382            fee_model: FeeModelConfig,
383        }
384        let parsed: Wrapper = serde_json::from_str(r#"{}"#).unwrap();
385        assert_eq!(parsed.fee_model, FeeModelConfig::Zero(ZeroFeeModel));
386    }
387
388    #[test]
389    fn percentage_fee_model_serde_roundtrip() {
390        let cfg = FeeModelConfig::Percentage(PercentageFeeModel::new(d("0.001")));
391        let json = serde_json::to_string(&cfg).unwrap();
392        assert_eq!(json, r#"{"Percentage":{"rate":"0.001"}}"#);
393        let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
394        assert_eq!(parsed, cfg);
395    }
396
397    #[test]
398    fn per_contract_fee_model_serde_roundtrip() {
399        let cfg = FeeModelConfig::PerContract(PerContractFeeModel {
400            commission_per_contract: d("0.65"),
401        });
402        let json = serde_json::to_string(&cfg).unwrap();
403        assert_eq!(
404            json,
405            r#"{"PerContract":{"commission_per_contract":"0.65"}}"#
406        );
407        let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
408        assert_eq!(parsed, cfg);
409    }
410
411    // --- Maker / taker ---
412
413    #[test]
414    fn a_percentage_model_without_a_maker_rate_charges_one_rate_on_both_sides() {
415        let model = PercentageFeeModel::new(d("0.001"));
416
417        assert_eq!(
418            model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Maker),
419            model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Taker),
420            "an unconfigured maker rate must price exactly as the model did before the field \
421             existed"
422        );
423    }
424
425    #[test]
426    fn a_configured_maker_rate_is_charged_only_to_the_maker() {
427        let model = PercentageFeeModel::maker_taker(d("0.0002"), d("0.001"));
428
429        assert_eq!(
430            model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Taker),
431            d("1"),
432            "0.1% of 1000 notional"
433        );
434        assert_eq!(
435            model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Maker),
436            d("0.2"),
437            "0.02% of 1000 notional"
438        );
439    }
440
441    /// Some venues pay for liquidity. A rebate is a negative fee, not a missing one.
442    #[test]
443    fn a_maker_rebate_is_a_negative_fee() {
444        let model = PercentageFeeModel::maker_taker(d("-0.0001"), d("0.001"));
445
446        assert_eq!(
447            model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Maker),
448            d("-0.1")
449        );
450    }
451
452    #[test]
453    fn a_per_contract_commission_does_not_vary_with_liquidity() {
454        let model = PerContractFeeModel {
455            commission_per_contract: d("0.65"),
456        };
457
458        assert_eq!(
459            model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Maker),
460            model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker)
461        );
462    }
463
464    /// A config written before `maker_rate` existed must still deserialise, and price as it did.
465    #[test]
466    fn a_percentage_model_deserialises_without_a_maker_rate() {
467        let model: PercentageFeeModel =
468            serde_json::from_str(r#"{"rate":"0.001"}"#).expect("legacy shape must parse");
469
470        assert_eq!(model, PercentageFeeModel::new(d("0.001")));
471        assert_eq!(model.maker_rate, None);
472    }
473
474    #[test]
475    fn a_percentage_model_round_trips_a_maker_rate() {
476        let model = PercentageFeeModel::maker_taker(d("0.0002"), d("0.001"));
477        let json = serde_json::to_string(&model).expect("must serialise");
478
479        assert_eq!(
480            serde_json::from_str::<PercentageFeeModel>(&json).expect("must round-trip"),
481            model
482        );
483    }
484}