Skip to main content

finance_query/backtesting/config/
mod.rs

1//! Backtest configuration.
2
3mod builder;
4mod costs;
5mod sizing;
6
7use serde::{Deserialize, Serialize};
8
9use super::error::{BacktestError, Result};
10
11pub use builder::BacktestConfigBuilder;
12pub use costs::CommissionFn;
13pub use sizing::{PositionSizing, SizingContext};
14
15/// Configuration for backtest execution.
16///
17/// Use `BacktestConfig::builder()` to construct with the builder pattern.
18///
19/// # Example
20///
21/// ```
22/// use finance_query::backtesting::BacktestConfig;
23///
24/// let config = BacktestConfig::builder()
25///     .initial_capital(50_000.0)
26///     .commission_pct(0.001)
27///     .slippage_pct(0.0005)
28///     .allow_short(true)
29///     .stop_loss_pct(0.05)
30///     .take_profit_pct(0.10)
31///     .build()
32///     .unwrap();
33/// ```
34#[non_exhaustive]
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct BacktestConfig {
37    /// Initial portfolio capital in base currency
38    pub initial_capital: f64,
39
40    /// Commission per trade (flat fee)
41    pub commission: f64,
42
43    /// Commission as percentage of trade value (0.0 - 1.0)
44    pub commission_pct: f64,
45
46    /// Slippage as percentage of price (0.0 - 1.0)
47    pub slippage_pct: f64,
48
49    /// Position sizing: fraction of equity per trade (0.0 - 1.0)
50    pub position_size_pct: f64,
51
52    /// Maximum number of concurrent positions (None = unlimited)
53    pub max_positions: Option<usize>,
54
55    /// Allow short selling
56    pub allow_short: bool,
57
58    /// Require signal strength threshold to trigger trades (0.0 - 1.0)
59    pub min_signal_strength: f64,
60
61    /// Stop-loss percentage (0.0 - 1.0). Auto-exit if loss exceeds this.
62    pub stop_loss_pct: Option<f64>,
63
64    /// Take-profit percentage (0.0 - 1.0). Auto-exit if profit exceeds this.
65    pub take_profit_pct: Option<f64>,
66
67    /// Close any open position at end of backtest
68    pub close_at_end: bool,
69
70    /// Annual risk-free rate for Sharpe/Sortino/Calmar ratio calculations (0.0 - 1.0).
71    ///
72    /// Defaults to `0.0`. Use the current T-bill rate for accurate ratios
73    /// (e.g. `0.05` for 5% annual). Converted to a per-period rate internally.
74    pub risk_free_rate: f64,
75
76    /// Trailing stop percentage (0.0 - 1.0).
77    ///
78    /// For **long** positions: tracks the peak (highest) price since entry and
79    /// triggers an exit when the price drops this fraction below the peak.
80    ///
81    /// For **short** positions: tracks the trough (lowest) price since entry and
82    /// triggers an exit when the price rises this fraction above the trough.
83    ///
84    /// Checked before strategy signals each bar, same as `stop_loss_pct` and
85    /// `take_profit_pct`. Exit slippage is applied.
86    pub trailing_stop_pct: Option<f64>,
87
88    /// When `true`, dividend income received during a holding period is
89    /// notionally reinvested: the income is included in the trade's P&L as
90    /// if additional shares were purchased at the dividend ex-date close price.
91    ///
92    /// When `false` (default), dividend income is simply added to P&L at close.
93    /// In both cases the dividend amount is recorded on the `Trade` for reporting.
94    pub reinvest_dividends: bool,
95
96    /// Number of bars per calendar year, used for annualising returns and ratios.
97    ///
98    /// Defaults to `252.0` (US equity daily bars). Set to `52.0` for weekly
99    /// bars, `12.0` for monthly, or `252.0 * 6.5` (≈ 1638) for hourly bars.
100    /// This affects annualised return, Sharpe, Sortino, Calmar, and all
101    /// benchmark metrics.
102    pub bars_per_year: f64,
103
104    // ── Broker simulation ────────────────────────────────────────────────────
105    /// Symmetric bid-ask spread as a fraction of price (0.0 – 1.0).
106    ///
107    /// On each fill, **half** the spread widens the entry price adversely and
108    /// **half** widens the exit price adversely (independent of [`slippage_pct`],
109    /// which models directional market impact). For example, a `0.0002` spread
110    /// (2 bps) costs 1 bp on entry and 1 bp on exit.
111    ///
112    /// Defaults to `0.0`.
113    ///
114    /// [`slippage_pct`]: Self::slippage_pct
115    pub spread_pct: f64,
116
117    /// Transaction tax as a fraction of trade value, applied on **buy** orders
118    /// only (0.0 – 1.0).
119    ///
120    /// Models jurisdiction-specific purchase taxes such as the UK Stamp Duty
121    /// Reserve Tax (0.5 %). Applied on:
122    /// - Long entries (buying shares)
123    /// - Short exits (covering the short — i.e. buying to close)
124    ///
125    /// Defaults to `0.0`.
126    pub transaction_tax_pct: f64,
127
128    /// Custom commission function `f(size, price) -> commission`.
129    ///
130    /// When `Some`, **replaces** the flat [`commission`] + percentage
131    /// [`commission_pct`] fields. The function receives the fill quantity
132    /// (`size`) and the fill price (`price`) and must return the total
133    /// commission amount in the same currency as [`initial_capital`].
134    ///
135    /// **Not serialized** — reconstruct after deserialization if needed.
136    ///
137    /// [`commission`]: Self::commission
138    /// [`commission_pct`]: Self::commission_pct
139    /// [`initial_capital`]: Self::initial_capital
140    #[serde(skip)]
141    pub commission_fn: Option<CommissionFn>,
142
143    /// Maximum gross exposure as a multiple of equity.
144    ///
145    /// `1.0` (the default) is a cash account: an entry can commit at most the
146    /// available equity. Above `1.0` the shortfall is a margin loan, charged at
147    /// [`margin_interest_rate`] and subject to [`maintenance_margin_pct`]. A
148    /// short credits its proceeds to cash, so it pays [`short_borrow_rate`]
149    /// rather than margin interest at any leverage.
150    ///
151    /// [`margin_interest_rate`]: Self::margin_interest_rate
152    /// [`maintenance_margin_pct`]: Self::maintenance_margin_pct
153    /// [`short_borrow_rate`]: Self::short_borrow_rate
154    #[serde(default = "default_max_leverage")]
155    pub max_leverage: f64,
156
157    /// Equity floor as a fraction of gross exposure, below which the broker
158    /// liquidates the position (0.0 - 1.0).
159    ///
160    /// Consulted for any levered position, and for a short at any leverage: a
161    /// short's exposure grows as price rises while its equity falls, so it can
162    /// breach the floor without a margin loan. An unlevered long cannot.
163    ///
164    /// [`max_leverage`]: Self::max_leverage
165    #[serde(default = "default_maintenance_margin_pct")]
166    pub maintenance_margin_pct: f64,
167
168    /// Annual rate charged on the value of borrowed shares while a short
169    /// position is open (0.0 - 1.0).
170    ///
171    /// Prorated per bar by [`bars_per_year`]. Defaults to `0.0`.
172    ///
173    /// [`bars_per_year`]: Self::bars_per_year
174    #[serde(default)]
175    pub short_borrow_rate: f64,
176
177    /// Annual rate charged on a debit cash balance (0.0 - 1.0).
178    ///
179    /// A leveraged long drives cash negative; that shortfall is the margin
180    /// loan. Prorated per bar by [`bars_per_year`]. Defaults to `0.0`, which
181    /// makes leverage free and will flatter any leveraged strategy.
182    ///
183    /// [`bars_per_year`]: Self::bars_per_year
184    #[serde(default)]
185    pub margin_interest_rate: f64,
186
187    /// Scheme used to size each entry.
188    ///
189    /// Defaults to [`PositionSizing::FixedFraction`], which commits
190    /// [`position_size_pct`] of equity. Every other scheme treats that field as
191    /// a ceiling and sizes at or below it.
192    ///
193    /// [`position_size_pct`]: Self::position_size_pct
194    #[serde(default)]
195    pub position_sizing: PositionSizing,
196}
197
198fn default_max_leverage() -> f64 {
199    1.0
200}
201
202fn default_maintenance_margin_pct() -> f64 {
203    0.25
204}
205
206impl Default for BacktestConfig {
207    fn default() -> Self {
208        Self {
209            initial_capital: 10_000.0,
210            commission: 0.0,
211            commission_pct: 0.001,  // 0.1% per trade
212            slippage_pct: 0.001,    // 0.1% slippage
213            position_size_pct: 1.0, // Use 100% of available capital
214            max_positions: Some(1), // Single position at a time
215            allow_short: false,
216            min_signal_strength: 0.0,
217            stop_loss_pct: None,
218            take_profit_pct: None,
219            close_at_end: true,
220            risk_free_rate: 0.0,
221            trailing_stop_pct: None,
222            reinvest_dividends: false,
223            bars_per_year: 252.0,
224            spread_pct: 0.0,
225            transaction_tax_pct: 0.0,
226            commission_fn: None,
227            max_leverage: default_max_leverage(),
228            maintenance_margin_pct: default_maintenance_margin_pct(),
229            short_borrow_rate: 0.0,
230            margin_interest_rate: 0.0,
231            position_sizing: PositionSizing::default(),
232        }
233    }
234}
235
236impl BacktestConfig {
237    /// Create a zero-cost configuration with no commission, slippage, spread, or tax.
238    ///
239    /// Useful for unit tests and frictionless benchmark comparisons.
240    /// All other fields use the same defaults as [`BacktestConfig::default()`].
241    pub fn zero_cost() -> Self {
242        Self {
243            commission: 0.0,
244            commission_pct: 0.0,
245            slippage_pct: 0.0,
246            spread_pct: 0.0,
247            transaction_tax_pct: 0.0,
248            commission_fn: None,
249            ..Default::default()
250        }
251    }
252
253    /// Create a new builder
254    pub fn builder() -> BacktestConfigBuilder {
255        BacktestConfigBuilder::default()
256    }
257
258    /// Validate configuration parameters
259    pub fn validate(&self) -> Result<()> {
260        if !self.initial_capital.is_finite() || self.initial_capital <= 0.0 {
261            return Err(BacktestError::invalid_param(
262                "initial_capital",
263                "must be finite and positive",
264            ));
265        }
266
267        if !self.commission.is_finite() || self.commission < 0.0 {
268            return Err(BacktestError::invalid_param(
269                "commission",
270                "must be finite and cannot be negative",
271            ));
272        }
273
274        if !(0.0..=1.0).contains(&self.commission_pct) {
275            return Err(BacktestError::invalid_param(
276                "commission_pct",
277                "must be between 0.0 and 1.0",
278            ));
279        }
280
281        if !(0.0..=1.0).contains(&self.slippage_pct) {
282            return Err(BacktestError::invalid_param(
283                "slippage_pct",
284                "must be between 0.0 and 1.0",
285            ));
286        }
287
288        if !(self.position_size_pct > 0.0 && self.position_size_pct <= 1.0) {
289            return Err(BacktestError::invalid_param(
290                "position_size_pct",
291                "must be between 0.0 (exclusive) and 1.0 (inclusive)",
292            ));
293        }
294
295        if !(0.0..=1.0).contains(&self.min_signal_strength) {
296            return Err(BacktestError::invalid_param(
297                "min_signal_strength",
298                "must be between 0.0 and 1.0",
299            ));
300        }
301
302        if let Some(sl) = self.stop_loss_pct
303            && !(0.0..=1.0).contains(&sl)
304        {
305            return Err(BacktestError::invalid_param(
306                "stop_loss_pct",
307                "must be between 0.0 and 1.0",
308            ));
309        }
310
311        if let Some(tp) = self.take_profit_pct
312            && !(0.0..=1.0).contains(&tp)
313        {
314            return Err(BacktestError::invalid_param(
315                "take_profit_pct",
316                "must be between 0.0 and 1.0",
317            ));
318        }
319
320        if !(0.0..=1.0).contains(&self.risk_free_rate) {
321            return Err(BacktestError::invalid_param(
322                "risk_free_rate",
323                "must be between 0.0 and 1.0",
324            ));
325        }
326
327        if let Some(trail) = self.trailing_stop_pct
328            && !(0.0..=1.0).contains(&trail)
329        {
330            return Err(BacktestError::invalid_param(
331                "trailing_stop_pct",
332                "must be between 0.0 and 1.0",
333            ));
334        }
335
336        if !self.bars_per_year.is_finite() || self.bars_per_year <= 0.0 {
337            return Err(BacktestError::invalid_param(
338                "bars_per_year",
339                "must be finite and positive (e.g. 252 for daily, 52 for weekly)",
340            ));
341        }
342
343        if !(0.0..=1.0).contains(&self.spread_pct) {
344            return Err(BacktestError::invalid_param(
345                "spread_pct",
346                "must be between 0.0 and 1.0",
347            ));
348        }
349
350        if !(0.0..=1.0).contains(&self.transaction_tax_pct) {
351            return Err(BacktestError::invalid_param(
352                "transaction_tax_pct",
353                "must be between 0.0 and 1.0",
354            ));
355        }
356
357        if !self.max_leverage.is_finite() || self.max_leverage < 1.0 {
358            return Err(BacktestError::invalid_param(
359                "max_leverage",
360                "must be finite and at least 1.0",
361            ));
362        }
363
364        if !(0.0..=1.0).contains(&self.maintenance_margin_pct) {
365            return Err(BacktestError::invalid_param(
366                "maintenance_margin_pct",
367                "must be between 0.0 and 1.0",
368            ));
369        }
370
371        if (self.max_leverage > 1.0 || self.allow_short)
372            && self.max_leverage * self.maintenance_margin_pct >= 1.0
373        {
374            return Err(BacktestError::invalid_param(
375                "max_leverage",
376                "leverage times maintenance_margin_pct must be below 1.0, or a \
377                 full-size entry (levered, or short at any leverage) is \
378                 liquidated on the bar after it opens",
379            ));
380        }
381
382        if !(0.0..=1.0).contains(&self.short_borrow_rate) {
383            return Err(BacktestError::invalid_param(
384                "short_borrow_rate",
385                "must be between 0.0 and 1.0",
386            ));
387        }
388
389        if !(0.0..=1.0).contains(&self.margin_interest_rate) {
390            return Err(BacktestError::invalid_param(
391                "margin_interest_rate",
392                "must be between 0.0 and 1.0",
393            ));
394        }
395
396        self.validate_position_sizing()?;
397
398        Ok(())
399    }
400
401    fn validate_position_sizing(&self) -> Result<()> {
402        match self.position_sizing {
403            PositionSizing::FixedFraction => {}
404            PositionSizing::Atr {
405                risk_pct,
406                atr_period,
407                atr_multiple,
408            } => {
409                if !(0.0..=1.0).contains(&risk_pct) {
410                    return Err(BacktestError::invalid_param(
411                        "position_sizing.risk_pct",
412                        "must be between 0.0 and 1.0",
413                    ));
414                }
415                if atr_period == 0 {
416                    return Err(BacktestError::invalid_param(
417                        "position_sizing.atr_period",
418                        "must be at least 1",
419                    ));
420                }
421                if !atr_multiple.is_finite() || atr_multiple <= 0.0 {
422                    return Err(BacktestError::invalid_param(
423                        "position_sizing.atr_multiple",
424                        "must be finite and positive",
425                    ));
426                }
427            }
428            PositionSizing::VolatilityTarget {
429                target_vol_pct,
430                lookback,
431            } => {
432                if !(0.0..=1.0).contains(&target_vol_pct) {
433                    return Err(BacktestError::invalid_param(
434                        "position_sizing.target_vol_pct",
435                        "must be between 0.0 and 1.0",
436                    ));
437                }
438                if lookback < 2 {
439                    return Err(BacktestError::invalid_param(
440                        "position_sizing.lookback",
441                        "must be at least 2",
442                    ));
443                }
444            }
445            PositionSizing::FractionalKelly {
446                kelly_fraction,
447                lookback_trades,
448            } => {
449                if !(0.0..=1.0).contains(&kelly_fraction) {
450                    return Err(BacktestError::invalid_param(
451                        "position_sizing.kelly_fraction",
452                        "must be between 0.0 and 1.0",
453                    ));
454                }
455                if lookback_trades == 0 {
456                    return Err(BacktestError::invalid_param(
457                        "position_sizing.lookback_trades",
458                        "must be at least 1",
459                    ));
460                }
461            }
462        }
463
464        Ok(())
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_default_config() {
474        let config = BacktestConfig::default();
475        assert_eq!(config.initial_capital, 10_000.0);
476        assert!(config.validate().is_ok());
477    }
478
479    #[test]
480    fn test_leverage_rejected_when_it_cannot_survive_its_own_entry() {
481        let levered = |leverage: f64, maintenance: f64| {
482            BacktestConfig::builder()
483                .max_leverage(leverage)
484                .maintenance_margin_pct(maintenance)
485                .build()
486        };
487
488        assert!(levered(5.0, 0.25).is_err());
489        assert!(levered(4.0, 0.25).is_err());
490        assert!(levered(3.0, 0.25).is_ok());
491        assert!(levered(1.0, 1.0).is_ok());
492
493        assert!(
494            BacktestConfig::builder()
495                .max_leverage(1.0)
496                .maintenance_margin_pct(1.0)
497                .allow_short(true)
498                .build()
499                .is_err()
500        );
501    }
502
503    #[test]
504    fn test_position_sizing_validation_failures() {
505        let sizing = |s: PositionSizing| BacktestConfig::builder().position_sizing(s).build();
506
507        assert!(
508            sizing(PositionSizing::Atr {
509                risk_pct: 0.02,
510                atr_period: 0,
511                atr_multiple: 2.0,
512            })
513            .is_err()
514        );
515        assert!(
516            sizing(PositionSizing::Atr {
517                risk_pct: -0.01,
518                atr_period: 14,
519                atr_multiple: 2.0,
520            })
521            .is_err()
522        );
523        assert!(
524            sizing(PositionSizing::Atr {
525                risk_pct: 0.02,
526                atr_period: 14,
527                atr_multiple: 0.0,
528            })
529            .is_err()
530        );
531        assert!(
532            sizing(PositionSizing::VolatilityTarget {
533                target_vol_pct: 0.01,
534                lookback: 1,
535            })
536            .is_err()
537        );
538        assert!(
539            sizing(PositionSizing::FractionalKelly {
540                kelly_fraction: 0.5,
541                lookback_trades: 0,
542            })
543            .is_err()
544        );
545        assert!(
546            sizing(PositionSizing::FractionalKelly {
547                kelly_fraction: -0.5,
548                lookback_trades: 20,
549            })
550            .is_err()
551        );
552        assert!(
553            sizing(PositionSizing::Atr {
554                risk_pct: 0.02,
555                atr_period: 14,
556                atr_multiple: 2.0,
557            })
558            .is_ok()
559        );
560    }
561
562    #[test]
563    fn test_validation_failures() {
564        assert!(
565            BacktestConfig::builder()
566                .initial_capital(-100.0)
567                .build()
568                .is_err()
569        );
570
571        assert!(
572            BacktestConfig::builder()
573                .commission_pct(1.5)
574                .build()
575                .is_err()
576        );
577
578        assert!(
579            BacktestConfig::builder()
580                .stop_loss_pct(2.0)
581                .build()
582                .is_err()
583        );
584    }
585
586    #[test]
587    fn test_risk_free_rate() {
588        let config = BacktestConfig::builder()
589            .risk_free_rate(0.05)
590            .build()
591            .unwrap();
592        assert!((config.risk_free_rate - 0.05).abs() < f64::EPSILON);
593
594        // Out-of-range should fail
595        assert!(
596            BacktestConfig::builder()
597                .risk_free_rate(1.5)
598                .build()
599                .is_err()
600        );
601    }
602
603    #[test]
604    fn test_position_size_zero_rejected() {
605        assert!(
606            BacktestConfig::builder()
607                .position_size_pct(0.0)
608                .build()
609                .is_err()
610        );
611    }
612
613    #[test]
614    fn test_bars_per_year_validation() {
615        // Default is 252
616        let config = BacktestConfig::default();
617        assert!((config.bars_per_year - 252.0).abs() < f64::EPSILON);
618        assert!(config.validate().is_ok());
619
620        // Valid custom value
621        let config = BacktestConfig::builder()
622            .bars_per_year(52.0)
623            .build()
624            .unwrap();
625        assert!((config.bars_per_year - 52.0).abs() < f64::EPSILON);
626
627        // Zero must be rejected
628        assert!(
629            BacktestConfig::builder()
630                .bars_per_year(0.0)
631                .build()
632                .is_err()
633        );
634
635        // Negative must be rejected
636        assert!(
637            BacktestConfig::builder()
638                .bars_per_year(-1.0)
639                .build()
640                .is_err()
641        );
642    }
643
644    #[test]
645    fn test_spread_validation() {
646        assert!(BacktestConfig::builder().spread_pct(1.5).build().is_err());
647        assert!(BacktestConfig::builder().spread_pct(-0.01).build().is_err());
648        assert!(BacktestConfig::builder().spread_pct(0.0).build().is_ok());
649        assert!(BacktestConfig::builder().spread_pct(1.0).build().is_ok());
650    }
651
652    #[test]
653    fn test_transaction_tax_validation() {
654        assert!(
655            BacktestConfig::builder()
656                .transaction_tax_pct(1.5)
657                .build()
658                .is_err()
659        );
660        assert!(
661            BacktestConfig::builder()
662                .transaction_tax_pct(-0.001)
663                .build()
664                .is_err()
665        );
666    }
667
668    #[test]
669    fn test_margin_defaults_are_a_cash_account() {
670        let config = BacktestConfig::default();
671        assert_eq!(config.max_leverage, 1.0);
672        assert_eq!(config.maintenance_margin_pct, 0.25);
673        assert_eq!(config.short_borrow_rate, 0.0);
674        assert_eq!(config.margin_interest_rate, 0.0);
675        assert_eq!(config.position_sizing, PositionSizing::FixedFraction);
676    }
677
678    #[test]
679    fn test_margin_field_validation() {
680        assert!(BacktestConfig::builder().max_leverage(0.5).build().is_err());
681        assert!(
682            BacktestConfig::builder()
683                .max_leverage(f64::NAN)
684                .build()
685                .is_err()
686        );
687        assert!(BacktestConfig::builder().max_leverage(3.0).build().is_ok());
688
689        assert!(
690            BacktestConfig::builder()
691                .maintenance_margin_pct(1.5)
692                .build()
693                .is_err()
694        );
695        assert!(
696            BacktestConfig::builder()
697                .short_borrow_rate(-0.01)
698                .build()
699                .is_err()
700        );
701        assert!(
702            BacktestConfig::builder()
703                .margin_interest_rate(1.5)
704                .build()
705                .is_err()
706        );
707    }
708
709    #[test]
710    fn test_non_finite_fields_rejected() {
711        assert!(
712            BacktestConfig::builder()
713                .initial_capital(f64::NAN)
714                .build()
715                .is_err()
716        );
717        assert!(
718            BacktestConfig::builder()
719                .initial_capital(f64::INFINITY)
720                .build()
721                .is_err()
722        );
723        assert!(
724            BacktestConfig::builder()
725                .commission(f64::NAN)
726                .build()
727                .is_err()
728        );
729        assert!(
730            BacktestConfig::builder()
731                .commission(f64::INFINITY)
732                .build()
733                .is_err()
734        );
735        assert!(
736            BacktestConfig::builder()
737                .position_size_pct(f64::NAN)
738                .build()
739                .is_err()
740        );
741        assert!(
742            BacktestConfig::builder()
743                .bars_per_year(f64::NAN)
744                .build()
745                .is_err()
746        );
747        assert!(
748            BacktestConfig::builder()
749                .bars_per_year(f64::INFINITY)
750                .build()
751                .is_err()
752        );
753    }
754
755    #[test]
756    fn test_config_without_margin_fields_deserializes_to_defaults() {
757        let json = serde_json::json!({
758            "initial_capital": 10_000.0,
759            "commission": 0.0,
760            "commission_pct": 0.001,
761            "slippage_pct": 0.001,
762            "position_size_pct": 1.0,
763            "max_positions": 1,
764            "allow_short": false,
765            "min_signal_strength": 0.0,
766            "stop_loss_pct": null,
767            "take_profit_pct": null,
768            "close_at_end": true,
769            "risk_free_rate": 0.0,
770            "trailing_stop_pct": null,
771            "reinvest_dividends": false,
772            "bars_per_year": 252.0,
773            "spread_pct": 0.0,
774            "transaction_tax_pct": 0.0,
775        });
776        let config: BacktestConfig = serde_json::from_value(json).unwrap();
777        assert_eq!(config.max_leverage, 1.0);
778        assert_eq!(config.maintenance_margin_pct, 0.25);
779        assert_eq!(config.short_borrow_rate, 0.0);
780        assert_eq!(config.margin_interest_rate, 0.0);
781        assert_eq!(config.position_sizing, PositionSizing::FixedFraction);
782    }
783
784    #[test]
785    fn test_zero_cost_clears_new_fields() {
786        let config = BacktestConfig::zero_cost();
787        assert_eq!(config.spread_pct, 0.0);
788        assert_eq!(config.transaction_tax_pct, 0.0);
789        assert!(config.commission_fn.is_none());
790    }
791}