Skip to main content

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}