rustrade_execution/fee.rs
1use rust_decimal::Decimal;
2use rustrade_instrument::asset::name::AssetNameExchange;
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6/// Which side of the trade a fill was on: the one that supplied liquidity, or the one that took it.
7///
8/// Venues price these differently, and usually by a wide margin — a maker rebate against a taker
9/// fee is the whole economics of quoting. A fee model that cannot tell them apart charges the taker
10/// rate for everything, which systematically overstates the cost of exactly the strategies that
11/// rest orders in order to earn the maker side.
12#[derive(
13 Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
14)]
15pub enum Liquidity {
16 /// The fill took liquidity already resting on the book: a market order, or a limit order
17 /// marketable on arrival. The default, because it is the conservative assumption — a model that
18 /// guesses wrong this way overstates costs rather than inventing profit.
19 #[default]
20 Taker,
21 /// The fill supplied liquidity: an order that rested on the book and was matched by someone
22 /// else's incoming order.
23 Maker,
24}
25
26/// Computes the trading fee for a single fill.
27///
28/// # Arguments
29/// * `price` - Execution price per unit of the underlying.
30/// * `quantity` - Number of contracts (or shares/units) filled.
31/// * `contract_size` - Multiplier converting contracts to underlying units
32/// (e.g. 100 for standard equity options). Use `Decimal::ONE` for spot.
33/// * `liquidity` - Whether the fill made or took liquidity; see [`Liquidity`].
34pub trait FeeModel {
35 fn compute_fee(
36 &self,
37 price: Decimal,
38 quantity: Decimal,
39 contract_size: Decimal,
40 liquidity: Liquidity,
41 ) -> Decimal;
42}
43
44/// Zero-fee model. Useful for backtests where fees are excluded.
45#[derive(
46 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
47)]
48pub struct ZeroFeeModel;
49
50impl FeeModel for ZeroFeeModel {
51 fn compute_fee(
52 &self,
53 _price: Decimal,
54 _quantity: Decimal,
55 _contract_size: Decimal,
56 _liquidity: Liquidity,
57 ) -> Decimal {
58 Decimal::ZERO
59 }
60}
61
62/// Flat commission charged per contract filled.
63///
64/// `total_fee = commission_per_contract * quantity.abs()`
65///
66/// This matches the typical Alpaca/IBKR per-contract options pricing.
67/// `contract_size` is accepted but not used; the fee is per contract unit,
68/// not per underlying share.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
70pub struct PerContractFeeModel {
71 #[serde(with = "rust_decimal::serde::str")]
72 pub commission_per_contract: Decimal,
73}
74
75impl FeeModel for PerContractFeeModel {
76 /// `liquidity` is ignored: a per-contract commission is a flat brokerage charge, and the
77 /// brokers this models (Alpaca, IBKR options) do not vary it by who supplied liquidity.
78 fn compute_fee(
79 &self,
80 _price: Decimal,
81 quantity: Decimal,
82 _contract_size: Decimal,
83 _liquidity: Liquidity,
84 ) -> Decimal {
85 self.commission_per_contract * quantity.abs()
86 }
87}
88
89/// Percentage-of-notional fee model for spot, futures and CFD exchanges.
90///
91/// `total_fee = rate * price * quantity.abs() * contract_size`
92///
93/// Common for crypto spot/futures exchanges (e.g. Binance 0.1% taker fee).
94///
95/// # Why `contract_size` is applied
96///
97/// The notional a percentage is charged on is the *underlying* exposure, not the contract count.
98/// For `Spot` the multiplier is `Decimal::ONE` and the term is inert, but a €25-per-point index
99/// CFD at 5000 has a notional of €125,000, not €5,000 — dropping the multiplier would understate
100/// the fee by exactly that factor, silently, on every fill. The same applies to any `Future` or
101/// `Perpetual` whose `contract_size` is not one.
102///
103/// Contrast [`PerContractFeeModel`], which ignores the multiplier because its fee genuinely is
104/// per contract rather than per underlying unit.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
106pub struct PercentageFeeModel {
107 /// Fee rate charged when the fill **took** liquidity, and when no [`maker_rate`](Self::maker_rate)
108 /// is configured. Typical range is `[0, 1]`:
109 /// - `0.001` = 0.1% (common taker fee)
110 /// - `0.0005` = 0.05% (common maker fee)
111 ///
112 /// No validation is performed; values outside `[0, 1]` are accepted
113 /// but produce unusual fee amounts.
114 #[serde(with = "rust_decimal::serde::str")]
115 pub rate: Decimal,
116
117 /// Fee rate charged when the fill **made** liquidity. `None` charges [`rate`](Self::rate) on
118 /// both sides.
119 ///
120 /// Absent by default so a configuration written before this field existed deserialises and
121 /// prices exactly as it did. Set it to model a venue's maker schedule — commonly a fraction of
122 /// the taker rate, and on some venues a rebate, which this represents as a negative rate.
123 #[serde(
124 default,
125 skip_serializing_if = "Option::is_none",
126 with = "rust_decimal::serde::str_option"
127 )]
128 pub maker_rate: Option<Decimal>,
129}
130
131impl PercentageFeeModel {
132 /// One rate, charged on both sides of the book.
133 pub fn new(rate: Decimal) -> Self {
134 Self {
135 rate,
136 maker_rate: None,
137 }
138 }
139
140 /// Distinct maker and taker rates, as a venue's fee schedule quotes them.
141 ///
142 /// A maker *rebate* is a negative `maker_rate`.
143 pub fn maker_taker(maker_rate: Decimal, taker_rate: Decimal) -> Self {
144 Self {
145 rate: taker_rate,
146 maker_rate: Some(maker_rate),
147 }
148 }
149
150 /// The rate charged for `liquidity`: [`maker_rate`](Self::maker_rate) when it is configured and
151 /// the fill made liquidity, otherwise [`rate`](Self::rate).
152 pub fn rate_for(&self, liquidity: Liquidity) -> Decimal {
153 match liquidity {
154 Liquidity::Maker => self.maker_rate.unwrap_or(self.rate),
155 Liquidity::Taker => self.rate,
156 }
157 }
158}
159
160impl FeeModel for PercentageFeeModel {
161 fn compute_fee(
162 &self,
163 price: Decimal,
164 quantity: Decimal,
165 contract_size: Decimal,
166 liquidity: Liquidity,
167 ) -> Decimal {
168 self.rate_for(liquidity) * price * quantity.abs() * contract_size
169 }
170}
171
172/// Enum-dispatched fee model for use in types that require `Clone`, `PartialEq`,
173/// `Serialize`, and `Deserialize` (e.g. `InstrumentState`).
174///
175/// Prefer this over `Box<dyn FeeModel>` when the field must be part of a derived
176/// `serde` struct. Defaults to [`ZeroFeeModel`].
177///
178/// # Double-counting warning
179///
180/// Only enable a non-[`Zero`](FeeModelConfig::Zero) fee model when the `ExecutionClient`
181/// reports `Trade.fees.fees = 0` for fills (i.e., commission is not already embedded in
182/// fill reports). If the client already includes fees in `fees.fees` and a fee model
183/// is also active, fees will be counted twice.
184///
185/// # Variants
186///
187/// - [`Zero`](FeeModelConfig::Zero): No fees (backtests where fees are excluded).
188/// - [`PerContract`](FeeModelConfig::PerContract): Flat per-contract commission (options).
189/// - [`Percentage`](FeeModelConfig::Percentage): Percentage of notional (spot/futures).
190#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
191pub enum FeeModelConfig {
192 Zero(ZeroFeeModel),
193 PerContract(PerContractFeeModel),
194 Percentage(PercentageFeeModel),
195}
196
197impl Default for FeeModelConfig {
198 fn default() -> Self {
199 Self::Zero(ZeroFeeModel)
200 }
201}
202
203/// Which asset the simulated venue charges a spot fill's fee in.
204///
205/// The amount always comes from the venue's [`FeeModelConfig`], which prices a fee in quote. This
206/// rule says only which balance it is taken from, so it changes no cost, only which asset pays it:
207///
208/// | Rule | Buy | Sell |
209/// |---|---|---|
210/// | [`Quote`](Self::Quote) | quote, on top of the notional | quote, out of the proceeds |
211/// | [`Received`](Self::Received) | base, out of the quantity received | quote, out of the proceeds |
212///
213/// A fee in base is the quote fee divided by the fill price, to `Decimal`'s 28 digits. The fill
214/// reports it in base, with that quote fee as its
215/// [`AssetFees::fees_quote`](crate::trade::AssetFees::fees_quote).
216///
217/// Venues differ, and so do accounts on one venue. `Quote` matches Coinbase spot's default.
218/// `Received` matches Binance spot's default, with BNB fee payment turned off. Pick the rule of
219/// the account a backtest stands in for. A CFD fill is cash-settled, so its fee is always in
220/// quote, whatever this says.
221///
222/// # A buy's fee in base
223/// Under `Received`, a buy receives its quantity less the fee, and the engine nets a base fee from
224/// a spot position (see `rustrade`'s `InstrumentState::update_from_trade`), so the two stay equal.
225/// The holding is then not a round number: a buy of 1 with a 0.1 % fee holds 0.999. A live venue
226/// sells only whole multiples of its lot step, so when the instrument's spec gives a quantity
227/// step, the engine rounds the position toward zero to it and leaves the remainder in the balance,
228/// where only the venue's own dust conversion can clear it. This venue has no lot step: it fills
229/// whatever quantity it is sent, so a close of the rounded position leaves the same remainder
230/// here as live.
231///
232/// A flat fee on a tiny buy can exceed the quantity it receives. The excess is then taken from the
233/// base balance, and the order is refused if that cannot cover it, as a sell whose fee exceeds its
234/// proceeds takes the excess from quote. A fill at a price that is not positive cannot be
235/// converted, so it is charged in quote under either rule.
236///
237/// A fee charged in a third asset, such as BNB, is set apart from this rule, by
238/// [`FeeAssetConfig`]. Where it does not apply, this rule does.
239///
240/// `#[non_exhaustive]`: further rules may be added.
241#[derive(
242 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize,
243)]
244#[non_exhaustive]
245pub enum SpotFeeAsset {
246 /// The fee is in quote on both sides: on top of what a buy pays, and out of what a sell
247 /// receives. Venue-neutral, and the base a fill moves is exactly its quantity.
248 #[default]
249 Quote,
250 /// The fee is in the asset the fill receives: base out of a buy's quantity, quote out of a
251 /// sell's proceeds.
252 Received,
253}
254
255/// A third asset the simulated venue charges spot fees in, at a discount, while the account
256/// holds enough of it: Binance's "pay fees with BNB".
257///
258/// # What a fill pays
259/// The fee is the [`FeeModelConfig`]'s fee in quote, less [`discount`](Self::discount), converted
260/// to the fee asset at the venue's price for it: the mid, else the last price, of a spot
261/// instrument on the venue pairing the fee asset with the fill's quote asset, in either direction
262/// (`BNB/USDT`, or the reciprocal of `USDT/BNB`). Of several, the one first by name. It is taken
263/// from the fee asset's free balance when the fill settles, apart from what the fill exchanges,
264/// and the fill reports it in the fee asset, as a live venue does. Nothing is reserved for it
265/// while an order rests, since a venue does not hold the fee asset for open orders.
266///
267/// The fee is not rounded to the fee asset's precision, which this venue does not know, so it
268/// can differ from a live venue's in its last decimals.
269///
270/// # When it falls back
271/// The fill is charged as without this setting, the undiscounted fee under the venue's
272/// [`SpotFeeAsset`] rule, when any of these holds:
273/// - the fee asset's free balance is short of the fee: the whole fee falls back, none of it is
274/// split. A margin account does not lend the fee asset for a fee.
275/// - the venue holds no positive price for the fee asset in the fill's quote. A venue with no
276/// market feed holds none.
277/// - the instrument is the fee asset's own, its base or its quote (`BNB/USDT`, `ETH/BNB`). A live
278/// venue discounts those too; this one does not.
279/// - the fill is cash-settled (a CFD), or its fee is zero or negative.
280///
281/// A fill's fee asset says which happened. With an engine that prices the fee asset, PnL counts a
282/// fee charged in it; otherwise a backtest leaves it out, as a live run does.
283///
284/// [`new`](Self::new) refuses a discount outside zero to one, and so does deserialising one.
285/// Serialised, the discount is a decimal string (`"0.25"`).
286#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
287#[serde(try_from = "FeeAssetRepr", into = "FeeAssetRepr")]
288pub struct FeeAssetConfig(FeeAssetRepr);
289
290/// The serialised form of a [`FeeAssetConfig`], before validation.
291#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
292struct FeeAssetRepr {
293 asset: AssetNameExchange,
294 #[serde(with = "rust_decimal::serde::str")]
295 discount: Decimal,
296}
297
298impl FeeAssetConfig {
299 /// Fees charged in `asset`, at `discount` off the fee model's fee: `0.25` for a quarter off.
300 ///
301 /// # Errors
302 /// Returns [`FeeAssetConfigError`] if `discount` is below zero or above one.
303 pub fn new(asset: AssetNameExchange, discount: Decimal) -> Result<Self, FeeAssetConfigError> {
304 Self::try_from(FeeAssetRepr { asset, discount })
305 }
306
307 /// The asset fees are charged in.
308 pub fn asset(&self) -> &AssetNameExchange {
309 &self.0.asset
310 }
311
312 /// The fraction taken off the fee model's fee: zero for none, one for no fee at all.
313 pub fn discount(&self) -> Decimal {
314 self.0.discount
315 }
316}
317
318impl TryFrom<FeeAssetRepr> for FeeAssetConfig {
319 type Error = FeeAssetConfigError;
320
321 fn try_from(repr: FeeAssetRepr) -> Result<Self, Self::Error> {
322 if repr.discount < Decimal::ZERO || repr.discount > Decimal::ONE {
323 return Err(FeeAssetConfigError::DiscountOutOfRange(repr.discount));
324 }
325 Ok(Self(repr))
326 }
327}
328
329impl From<FeeAssetConfig> for FeeAssetRepr {
330 fn from(config: FeeAssetConfig) -> Self {
331 config.0
332 }
333}
334
335/// Why a [`FeeAssetConfig`] was refused.
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
337#[non_exhaustive]
338pub enum FeeAssetConfigError {
339 /// A discount below zero, which would charge more than the fee model, or above one, which
340 /// would pay the account for trading.
341 #[error("fee asset discount {0} is outside 0 to 1")]
342 DiscountOutOfRange(Decimal),
343}
344
345impl FeeModel for FeeModelConfig {
346 fn compute_fee(
347 &self,
348 price: Decimal,
349 quantity: Decimal,
350 contract_size: Decimal,
351 liquidity: Liquidity,
352 ) -> Decimal {
353 match self {
354 FeeModelConfig::Zero(model) => {
355 model.compute_fee(price, quantity, contract_size, liquidity)
356 }
357 FeeModelConfig::PerContract(model) => {
358 model.compute_fee(price, quantity, contract_size, liquidity)
359 }
360 FeeModelConfig::Percentage(model) => {
361 model.compute_fee(price, quantity, contract_size, liquidity)
362 }
363 }
364 }
365}
366
367#[cfg(test)]
368#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
369mod tests {
370 use super::*;
371
372 fn d(s: &str) -> Decimal {
373 s.parse().unwrap()
374 }
375
376 #[test]
377 fn zero_fee_model_always_returns_zero() {
378 assert_eq!(
379 ZeroFeeModel.compute_fee(d("100"), d("5"), d("100"), Liquidity::Taker),
380 Decimal::ZERO
381 );
382 assert_eq!(
383 ZeroFeeModel.compute_fee(Decimal::ZERO, Decimal::ZERO, Decimal::ONE, Liquidity::Taker),
384 Decimal::ZERO
385 );
386 }
387
388 #[test]
389 fn per_contract_fee_charges_by_quantity() {
390 let model = PerContractFeeModel {
391 commission_per_contract: d("0.65"),
392 };
393 assert_eq!(
394 model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
395 d("6.5")
396 );
397 }
398
399 #[test]
400 fn per_contract_fee_uses_abs_quantity() {
401 let model = PerContractFeeModel {
402 commission_per_contract: d("0.65"),
403 };
404 // Negative quantity (sell side) should produce the same fee as positive.
405 assert_eq!(
406 model.compute_fee(d("100"), d("-10"), d("100"), Liquidity::Taker),
407 model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
408 );
409 }
410
411 // --- FeeModelConfig enum dispatch ---
412
413 #[test]
414 fn fee_model_config_zero_dispatches() {
415 let cfg = FeeModelConfig::Zero(ZeroFeeModel);
416 assert_eq!(
417 cfg.compute_fee(d("100"), d("5"), d("100"), Liquidity::Taker),
418 Decimal::ZERO
419 );
420 }
421
422 #[test]
423 fn fee_model_config_per_contract_dispatches() {
424 let model = PerContractFeeModel {
425 commission_per_contract: d("0.65"),
426 };
427 let cfg = FeeModelConfig::PerContract(model);
428 assert_eq!(
429 cfg.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
430 model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker),
431 );
432 }
433
434 #[test]
435 fn fee_model_config_default_is_zero() {
436 assert_eq!(
437 FeeModelConfig::default(),
438 FeeModelConfig::Zero(ZeroFeeModel)
439 );
440 }
441
442 // --- PercentageFeeModel ---
443
444 #[test]
445 fn percentage_fee_computes_rate_times_notional() {
446 // 0.1% fee rate
447 let model = PercentageFeeModel::new(d("0.001"));
448 // 10 units at price 100 = notional 1000, fee = 1000 * 0.001 = 1
449 assert_eq!(
450 model.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
451 d("1")
452 );
453 }
454
455 #[test]
456 fn percentage_fee_uses_abs_quantity() {
457 let model = PercentageFeeModel::new(d("0.001"));
458 assert_eq!(
459 model.compute_fee(d("100"), d("-10"), d("1"), Liquidity::Taker),
460 model.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
461 );
462 }
463
464 #[test]
465 fn percentage_fee_scales_by_contract_size() {
466 // A EUR25-per-point index CFD at 5000: the notional is 125_000, not 5_000, so 0.1% is 125.
467 // Charging 5 here would understate every CFD fill by the multiplier.
468 let model = PercentageFeeModel::new(d("0.001"));
469 assert_eq!(
470 model.compute_fee(d("5000"), d("1"), d("25"), Liquidity::Taker),
471 d("125")
472 );
473 }
474
475 #[test]
476 fn percentage_fee_is_unchanged_for_a_unit_contract_size() {
477 // Spot is the overwhelmingly common case and must be untouched by the multiplier.
478 let model = PercentageFeeModel::new(d("0.001"));
479 assert_eq!(
480 model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Taker),
481 d("1")
482 );
483 }
484
485 #[test]
486 fn per_contract_fee_ignores_contract_size() {
487 // The counterpart to `percentage_fee_scales_by_contract_size`: this fee genuinely is per
488 // contract, so the multiplier must NOT apply. Pinned so the two models cannot drift into
489 // sharing one rule.
490 let model = PerContractFeeModel {
491 commission_per_contract: d("0.65"),
492 };
493 assert_eq!(
494 model.compute_fee(d("5000"), d("10"), d("25"), Liquidity::Taker),
495 model.compute_fee(d("5000"), d("10"), Decimal::ONE, Liquidity::Taker),
496 );
497 }
498
499 #[test]
500 fn fee_model_config_percentage_dispatches() {
501 let model = PercentageFeeModel::new(d("0.001"));
502 let cfg = FeeModelConfig::Percentage(model);
503 assert_eq!(
504 cfg.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
505 model.compute_fee(d("100"), d("10"), d("1"), Liquidity::Taker),
506 );
507 }
508
509 // --- Serde round-trip tests ---
510
511 #[test]
512 fn zero_fee_model_serde_roundtrip() {
513 let cfg = FeeModelConfig::Zero(ZeroFeeModel);
514 let json = serde_json::to_string(&cfg).unwrap();
515 assert_eq!(json, r#"{"Zero":null}"#);
516 let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
517 assert_eq!(parsed, cfg);
518 }
519
520 #[test]
521 fn fee_model_config_default_when_field_omitted() {
522 // Simulates deserializing a struct where fee_model field is absent
523 #[derive(Deserialize)]
524 struct Wrapper {
525 #[serde(default)]
526 fee_model: FeeModelConfig,
527 }
528 let parsed: Wrapper = serde_json::from_str(r#"{}"#).unwrap();
529 assert_eq!(parsed.fee_model, FeeModelConfig::Zero(ZeroFeeModel));
530 }
531
532 #[test]
533 fn percentage_fee_model_serde_roundtrip() {
534 let cfg = FeeModelConfig::Percentage(PercentageFeeModel::new(d("0.001")));
535 let json = serde_json::to_string(&cfg).unwrap();
536 assert_eq!(json, r#"{"Percentage":{"rate":"0.001"}}"#);
537 let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
538 assert_eq!(parsed, cfg);
539 }
540
541 #[test]
542 fn per_contract_fee_model_serde_roundtrip() {
543 let cfg = FeeModelConfig::PerContract(PerContractFeeModel {
544 commission_per_contract: d("0.65"),
545 });
546 let json = serde_json::to_string(&cfg).unwrap();
547 assert_eq!(
548 json,
549 r#"{"PerContract":{"commission_per_contract":"0.65"}}"#
550 );
551 let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
552 assert_eq!(parsed, cfg);
553 }
554
555 // --- Maker / taker ---
556
557 #[test]
558 fn a_percentage_model_without_a_maker_rate_charges_one_rate_on_both_sides() {
559 let model = PercentageFeeModel::new(d("0.001"));
560
561 assert_eq!(
562 model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Maker),
563 model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Taker),
564 "an unconfigured maker rate must price exactly as the model did before the field \
565 existed"
566 );
567 }
568
569 #[test]
570 fn a_configured_maker_rate_is_charged_only_to_the_maker() {
571 let model = PercentageFeeModel::maker_taker(d("0.0002"), d("0.001"));
572
573 assert_eq!(
574 model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Taker),
575 d("1"),
576 "0.1% of 1000 notional"
577 );
578 assert_eq!(
579 model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Maker),
580 d("0.2"),
581 "0.02% of 1000 notional"
582 );
583 }
584
585 /// Some venues pay for liquidity. A rebate is a negative fee, not a missing one.
586 #[test]
587 fn a_maker_rebate_is_a_negative_fee() {
588 let model = PercentageFeeModel::maker_taker(d("-0.0001"), d("0.001"));
589
590 assert_eq!(
591 model.compute_fee(d("100"), d("10"), Decimal::ONE, Liquidity::Maker),
592 d("-0.1")
593 );
594 }
595
596 #[test]
597 fn a_per_contract_commission_does_not_vary_with_liquidity() {
598 let model = PerContractFeeModel {
599 commission_per_contract: d("0.65"),
600 };
601
602 assert_eq!(
603 model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Maker),
604 model.compute_fee(d("100"), d("10"), d("100"), Liquidity::Taker)
605 );
606 }
607
608 /// A config written before `maker_rate` existed must still deserialise, and price as it did.
609 #[test]
610 fn a_percentage_model_deserialises_without_a_maker_rate() {
611 let model: PercentageFeeModel =
612 serde_json::from_str(r#"{"rate":"0.001"}"#).expect("legacy shape must parse");
613
614 assert_eq!(model, PercentageFeeModel::new(d("0.001")));
615 assert_eq!(model.maker_rate, None);
616 }
617
618 #[test]
619 fn a_percentage_model_round_trips_a_maker_rate() {
620 let model = PercentageFeeModel::maker_taker(d("0.0002"), d("0.001"));
621 let json = serde_json::to_string(&model).expect("must serialise");
622
623 assert_eq!(
624 serde_json::from_str::<PercentageFeeModel>(&json).expect("must round-trip"),
625 model
626 );
627 }
628
629 #[test]
630 fn a_fee_asset_discount_must_lie_between_zero_and_one() {
631 let bnb = || AssetNameExchange::new("BNB");
632 for discount in ["0", "0.25", "1"] {
633 let config = FeeAssetConfig::new(bnb(), d(discount)).unwrap();
634 assert_eq!((config.asset(), config.discount()), (&bnb(), d(discount)));
635 }
636 for discount in ["-0.01", "1.01"] {
637 assert_eq!(
638 FeeAssetConfig::new(bnb(), d(discount)),
639 Err(FeeAssetConfigError::DiscountOutOfRange(d(discount)))
640 );
641 }
642 }
643
644 /// The discount serialises as a decimal string, and deserialising refuses one out of range.
645 #[test]
646 fn a_fee_asset_config_round_trips_and_refuses_an_invalid_discount() {
647 let config = FeeAssetConfig::new(AssetNameExchange::new("BNB"), d("0.25")).unwrap();
648 let json = serde_json::to_string(&config).unwrap();
649 assert_eq!(json, r#"{"asset":"BNB","discount":"0.25"}"#);
650 assert_eq!(
651 serde_json::from_str::<FeeAssetConfig>(&json).unwrap(),
652 config
653 );
654
655 let error = serde_json::from_str::<FeeAssetConfig>(r#"{"asset":"BNB","discount":"2"}"#)
656 .unwrap_err();
657 assert!(error.to_string().contains("outside 0 to 1"), "{error}");
658 }
659}