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
201/// Which asset the simulated venue charges a spot fill's fee in.
202///
203/// The amount always comes from the venue's [`FeeModelConfig`], which prices a fee in quote. This
204/// rule says only which balance it is taken from, so it changes no cost, only which asset pays it:
205///
206/// | Rule | Buy | Sell |
207/// |---|---|---|
208/// | [`Quote`](Self::Quote) | quote, on top of the notional | quote, out of the proceeds |
209/// | [`Received`](Self::Received) | base, out of the quantity received | quote, out of the proceeds |
210///
211/// A fee in base is the quote fee divided by the fill price, to `Decimal`'s 28 digits. The fill
212/// reports it in base, with that quote fee as its
213/// [`AssetFees::fees_quote`](crate::trade::AssetFees::fees_quote).
214///
215/// Venues differ, and so do accounts on one venue. `Quote` matches Coinbase spot's default.
216/// `Received` matches Binance spot's default, with BNB fee payment turned off. Pick the rule of
217/// the account a backtest stands in for. A CFD fill is cash-settled, so its fee is always in
218/// quote, whatever this says.
219///
220/// # A buy's fee in base
221/// Under `Received`, a buy receives its quantity less the fee, and the engine nets a base fee from
222/// a spot position (see `rustrade`'s `InstrumentState::update_from_trade`), so the two stay equal.
223/// The holding is then not a round number: a buy of 1 with a 0.1 % fee holds 0.999. A live venue
224/// sells only whole multiples of its lot step, so a full-size close there can leave a remainder
225/// below one lot, which only that venue's own dust conversion can clear. Rounding a close to the
226/// lot step is the strategy's to do. This venue has no lot step, so a close here sells the whole
227/// holding.
228///
229/// A flat fee on a tiny buy can exceed the quantity it receives. The excess is then taken from the
230/// base balance, and the order is refused if that cannot cover it, as a sell whose fee exceeds its
231/// proceeds takes the excess from quote. A fill at a price that is not positive cannot be
232/// converted, so it is charged in quote under either rule.
233///
234/// `#[non_exhaustive]`: a rule that charges a third asset, such as BNB, may be added.
235#[derive(
236    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize,
237)]
238#[non_exhaustive]
239pub enum SpotFeeAsset {
240    /// The fee is in quote on both sides: on top of what a buy pays, and out of what a sell
241    /// receives. Venue-neutral, and the base a fill moves is exactly its quantity.
242    #[default]
243    Quote,
244    /// The fee is in the asset the fill receives: base out of a buy's quantity, quote out of a
245    /// sell's proceeds.
246    Received,
247}
248
249impl FeeModel for FeeModelConfig {
250    fn compute_fee(
251        &self,
252        price: Decimal,
253        quantity: Decimal,
254        contract_size: Decimal,
255        liquidity: Liquidity,
256    ) -> Decimal {
257        match self {
258            FeeModelConfig::Zero(model) => {
259                model.compute_fee(price, quantity, contract_size, liquidity)
260            }
261            FeeModelConfig::PerContract(model) => {
262                model.compute_fee(price, quantity, contract_size, liquidity)
263            }
264            FeeModelConfig::Percentage(model) => {
265                model.compute_fee(price, quantity, contract_size, liquidity)
266            }
267        }
268    }
269}
270
271#[cfg(test)]
272#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
273mod tests {
274    use super::*;
275
276    fn d(s: &str) -> Decimal {
277        s.parse().unwrap()
278    }
279
280    #[test]
281    fn zero_fee_model_always_returns_zero() {
282        assert_eq!(
283            ZeroFeeModel.compute_fee(d("100"), d("5"), d("100"), Liquidity::Taker),
284            Decimal::ZERO
285        );
286        assert_eq!(
287            ZeroFeeModel.compute_fee(Decimal::ZERO, Decimal::ZERO, Decimal::ONE, Liquidity::Taker),
288            Decimal::ZERO
289        );
290    }
291
292    #[test]
293    fn per_contract_fee_charges_by_quantity() {
294        let model = PerContractFeeModel {
295            commission_per_contract: d("0.65"),
296        };
297        assert_eq!(
298            model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
299            d("6.5")
300        );
301    }
302
303    #[test]
304    fn per_contract_fee_uses_abs_quantity() {
305        let model = PerContractFeeModel {
306            commission_per_contract: d("0.65"),
307        };
308        // Negative quantity (sell side) should produce the same fee as positive.
309        assert_eq!(
310            model.compute_fee(d("100"), d("-10"), d("100"), Liquidity::Taker),
311            model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
312        );
313    }
314
315    // --- FeeModelConfig enum dispatch ---
316
317    #[test]
318    fn fee_model_config_zero_dispatches() {
319        let cfg = FeeModelConfig::Zero(ZeroFeeModel);
320        assert_eq!(
321            cfg.compute_fee(d("100"), d("5"), d("100"), Liquidity::Taker),
322            Decimal::ZERO
323        );
324    }
325
326    #[test]
327    fn fee_model_config_per_contract_dispatches() {
328        let model = PerContractFeeModel {
329            commission_per_contract: d("0.65"),
330        };
331        let cfg = FeeModelConfig::PerContract(model);
332        assert_eq!(
333            cfg.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
334            model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
335        );
336    }
337
338    #[test]
339    fn fee_model_config_default_is_zero() {
340        assert_eq!(
341            FeeModelConfig::default(),
342            FeeModelConfig::Zero(ZeroFeeModel)
343        );
344    }
345
346    // --- PercentageFeeModel ---
347
348    #[test]
349    fn percentage_fee_computes_rate_times_notional() {
350        // 0.1% fee rate
351        let model = PercentageFeeModel::new(d("0.001"));
352        // 10 units at price 100 = notional 1000, fee = 1000 * 0.001 = 1
353        assert_eq!(
354            model.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
355            d("1")
356        );
357    }
358
359    #[test]
360    fn percentage_fee_uses_abs_quantity() {
361        let model = PercentageFeeModel::new(d("0.001"));
362        assert_eq!(
363            model.compute_fee(d("100"), d("-10"), d("1"), Liquidity::Taker),
364            model.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
365        );
366    }
367
368    #[test]
369    fn percentage_fee_scales_by_contract_size() {
370        // A EUR25-per-point index CFD at 5000: the notional is 125_000, not 5_000, so 0.1% is 125.
371        // Charging 5 here would understate every CFD fill by the multiplier.
372        let model = PercentageFeeModel::new(d("0.001"));
373        assert_eq!(
374            model.compute_fee(d("5000"), d("1"), d("25"), Liquidity::Taker),
375            d("125")
376        );
377    }
378
379    #[test]
380    fn percentage_fee_is_unchanged_for_a_unit_contract_size() {
381        // Spot is the overwhelmingly common case and must be untouched by the multiplier.
382        let model = PercentageFeeModel::new(d("0.001"));
383        assert_eq!(
384            model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Taker),
385            d("1")
386        );
387    }
388
389    #[test]
390    fn per_contract_fee_ignores_contract_size() {
391        // The counterpart to `percentage_fee_scales_by_contract_size`: this fee genuinely is per
392        // contract, so the multiplier must NOT apply. Pinned so the two models cannot drift into
393        // sharing one rule.
394        let model = PerContractFeeModel {
395            commission_per_contract: d("0.65"),
396        };
397        assert_eq!(
398            model.compute_fee(d("5000"), d("10"), d("25"), Liquidity::Taker),
399            model.compute_fee(d("5000"), d("10"), Decimal::ONE, Liquidity::Taker),
400        );
401    }
402
403    #[test]
404    fn fee_model_config_percentage_dispatches() {
405        let model = PercentageFeeModel::new(d("0.001"));
406        let cfg = FeeModelConfig::Percentage(model);
407        assert_eq!(
408            cfg.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
409            model.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
410        );
411    }
412
413    // --- Serde round-trip tests ---
414
415    #[test]
416    fn zero_fee_model_serde_roundtrip() {
417        let cfg = FeeModelConfig::Zero(ZeroFeeModel);
418        let json = serde_json::to_string(&cfg).unwrap();
419        assert_eq!(json, r#"{"Zero":null}"#);
420        let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
421        assert_eq!(parsed, cfg);
422    }
423
424    #[test]
425    fn fee_model_config_default_when_field_omitted() {
426        // Simulates deserializing a struct where fee_model field is absent
427        #[derive(Deserialize)]
428        struct Wrapper {
429            #[serde(default)]
430            fee_model: FeeModelConfig,
431        }
432        let parsed: Wrapper = serde_json::from_str(r#"{}"#).unwrap();
433        assert_eq!(parsed.fee_model, FeeModelConfig::Zero(ZeroFeeModel));
434    }
435
436    #[test]
437    fn percentage_fee_model_serde_roundtrip() {
438        let cfg = FeeModelConfig::Percentage(PercentageFeeModel::new(d("0.001")));
439        let json = serde_json::to_string(&cfg).unwrap();
440        assert_eq!(json, r#"{"Percentage":{"rate":"0.001"}}"#);
441        let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
442        assert_eq!(parsed, cfg);
443    }
444
445    #[test]
446    fn per_contract_fee_model_serde_roundtrip() {
447        let cfg = FeeModelConfig::PerContract(PerContractFeeModel {
448            commission_per_contract: d("0.65"),
449        });
450        let json = serde_json::to_string(&cfg).unwrap();
451        assert_eq!(
452            json,
453            r#"{"PerContract":{"commission_per_contract":"0.65"}}"#
454        );
455        let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
456        assert_eq!(parsed, cfg);
457    }
458
459    // --- Maker / taker ---
460
461    #[test]
462    fn a_percentage_model_without_a_maker_rate_charges_one_rate_on_both_sides() {
463        let model = PercentageFeeModel::new(d("0.001"));
464
465        assert_eq!(
466            model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Maker),
467            model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Taker),
468            "an unconfigured maker rate must price exactly as the model did before the field \
469             existed"
470        );
471    }
472
473    #[test]
474    fn a_configured_maker_rate_is_charged_only_to_the_maker() {
475        let model = PercentageFeeModel::maker_taker(d("0.0002"), d("0.001"));
476
477        assert_eq!(
478            model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Taker),
479            d("1"),
480            "0.1% of 1000 notional"
481        );
482        assert_eq!(
483            model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Maker),
484            d("0.2"),
485            "0.02% of 1000 notional"
486        );
487    }
488
489    /// Some venues pay for liquidity. A rebate is a negative fee, not a missing one.
490    #[test]
491    fn a_maker_rebate_is_a_negative_fee() {
492        let model = PercentageFeeModel::maker_taker(d("-0.0001"), d("0.001"));
493
494        assert_eq!(
495            model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Maker),
496            d("-0.1")
497        );
498    }
499
500    #[test]
501    fn a_per_contract_commission_does_not_vary_with_liquidity() {
502        let model = PerContractFeeModel {
503            commission_per_contract: d("0.65"),
504        };
505
506        assert_eq!(
507            model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Maker),
508            model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker)
509        );
510    }
511
512    /// A config written before `maker_rate` existed must still deserialise, and price as it did.
513    #[test]
514    fn a_percentage_model_deserialises_without_a_maker_rate() {
515        let model: PercentageFeeModel =
516            serde_json::from_str(r#"{"rate":"0.001"}"#).expect("legacy shape must parse");
517
518        assert_eq!(model, PercentageFeeModel::new(d("0.001")));
519        assert_eq!(model.maker_rate, None);
520    }
521
522    #[test]
523    fn a_percentage_model_round_trips_a_maker_rate() {
524        let model = PercentageFeeModel::maker_taker(d("0.0002"), d("0.001"));
525        let json = serde_json::to_string(&model).expect("must serialise");
526
527        assert_eq!(
528            serde_json::from_str::<PercentageFeeModel>(&json).expect("must round-trip"),
529            model
530        );
531    }
532}