Skip to main content

finance_query/backtesting/config/
costs.rs

1//! Trading friction: commission, slippage, spread, and transaction tax.
2
3use std::fmt;
4use std::sync::Arc;
5
6use super::BacktestConfig;
7
8/// A custom commission function: `f(size, price) -> commission_amount`.
9///
10/// When set on [`BacktestConfig`] via [`BacktestConfigBuilder::commission_fn`],
11/// it **replaces** the flat `commission` + percentage `commission_pct` fields.
12/// Use it to model broker-specific fee schedules such as per-share fees with
13/// a minimum, tiered rates, or Robinhood-style zero-commission structures.
14///
15/// # Example
16///
17/// ```
18/// use finance_query::backtesting::BacktestConfig;
19///
20/// // IB-style: $0.005 per share, minimum $1.00 per order
21/// let config = BacktestConfig::builder()
22///     .commission_fn(|size, price| (size * 0.005_f64).max(1.00))
23///     .build()
24///     .unwrap();
25/// ```
26#[derive(Clone)]
27#[non_exhaustive]
28pub struct CommissionFn(Arc<dyn Fn(f64, f64) -> f64 + Send + Sync>);
29
30impl CommissionFn {
31    /// Create from any closure or function pointer matching `Fn(f64, f64) -> f64`.
32    pub fn new<F>(f: F) -> Self
33    where
34        F: Fn(f64, f64) -> f64 + Send + Sync + 'static,
35    {
36        Self(Arc::new(f))
37    }
38
39    /// Call the underlying function with `(size, price)`.
40    #[inline]
41    pub(crate) fn call(&self, size: f64, price: f64) -> f64 {
42        (self.0)(size, price)
43    }
44}
45
46impl fmt::Debug for CommissionFn {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(f, "CommissionFn(<closure>)")
49    }
50}
51
52impl BacktestConfig {
53    /// Calculate commission for a fill.
54    ///
55    /// When [`commission_fn`] is set it takes precedence over the flat
56    /// [`commission`] + percentage [`commission_pct`] fields.
57    ///
58    /// [`commission_fn`]: Self::commission_fn
59    /// [`commission`]: Self::commission
60    /// [`commission_pct`]: Self::commission_pct
61    pub fn calculate_commission(&self, size: f64, price: f64) -> f64 {
62        if let Some(ref f) = self.commission_fn {
63            f.call(size, price)
64        } else {
65            self.commission + (size * price * self.commission_pct)
66        }
67    }
68
69    /// Apply slippage to a price (for entry).
70    pub fn apply_entry_slippage(&self, price: f64, is_long: bool) -> f64 {
71        if is_long {
72            price * (1.0 + self.slippage_pct)
73        } else {
74            price * (1.0 - self.slippage_pct)
75        }
76    }
77
78    /// Apply slippage to a price (for exit).
79    pub fn apply_exit_slippage(&self, price: f64, is_long: bool) -> f64 {
80        if is_long {
81            price * (1.0 - self.slippage_pct)
82        } else {
83            price * (1.0 + self.slippage_pct)
84        }
85    }
86
87    /// Apply the bid-ask spread to an entry fill price (half-spread adverse).
88    ///
89    /// Long entries pay the ask (price rises by `spread_pct / 2`);
90    /// short entries receive the bid (price falls by `spread_pct / 2`).
91    pub fn apply_entry_spread(&self, price: f64, is_long: bool) -> f64 {
92        let half = self.spread_pct / 2.0;
93        if is_long {
94            price * (1.0 + half)
95        } else {
96            price * (1.0 - half)
97        }
98    }
99
100    /// Apply the bid-ask spread to an exit fill price (half-spread adverse).
101    ///
102    /// Long exits receive the bid (price falls by `spread_pct / 2`);
103    /// short exits pay the ask (price rises by `spread_pct / 2`).
104    pub fn apply_exit_spread(&self, price: f64, is_long: bool) -> f64 {
105        let half = self.spread_pct / 2.0;
106        if is_long {
107            price * (1.0 - half)
108        } else {
109            price * (1.0 + half)
110        }
111    }
112
113    /// Calculate the transaction tax on a fill.
114    ///
115    /// Tax applies only to **buy** orders (`is_buy = true`):
116    /// - Long entries (opening a long position)
117    /// - Short exits (covering a short position)
118    ///
119    /// Returns `0.0` for all sell orders.
120    pub fn calculate_transaction_tax(&self, trade_value: f64, is_buy: bool) -> f64 {
121        if is_buy {
122            trade_value * self.transaction_tax_pct
123        } else {
124            0.0
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_commission_calculation() {
135        let config = BacktestConfig::builder()
136            .commission(5.0)
137            .commission_pct(0.01)
138            .build()
139            .unwrap();
140
141        // For $1000 trade (10 units @ $100): $5 flat + 1% = $5 + $10 = $15
142        let commission = config.calculate_commission(10.0, 100.0);
143        assert!((commission - 15.0).abs() < 0.01);
144    }
145
146    #[test]
147    fn test_slippage() {
148        let config = BacktestConfig::builder()
149            .slippage_pct(0.01) // 1%
150            .build()
151            .unwrap();
152
153        // Long entry: price goes up
154        let entry_price = config.apply_entry_slippage(100.0, true);
155        assert!((entry_price - 101.0).abs() < 0.01);
156
157        // Long exit: price goes down
158        let exit_price = config.apply_exit_slippage(100.0, true);
159        assert!((exit_price - 99.0).abs() < 0.01);
160
161        // Short entry: price goes down (less favorable)
162        let short_entry = config.apply_entry_slippage(100.0, false);
163        assert!((short_entry - 99.0).abs() < 0.01);
164
165        // Short exit: price goes up
166        let short_exit = config.apply_exit_slippage(100.0, false);
167        assert!((short_exit - 101.0).abs() < 0.01);
168    }
169
170    #[test]
171    fn test_spread_entry_long() {
172        let config = BacktestConfig::builder()
173            .spread_pct(0.0004) // 4 bps
174            .build()
175            .unwrap();
176        // Long entry pays the ask: price rises by half-spread (2 bps)
177        let price = config.apply_entry_spread(100.0, true);
178        assert!((price - 100.02).abs() < 1e-10);
179    }
180
181    #[test]
182    fn test_spread_exit_long() {
183        let config = BacktestConfig::builder()
184            .spread_pct(0.0004)
185            .build()
186            .unwrap();
187        // Long exit receives the bid: price falls by half-spread
188        let price = config.apply_exit_spread(100.0, true);
189        assert!((price - 99.98).abs() < 1e-10);
190    }
191
192    #[test]
193    fn test_spread_entry_short() {
194        let config = BacktestConfig::builder()
195            .spread_pct(0.0004)
196            .build()
197            .unwrap();
198        // Short entry receives the bid: price falls by half-spread
199        let price = config.apply_entry_spread(100.0, false);
200        assert!((price - 99.98).abs() < 1e-10);
201    }
202
203    #[test]
204    fn test_spread_exit_short() {
205        let config = BacktestConfig::builder()
206            .spread_pct(0.0004)
207            .build()
208            .unwrap();
209        // Short exit pays the ask: price rises by half-spread
210        let price = config.apply_exit_spread(100.0, false);
211        assert!((price - 100.02).abs() < 1e-10);
212    }
213
214    #[test]
215    fn test_spread_zero_is_noop() {
216        let config = BacktestConfig::default(); // spread_pct = 0.0
217        assert!((config.apply_entry_spread(123.45, true) - 123.45).abs() < 1e-10);
218        assert!((config.apply_exit_spread(123.45, false) - 123.45).abs() < 1e-10);
219    }
220
221    #[test]
222    fn test_transaction_tax_on_buy() {
223        let config = BacktestConfig::builder()
224            .transaction_tax_pct(0.005) // UK stamp duty 0.5%
225            .build()
226            .unwrap();
227        let tax = config.calculate_transaction_tax(10_000.0, true);
228        assert!((tax - 50.0).abs() < 1e-10);
229    }
230
231    #[test]
232    fn test_transaction_tax_not_on_sell() {
233        let config = BacktestConfig::builder()
234            .transaction_tax_pct(0.005)
235            .build()
236            .unwrap();
237        let tax = config.calculate_transaction_tax(10_000.0, false);
238        assert_eq!(tax, 0.0);
239    }
240
241    #[test]
242    fn test_transaction_tax_zero_default() {
243        let config = BacktestConfig::default();
244        assert_eq!(config.calculate_transaction_tax(100_000.0, true), 0.0);
245    }
246
247    #[test]
248    fn test_commission_fn_replaces_flat_and_pct() {
249        // Custom fn: $0.005/share minimum $1.00
250        let config = BacktestConfig::builder()
251            .commission_fn(|size, _price| (size * 0.005_f64).max(1.00))
252            .build()
253            .unwrap();
254        // 100 shares: 100 * 0.005 = $0.50 → minimum kicks in → $1.00
255        let comm = config.calculate_commission(100.0, 50.0);
256        assert!((comm - 1.00).abs() < 1e-10);
257        // 500 shares: 500 * 0.005 = $2.50 → above minimum
258        let comm = config.calculate_commission(500.0, 50.0);
259        assert!((comm - 2.50).abs() < 1e-10);
260    }
261
262    #[test]
263    fn test_commission_fn_ignores_flat_and_pct_fields() {
264        // Even with flat=5 and pct=0.01 set, commission_fn should override
265        let config = BacktestConfig::builder()
266            .commission(5.0)
267            .commission_pct(0.01)
268            .commission_fn(|size, price| size * price * 0.0005)
269            .build()
270            .unwrap();
271        // 10 shares @ $100: fn gives 10*100*0.0005 = $0.50
272        let comm = config.calculate_commission(10.0, 100.0);
273        assert!((comm - 0.50).abs() < 1e-10);
274    }
275
276    #[test]
277    fn test_commission_fn_fallback_when_none() {
278        // Without commission_fn, standard flat+pct applies
279        let config = BacktestConfig::builder()
280            .commission(1.0)
281            .commission_pct(0.002)
282            .build()
283            .unwrap();
284        // 10 shares @ $100 = $1000 trade: $1 + $2 = $3
285        let comm = config.calculate_commission(10.0, 100.0);
286        assert!((comm - 3.0).abs() < 1e-10);
287    }
288}