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}