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