Skip to main content

finance_query/backtesting/
config.rs

1//! Backtest configuration and builder.
2
3use serde::{Deserialize, Serialize};
4
5use super::error::{BacktestError, Result};
6
7/// Configuration for backtest execution.
8///
9/// Use `BacktestConfig::builder()` to construct with the builder pattern.
10///
11/// # Example
12///
13/// ```
14/// use finance_query::backtesting::BacktestConfig;
15///
16/// let config = BacktestConfig::builder()
17///     .initial_capital(50_000.0)
18///     .commission_pct(0.001)
19///     .slippage_pct(0.0005)
20///     .allow_short(true)
21///     .stop_loss_pct(0.05)
22///     .take_profit_pct(0.10)
23///     .build()
24///     .unwrap();
25/// ```
26#[non_exhaustive]
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct BacktestConfig {
29    /// Initial portfolio capital in base currency
30    pub initial_capital: f64,
31
32    /// Commission per trade (flat fee)
33    pub commission: f64,
34
35    /// Commission as percentage of trade value (0.0 - 1.0)
36    pub commission_pct: f64,
37
38    /// Slippage as percentage of price (0.0 - 1.0)
39    pub slippage_pct: f64,
40
41    /// Position sizing: fraction of equity per trade (0.0 - 1.0)
42    pub position_size_pct: f64,
43
44    /// Maximum number of concurrent positions (None = unlimited)
45    pub max_positions: Option<usize>,
46
47    /// Allow short selling
48    pub allow_short: bool,
49
50    /// Require signal strength threshold to trigger trades (0.0 - 1.0)
51    pub min_signal_strength: f64,
52
53    /// Stop-loss percentage (0.0 - 1.0). Auto-exit if loss exceeds this.
54    pub stop_loss_pct: Option<f64>,
55
56    /// Take-profit percentage (0.0 - 1.0). Auto-exit if profit exceeds this.
57    pub take_profit_pct: Option<f64>,
58
59    /// Close any open position at end of backtest
60    pub close_at_end: bool,
61}
62
63impl Default for BacktestConfig {
64    fn default() -> Self {
65        Self {
66            initial_capital: 10_000.0,
67            commission: 0.0,
68            commission_pct: 0.001,  // 0.1% per trade
69            slippage_pct: 0.001,    // 0.1% slippage
70            position_size_pct: 1.0, // Use 100% of available capital
71            max_positions: Some(1), // Single position at a time
72            allow_short: false,
73            min_signal_strength: 0.0,
74            stop_loss_pct: None,
75            take_profit_pct: None,
76            close_at_end: true,
77        }
78    }
79}
80
81impl BacktestConfig {
82    /// Create a new builder
83    pub fn builder() -> BacktestConfigBuilder {
84        BacktestConfigBuilder::default()
85    }
86
87    /// Validate configuration parameters
88    pub fn validate(&self) -> Result<()> {
89        if self.initial_capital <= 0.0 {
90            return Err(BacktestError::invalid_param(
91                "initial_capital",
92                "must be positive",
93            ));
94        }
95
96        if self.commission < 0.0 {
97            return Err(BacktestError::invalid_param(
98                "commission",
99                "cannot be negative",
100            ));
101        }
102
103        if !(0.0..=1.0).contains(&self.commission_pct) {
104            return Err(BacktestError::invalid_param(
105                "commission_pct",
106                "must be between 0.0 and 1.0",
107            ));
108        }
109
110        if !(0.0..=1.0).contains(&self.slippage_pct) {
111            return Err(BacktestError::invalid_param(
112                "slippage_pct",
113                "must be between 0.0 and 1.0",
114            ));
115        }
116
117        if !(0.0..=1.0).contains(&self.position_size_pct) {
118            return Err(BacktestError::invalid_param(
119                "position_size_pct",
120                "must be between 0.0 and 1.0",
121            ));
122        }
123
124        if !(0.0..=1.0).contains(&self.min_signal_strength) {
125            return Err(BacktestError::invalid_param(
126                "min_signal_strength",
127                "must be between 0.0 and 1.0",
128            ));
129        }
130
131        if let Some(sl) = self.stop_loss_pct
132            && !(0.0..=1.0).contains(&sl)
133        {
134            return Err(BacktestError::invalid_param(
135                "stop_loss_pct",
136                "must be between 0.0 and 1.0",
137            ));
138        }
139
140        if let Some(tp) = self.take_profit_pct
141            && !(0.0..=1.0).contains(&tp)
142        {
143            return Err(BacktestError::invalid_param(
144                "take_profit_pct",
145                "must be between 0.0 and 1.0",
146            ));
147        }
148
149        Ok(())
150    }
151
152    /// Calculate commission for a trade value
153    pub fn calculate_commission(&self, trade_value: f64) -> f64 {
154        self.commission + (trade_value * self.commission_pct)
155    }
156
157    /// Apply slippage to a price (for entry)
158    pub fn apply_entry_slippage(&self, price: f64, is_long: bool) -> f64 {
159        if is_long {
160            // Buying: price goes up slightly
161            price * (1.0 + self.slippage_pct)
162        } else {
163            // Shorting: price goes down slightly (less favorable entry)
164            price * (1.0 - self.slippage_pct)
165        }
166    }
167
168    /// Apply slippage to a price (for exit)
169    pub fn apply_exit_slippage(&self, price: f64, is_long: bool) -> f64 {
170        if is_long {
171            // Selling long: price goes down slightly
172            price * (1.0 - self.slippage_pct)
173        } else {
174            // Covering short: price goes up slightly
175            price * (1.0 + self.slippage_pct)
176        }
177    }
178
179    /// Calculate position size based on available capital
180    pub fn calculate_position_size(&self, available_capital: f64, price: f64) -> f64 {
181        let capital_to_use = available_capital * self.position_size_pct;
182
183        // Account for commission to ensure we don't exceed available capital
184        // Commission is commission_pct of the total entry value
185        // If we use capital C, commission is commission_pct * C
186        // Total needed: C + (commission_pct * C) = C * (1 + commission_pct)
187        // So we should use: capital_to_use / (1 + commission_pct)
188        let adjusted_capital = capital_to_use / (1.0 + self.commission_pct);
189
190        adjusted_capital / price
191    }
192}
193
194/// Builder for BacktestConfig
195#[derive(Default)]
196pub struct BacktestConfigBuilder {
197    config: BacktestConfig,
198}
199
200impl BacktestConfigBuilder {
201    /// Set initial capital
202    pub fn initial_capital(mut self, capital: f64) -> Self {
203        self.config.initial_capital = capital;
204        self
205    }
206
207    /// Set flat commission per trade
208    pub fn commission(mut self, fee: f64) -> Self {
209        self.config.commission = fee;
210        self
211    }
212
213    /// Set commission as percentage of trade value
214    pub fn commission_pct(mut self, pct: f64) -> Self {
215        self.config.commission_pct = pct;
216        self
217    }
218
219    /// Set slippage as percentage of price
220    pub fn slippage_pct(mut self, pct: f64) -> Self {
221        self.config.slippage_pct = pct;
222        self
223    }
224
225    /// Set position size as fraction of available equity
226    pub fn position_size_pct(mut self, pct: f64) -> Self {
227        self.config.position_size_pct = pct;
228        self
229    }
230
231    /// Set maximum concurrent positions
232    pub fn max_positions(mut self, max: usize) -> Self {
233        self.config.max_positions = Some(max);
234        self
235    }
236
237    /// Allow unlimited concurrent positions
238    pub fn unlimited_positions(mut self) -> Self {
239        self.config.max_positions = None;
240        self
241    }
242
243    /// Allow or disallow short selling
244    pub fn allow_short(mut self, allow: bool) -> Self {
245        self.config.allow_short = allow;
246        self
247    }
248
249    /// Set minimum signal strength threshold
250    pub fn min_signal_strength(mut self, threshold: f64) -> Self {
251        self.config.min_signal_strength = threshold;
252        self
253    }
254
255    /// Set stop-loss percentage (auto-exit if loss exceeds this)
256    pub fn stop_loss_pct(mut self, pct: f64) -> Self {
257        self.config.stop_loss_pct = Some(pct);
258        self
259    }
260
261    /// Set take-profit percentage (auto-exit if profit exceeds this)
262    pub fn take_profit_pct(mut self, pct: f64) -> Self {
263        self.config.take_profit_pct = Some(pct);
264        self
265    }
266
267    /// Set whether to close open positions at end of backtest
268    pub fn close_at_end(mut self, close: bool) -> Self {
269        self.config.close_at_end = close;
270        self
271    }
272
273    /// Build and validate the configuration
274    pub fn build(self) -> Result<BacktestConfig> {
275        self.config.validate()?;
276        Ok(self.config)
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn test_default_config() {
286        let config = BacktestConfig::default();
287        assert_eq!(config.initial_capital, 10_000.0);
288        assert!(config.validate().is_ok());
289    }
290
291    #[test]
292    fn test_builder() {
293        let config = BacktestConfig::builder()
294            .initial_capital(50_000.0)
295            .commission_pct(0.002)
296            .allow_short(true)
297            .stop_loss_pct(0.05)
298            .take_profit_pct(0.10)
299            .build()
300            .unwrap();
301
302        assert_eq!(config.initial_capital, 50_000.0);
303        assert_eq!(config.commission_pct, 0.002);
304        assert!(config.allow_short);
305        assert_eq!(config.stop_loss_pct, Some(0.05));
306        assert_eq!(config.take_profit_pct, Some(0.10));
307    }
308
309    #[test]
310    fn test_validation_failures() {
311        assert!(
312            BacktestConfig::builder()
313                .initial_capital(-100.0)
314                .build()
315                .is_err()
316        );
317
318        assert!(
319            BacktestConfig::builder()
320                .commission_pct(1.5)
321                .build()
322                .is_err()
323        );
324
325        assert!(
326            BacktestConfig::builder()
327                .stop_loss_pct(2.0)
328                .build()
329                .is_err()
330        );
331    }
332
333    #[test]
334    fn test_commission_calculation() {
335        let config = BacktestConfig::builder()
336            .commission(5.0)
337            .commission_pct(0.01)
338            .build()
339            .unwrap();
340
341        // For $1000 trade: $5 flat + 1% = $5 + $10 = $15
342        let commission = config.calculate_commission(1000.0);
343        assert!((commission - 15.0).abs() < 0.01);
344    }
345
346    #[test]
347    fn test_slippage() {
348        let config = BacktestConfig::builder()
349            .slippage_pct(0.01) // 1%
350            .build()
351            .unwrap();
352
353        // Long entry: price goes up
354        let entry_price = config.apply_entry_slippage(100.0, true);
355        assert!((entry_price - 101.0).abs() < 0.01);
356
357        // Long exit: price goes down
358        let exit_price = config.apply_exit_slippage(100.0, true);
359        assert!((exit_price - 99.0).abs() < 0.01);
360
361        // Short entry: price goes down (less favorable)
362        let short_entry = config.apply_entry_slippage(100.0, false);
363        assert!((short_entry - 99.0).abs() < 0.01);
364
365        // Short exit: price goes up
366        let short_exit = config.apply_exit_slippage(100.0, false);
367        assert!((short_exit - 101.0).abs() < 0.01);
368    }
369
370    #[test]
371    fn test_position_sizing() {
372        let config = BacktestConfig::builder()
373            .position_size_pct(0.5) // Use 50% of capital
374            .commission_pct(0.0) // No commission for simpler test
375            .build()
376            .unwrap();
377
378        // With $10,000 and price $100, use $5,000 -> 50 shares
379        let size = config.calculate_position_size(10_000.0, 100.0);
380        assert!((size - 50.0).abs() < 0.01);
381    }
382
383    #[test]
384    fn test_position_sizing_with_commission() {
385        let config = BacktestConfig::builder()
386            .position_size_pct(0.5) // Use 50% of capital
387            .commission_pct(0.001) // 0.1% commission
388            .build()
389            .unwrap();
390
391        // With $10,000 and price $100, use $5,000
392        // But adjusted for commission: 5000 / 1.001 = 4995.004995
393        // So shares = 4995.004995 / 100 = 49.95
394        let size = config.calculate_position_size(10_000.0, 100.0);
395        let expected = 5000.0 / 1.001 / 100.0;
396        assert!((size - expected).abs() < 0.01);
397    }
398}