Skip to main content

rustrade_execution/
fee.rs

1use rust_decimal::Decimal;
2use serde::{Deserialize, Serialize};
3
4/// Computes the trading fee for a single fill.
5///
6/// # Arguments
7/// * `price` - Execution price per unit of the underlying.
8/// * `quantity` - Number of contracts (or shares/units) filled.
9/// * `contract_size` - Multiplier converting contracts to underlying units
10///   (e.g. 100 for standard equity options). Use `Decimal::ONE` for spot.
11pub trait FeeModel {
12    fn compute_fee(&self, price: Decimal, quantity: Decimal, contract_size: Decimal) -> Decimal;
13}
14
15/// Zero-fee model. Useful for backtests where fees are excluded.
16#[derive(
17    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
18)]
19pub struct ZeroFeeModel;
20
21impl FeeModel for ZeroFeeModel {
22    fn compute_fee(&self, _price: Decimal, _quantity: Decimal, _contract_size: Decimal) -> Decimal {
23        Decimal::ZERO
24    }
25}
26
27/// Flat commission charged per contract filled.
28///
29/// `total_fee = commission_per_contract * quantity.abs()`
30///
31/// This matches the typical Alpaca/IBKR per-contract options pricing.
32/// `contract_size` is accepted but not used; the fee is per contract unit,
33/// not per underlying share.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
35pub struct PerContractFeeModel {
36    #[serde(with = "rust_decimal::serde::str")]
37    pub commission_per_contract: Decimal,
38}
39
40impl FeeModel for PerContractFeeModel {
41    fn compute_fee(&self, _price: Decimal, quantity: Decimal, _contract_size: Decimal) -> Decimal {
42        self.commission_per_contract * quantity.abs()
43    }
44}
45
46/// Percentage-of-notional fee model for spot and futures exchanges.
47///
48/// `total_fee = rate * price * quantity.abs()`
49///
50/// Common for crypto spot/futures exchanges (e.g. Binance 0.1% taker fee).
51#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
52pub struct PercentageFeeModel {
53    /// Fee rate as a decimal fraction. Typical range is `[0, 1]`:
54    /// - `0.001` = 0.1% (common taker fee)
55    /// - `0.0005` = 0.05% (common maker fee)
56    ///
57    /// No validation is performed; values outside `[0, 1]` are accepted
58    /// but produce unusual fee amounts.
59    #[serde(with = "rust_decimal::serde::str")]
60    pub rate: Decimal,
61}
62
63impl FeeModel for PercentageFeeModel {
64    fn compute_fee(&self, price: Decimal, quantity: Decimal, _contract_size: Decimal) -> Decimal {
65        self.rate * price * quantity.abs()
66    }
67}
68
69/// Enum-dispatched fee model for use in types that require `Clone`, `PartialEq`,
70/// `Serialize`, and `Deserialize` (e.g. `InstrumentState`).
71///
72/// Prefer this over `Box<dyn FeeModel>` when the field must be part of a derived
73/// `serde` struct. Defaults to [`ZeroFeeModel`].
74///
75/// # Double-counting warning
76///
77/// Only enable a non-[`Zero`](FeeModelConfig::Zero) fee model when the `ExecutionClient`
78/// reports `Trade.fees.fees = 0` for fills (i.e., commission is not already embedded in
79/// fill reports). If the client already includes fees in `fees.fees` and a fee model
80/// is also active, fees will be counted twice.
81///
82/// # Variants
83///
84/// - [`Zero`](FeeModelConfig::Zero): No fees (backtests where fees are excluded).
85/// - [`PerContract`](FeeModelConfig::PerContract): Flat per-contract commission (options).
86/// - [`Percentage`](FeeModelConfig::Percentage): Percentage of notional (spot/futures).
87#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
88pub enum FeeModelConfig {
89    Zero(ZeroFeeModel),
90    PerContract(PerContractFeeModel),
91    Percentage(PercentageFeeModel),
92}
93
94impl Default for FeeModelConfig {
95    fn default() -> Self {
96        Self::Zero(ZeroFeeModel)
97    }
98}
99
100impl FeeModel for FeeModelConfig {
101    fn compute_fee(&self, price: Decimal, quantity: Decimal, contract_size: Decimal) -> Decimal {
102        match self {
103            FeeModelConfig::Zero(m) => m.compute_fee(price, quantity, contract_size),
104            FeeModelConfig::PerContract(m) => m.compute_fee(price, quantity, contract_size),
105            FeeModelConfig::Percentage(m) => m.compute_fee(price, quantity, contract_size),
106        }
107    }
108}
109
110#[cfg(test)]
111#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
112mod tests {
113    use super::*;
114
115    fn d(s: &str) -> Decimal {
116        s.parse().unwrap()
117    }
118
119    #[test]
120    fn zero_fee_model_always_returns_zero() {
121        assert_eq!(
122            ZeroFeeModel.compute_fee(d("100"), d("5"), d("100")),
123            Decimal::ZERO
124        );
125        assert_eq!(
126            ZeroFeeModel.compute_fee(Decimal::ZERO, Decimal::ZERO, Decimal::ONE),
127            Decimal::ZERO
128        );
129    }
130
131    #[test]
132    fn per_contract_fee_charges_by_quantity() {
133        let model = PerContractFeeModel {
134            commission_per_contract: d("0.65"),
135        };
136        assert_eq!(model.compute_fee(d("100"), d("10"), d("100")), d("6.5"));
137    }
138
139    #[test]
140    fn per_contract_fee_uses_abs_quantity() {
141        let model = PerContractFeeModel {
142            commission_per_contract: d("0.65"),
143        };
144        // Negative quantity (sell side) should produce the same fee as positive.
145        assert_eq!(
146            model.compute_fee(d("100"), d("-10"), d("100")),
147            model.compute_fee(d("100"), d("10"), d("100")),
148        );
149    }
150
151    // --- FeeModelConfig enum dispatch ---
152
153    #[test]
154    fn fee_model_config_zero_dispatches() {
155        let cfg = FeeModelConfig::Zero(ZeroFeeModel);
156        assert_eq!(cfg.compute_fee(d("100"), d("5"), d("100")), Decimal::ZERO);
157    }
158
159    #[test]
160    fn fee_model_config_per_contract_dispatches() {
161        let model = PerContractFeeModel {
162            commission_per_contract: d("0.65"),
163        };
164        let cfg = FeeModelConfig::PerContract(model);
165        assert_eq!(
166            cfg.compute_fee(d("100"), d("10"), d("100")),
167            model.compute_fee(d("100"), d("10"), d("100")),
168        );
169    }
170
171    #[test]
172    fn fee_model_config_default_is_zero() {
173        assert_eq!(
174            FeeModelConfig::default(),
175            FeeModelConfig::Zero(ZeroFeeModel)
176        );
177    }
178
179    // --- PercentageFeeModel ---
180
181    #[test]
182    fn percentage_fee_computes_rate_times_notional() {
183        // 0.1% fee rate
184        let model = PercentageFeeModel { rate: d("0.001") };
185        // 10 units at price 100 = notional 1000, fee = 1000 * 0.001 = 1
186        assert_eq!(model.compute_fee(d("100"), d("10"), d("1")), d("1"));
187    }
188
189    #[test]
190    fn percentage_fee_uses_abs_quantity() {
191        let model = PercentageFeeModel { rate: d("0.001") };
192        assert_eq!(
193            model.compute_fee(d("100"), d("-10"), d("1")),
194            model.compute_fee(d("100"), d("10"), d("1")),
195        );
196    }
197
198    #[test]
199    fn fee_model_config_percentage_dispatches() {
200        let model = PercentageFeeModel { rate: d("0.001") };
201        let cfg = FeeModelConfig::Percentage(model);
202        assert_eq!(
203            cfg.compute_fee(d("100"), d("10"), d("1")),
204            model.compute_fee(d("100"), d("10"), d("1")),
205        );
206    }
207
208    // --- Serde round-trip tests ---
209
210    #[test]
211    fn zero_fee_model_serde_roundtrip() {
212        let cfg = FeeModelConfig::Zero(ZeroFeeModel);
213        let json = serde_json::to_string(&cfg).unwrap();
214        assert_eq!(json, r#"{"Zero":null}"#);
215        let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
216        assert_eq!(parsed, cfg);
217    }
218
219    #[test]
220    fn fee_model_config_default_when_field_omitted() {
221        // Simulates deserializing a struct where fee_model field is absent
222        #[derive(Deserialize)]
223        struct Wrapper {
224            #[serde(default)]
225            fee_model: FeeModelConfig,
226        }
227        let parsed: Wrapper = serde_json::from_str(r#"{}"#).unwrap();
228        assert_eq!(parsed.fee_model, FeeModelConfig::Zero(ZeroFeeModel));
229    }
230
231    #[test]
232    fn percentage_fee_model_serde_roundtrip() {
233        let cfg = FeeModelConfig::Percentage(PercentageFeeModel { rate: d("0.001") });
234        let json = serde_json::to_string(&cfg).unwrap();
235        assert_eq!(json, r#"{"Percentage":{"rate":"0.001"}}"#);
236        let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
237        assert_eq!(parsed, cfg);
238    }
239
240    #[test]
241    fn per_contract_fee_model_serde_roundtrip() {
242        let cfg = FeeModelConfig::PerContract(PerContractFeeModel {
243            commission_per_contract: d("0.65"),
244        });
245        let json = serde_json::to_string(&cfg).unwrap();
246        assert_eq!(
247            json,
248            r#"{"PerContract":{"commission_per_contract":"0.65"}}"#
249        );
250        let parsed: FeeModelConfig = serde_json::from_str(&json).unwrap();
251        assert_eq!(parsed, cfg);
252    }
253}