Skip to main content

finance_query/backtesting/
config.rs

1//! Backtest configuration and builder.
2
3use std::fmt;
4use std::sync::Arc;
5
6use serde::{Deserialize, Serialize};
7
8use super::error::{BacktestError, Result};
9
10// ── CommissionFn ──────────────────────────────────────────────────────────────
11
12/// A custom commission function: `f(size, price) -> commission_amount`.
13///
14/// When set on [`BacktestConfig`] via [`BacktestConfigBuilder::commission_fn`],
15/// it **replaces** the flat `commission` + percentage `commission_pct` fields.
16/// Use it to model broker-specific fee schedules such as per-share fees with
17/// a minimum, tiered rates, or Robinhood-style zero-commission structures.
18///
19/// # Example
20///
21/// ```
22/// use finance_query::backtesting::BacktestConfig;
23///
24/// // IB-style: $0.005 per share, minimum $1.00 per order
25/// let config = BacktestConfig::builder()
26///     .commission_fn(|size, price| (size * 0.005_f64).max(1.00))
27///     .build()
28///     .unwrap();
29/// ```
30#[derive(Clone)]
31pub struct CommissionFn(Arc<dyn Fn(f64, f64) -> f64 + Send + Sync>);
32
33impl CommissionFn {
34    /// Create from any closure or function pointer matching `Fn(f64, f64) -> f64`.
35    pub fn new<F>(f: F) -> Self
36    where
37        F: Fn(f64, f64) -> f64 + Send + Sync + 'static,
38    {
39        Self(Arc::new(f))
40    }
41
42    /// Call the underlying function with `(size, price)`.
43    #[inline]
44    pub(crate) fn call(&self, size: f64, price: f64) -> f64 {
45        (self.0)(size, price)
46    }
47}
48
49impl fmt::Debug for CommissionFn {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        write!(f, "CommissionFn(<closure>)")
52    }
53}
54
55/// Configuration for backtest execution.
56///
57/// Use `BacktestConfig::builder()` to construct with the builder pattern.
58///
59/// # Example
60///
61/// ```
62/// use finance_query::backtesting::BacktestConfig;
63///
64/// let config = BacktestConfig::builder()
65///     .initial_capital(50_000.0)
66///     .commission_pct(0.001)
67///     .slippage_pct(0.0005)
68///     .allow_short(true)
69///     .stop_loss_pct(0.05)
70///     .take_profit_pct(0.10)
71///     .build()
72///     .unwrap();
73/// ```
74#[non_exhaustive]
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct BacktestConfig {
77    /// Initial portfolio capital in base currency
78    pub initial_capital: f64,
79
80    /// Commission per trade (flat fee)
81    pub commission: f64,
82
83    /// Commission as percentage of trade value (0.0 - 1.0)
84    pub commission_pct: f64,
85
86    /// Slippage as percentage of price (0.0 - 1.0)
87    pub slippage_pct: f64,
88
89    /// Position sizing: fraction of equity per trade (0.0 - 1.0)
90    pub position_size_pct: f64,
91
92    /// Maximum number of concurrent positions (None = unlimited)
93    pub max_positions: Option<usize>,
94
95    /// Allow short selling
96    pub allow_short: bool,
97
98    /// Require signal strength threshold to trigger trades (0.0 - 1.0)
99    pub min_signal_strength: f64,
100
101    /// Stop-loss percentage (0.0 - 1.0). Auto-exit if loss exceeds this.
102    pub stop_loss_pct: Option<f64>,
103
104    /// Take-profit percentage (0.0 - 1.0). Auto-exit if profit exceeds this.
105    pub take_profit_pct: Option<f64>,
106
107    /// Close any open position at end of backtest
108    pub close_at_end: bool,
109
110    /// Annual risk-free rate for Sharpe/Sortino/Calmar ratio calculations (0.0 - 1.0).
111    ///
112    /// Defaults to `0.0`. Use the current T-bill rate for accurate ratios
113    /// (e.g. `0.05` for 5% annual). Converted to a per-period rate internally.
114    pub risk_free_rate: f64,
115
116    /// Trailing stop percentage (0.0 - 1.0).
117    ///
118    /// For **long** positions: tracks the peak (highest) price since entry and
119    /// triggers an exit when the price drops this fraction below the peak.
120    ///
121    /// For **short** positions: tracks the trough (lowest) price since entry and
122    /// triggers an exit when the price rises this fraction above the trough.
123    ///
124    /// Checked before strategy signals each bar, same as `stop_loss_pct` and
125    /// `take_profit_pct`. Exit slippage is applied.
126    pub trailing_stop_pct: Option<f64>,
127
128    /// When `true`, dividend income received during a holding period is
129    /// notionally reinvested: the income is included in the trade's P&L as
130    /// if additional shares were purchased at the dividend ex-date close price.
131    ///
132    /// When `false` (default), dividend income is simply added to P&L at close.
133    /// In both cases the dividend amount is recorded on the `Trade` for reporting.
134    pub reinvest_dividends: bool,
135
136    /// Number of bars per calendar year, used for annualising returns and ratios.
137    ///
138    /// Defaults to `252.0` (US equity daily bars). Set to `52.0` for weekly
139    /// bars, `12.0` for monthly, or `252.0 * 6.5` (≈ 1638) for hourly bars.
140    /// This affects annualised return, Sharpe, Sortino, Calmar, and all
141    /// benchmark metrics.
142    pub bars_per_year: f64,
143
144    // ── Phase 5: Enhanced Broker Simulation ──────────────────────────────────
145    /// Symmetric bid-ask spread as a fraction of price (0.0 – 1.0).
146    ///
147    /// On each fill, **half** the spread widens the entry price adversely and
148    /// **half** widens the exit price adversely (independent of [`slippage_pct`],
149    /// which models directional market impact). For example, a `0.0002` spread
150    /// (2 bps) costs 1 bp on entry and 1 bp on exit.
151    ///
152    /// Defaults to `0.0`.
153    ///
154    /// [`slippage_pct`]: Self::slippage_pct
155    pub spread_pct: f64,
156
157    /// Transaction tax as a fraction of trade value, applied on **buy** orders
158    /// only (0.0 – 1.0).
159    ///
160    /// Models jurisdiction-specific purchase taxes such as the UK Stamp Duty
161    /// Reserve Tax (0.5 %). Applied on:
162    /// - Long entries (buying shares)
163    /// - Short exits (covering the short — i.e. buying to close)
164    ///
165    /// Defaults to `0.0`.
166    pub transaction_tax_pct: f64,
167
168    /// Custom commission function `f(size, price) -> commission`.
169    ///
170    /// When `Some`, **replaces** the flat [`commission`] + percentage
171    /// [`commission_pct`] fields. The function receives the fill quantity
172    /// (`size`) and the fill price (`price`) and must return the total
173    /// commission amount in the same currency as [`initial_capital`].
174    ///
175    /// **Not serialized** — reconstruct after deserialization if needed.
176    ///
177    /// [`commission`]: Self::commission
178    /// [`commission_pct`]: Self::commission_pct
179    /// [`initial_capital`]: Self::initial_capital
180    #[serde(skip)]
181    pub commission_fn: Option<CommissionFn>,
182}
183
184impl Default for BacktestConfig {
185    fn default() -> Self {
186        Self {
187            initial_capital: 10_000.0,
188            commission: 0.0,
189            commission_pct: 0.001,  // 0.1% per trade
190            slippage_pct: 0.001,    // 0.1% slippage
191            position_size_pct: 1.0, // Use 100% of available capital
192            max_positions: Some(1), // Single position at a time
193            allow_short: false,
194            min_signal_strength: 0.0,
195            stop_loss_pct: None,
196            take_profit_pct: None,
197            close_at_end: true,
198            risk_free_rate: 0.0,
199            trailing_stop_pct: None,
200            reinvest_dividends: false,
201            bars_per_year: 252.0,
202            spread_pct: 0.0,
203            transaction_tax_pct: 0.0,
204            commission_fn: None,
205        }
206    }
207}
208
209impl BacktestConfig {
210    /// Create a zero-cost configuration with no commission, slippage, spread, or tax.
211    ///
212    /// Useful for unit tests and frictionless benchmark comparisons.
213    /// All other fields use the same defaults as [`BacktestConfig::default()`].
214    pub fn zero_cost() -> Self {
215        Self {
216            commission: 0.0,
217            commission_pct: 0.0,
218            slippage_pct: 0.0,
219            spread_pct: 0.0,
220            transaction_tax_pct: 0.0,
221            commission_fn: None,
222            ..Default::default()
223        }
224    }
225
226    /// Create a new builder
227    pub fn builder() -> BacktestConfigBuilder {
228        BacktestConfigBuilder::default()
229    }
230
231    /// Validate configuration parameters
232    pub fn validate(&self) -> Result<()> {
233        if self.initial_capital <= 0.0 {
234            return Err(BacktestError::invalid_param(
235                "initial_capital",
236                "must be positive",
237            ));
238        }
239
240        if self.commission < 0.0 {
241            return Err(BacktestError::invalid_param(
242                "commission",
243                "cannot be negative",
244            ));
245        }
246
247        if !(0.0..=1.0).contains(&self.commission_pct) {
248            return Err(BacktestError::invalid_param(
249                "commission_pct",
250                "must be between 0.0 and 1.0",
251            ));
252        }
253
254        if !(0.0..=1.0).contains(&self.slippage_pct) {
255            return Err(BacktestError::invalid_param(
256                "slippage_pct",
257                "must be between 0.0 and 1.0",
258            ));
259        }
260
261        if self.position_size_pct <= 0.0 || self.position_size_pct > 1.0 {
262            return Err(BacktestError::invalid_param(
263                "position_size_pct",
264                "must be between 0.0 (exclusive) and 1.0 (inclusive)",
265            ));
266        }
267
268        if !(0.0..=1.0).contains(&self.min_signal_strength) {
269            return Err(BacktestError::invalid_param(
270                "min_signal_strength",
271                "must be between 0.0 and 1.0",
272            ));
273        }
274
275        if let Some(sl) = self.stop_loss_pct
276            && !(0.0..=1.0).contains(&sl)
277        {
278            return Err(BacktestError::invalid_param(
279                "stop_loss_pct",
280                "must be between 0.0 and 1.0",
281            ));
282        }
283
284        if let Some(tp) = self.take_profit_pct
285            && !(0.0..=1.0).contains(&tp)
286        {
287            return Err(BacktestError::invalid_param(
288                "take_profit_pct",
289                "must be between 0.0 and 1.0",
290            ));
291        }
292
293        if !(0.0..=1.0).contains(&self.risk_free_rate) {
294            return Err(BacktestError::invalid_param(
295                "risk_free_rate",
296                "must be between 0.0 and 1.0",
297            ));
298        }
299
300        if let Some(trail) = self.trailing_stop_pct
301            && !(0.0..=1.0).contains(&trail)
302        {
303            return Err(BacktestError::invalid_param(
304                "trailing_stop_pct",
305                "must be between 0.0 and 1.0",
306            ));
307        }
308
309        if self.bars_per_year <= 0.0 {
310            return Err(BacktestError::invalid_param(
311                "bars_per_year",
312                "must be positive (e.g. 252 for daily, 52 for weekly)",
313            ));
314        }
315
316        if !(0.0..=1.0).contains(&self.spread_pct) {
317            return Err(BacktestError::invalid_param(
318                "spread_pct",
319                "must be between 0.0 and 1.0",
320            ));
321        }
322
323        if !(0.0..=1.0).contains(&self.transaction_tax_pct) {
324            return Err(BacktestError::invalid_param(
325                "transaction_tax_pct",
326                "must be between 0.0 and 1.0",
327            ));
328        }
329
330        Ok(())
331    }
332
333    /// Calculate commission for a fill.
334    ///
335    /// When [`commission_fn`] is set it takes precedence over the flat
336    /// [`commission`] + percentage [`commission_pct`] fields.
337    ///
338    /// [`commission_fn`]: Self::commission_fn
339    /// [`commission`]: Self::commission
340    /// [`commission_pct`]: Self::commission_pct
341    pub fn calculate_commission(&self, size: f64, price: f64) -> f64 {
342        if let Some(ref f) = self.commission_fn {
343            f.call(size, price)
344        } else {
345            self.commission + (size * price * self.commission_pct)
346        }
347    }
348
349    /// Apply slippage to a price (for entry).
350    pub fn apply_entry_slippage(&self, price: f64, is_long: bool) -> f64 {
351        if is_long {
352            price * (1.0 + self.slippage_pct)
353        } else {
354            price * (1.0 - self.slippage_pct)
355        }
356    }
357
358    /// Apply slippage to a price (for exit).
359    pub fn apply_exit_slippage(&self, price: f64, is_long: bool) -> f64 {
360        if is_long {
361            price * (1.0 - self.slippage_pct)
362        } else {
363            price * (1.0 + self.slippage_pct)
364        }
365    }
366
367    /// Apply the bid-ask spread to an entry fill price (half-spread adverse).
368    ///
369    /// Long entries pay the ask (price rises by `spread_pct / 2`);
370    /// short entries receive the bid (price falls by `spread_pct / 2`).
371    pub fn apply_entry_spread(&self, price: f64, is_long: bool) -> f64 {
372        let half = self.spread_pct / 2.0;
373        if is_long {
374            price * (1.0 + half)
375        } else {
376            price * (1.0 - half)
377        }
378    }
379
380    /// Apply the bid-ask spread to an exit fill price (half-spread adverse).
381    ///
382    /// Long exits receive the bid (price falls by `spread_pct / 2`);
383    /// short exits pay the ask (price rises by `spread_pct / 2`).
384    pub fn apply_exit_spread(&self, price: f64, is_long: bool) -> f64 {
385        let half = self.spread_pct / 2.0;
386        if is_long {
387            price * (1.0 - half)
388        } else {
389            price * (1.0 + half)
390        }
391    }
392
393    /// Calculate the transaction tax on a fill.
394    ///
395    /// Tax applies only to **buy** orders (`is_buy = true`):
396    /// - Long entries (opening a long position)
397    /// - Short exits (covering a short position)
398    ///
399    /// Returns `0.0` for all sell orders.
400    pub fn calculate_transaction_tax(&self, trade_value: f64, is_buy: bool) -> f64 {
401        if is_buy {
402            trade_value * self.transaction_tax_pct
403        } else {
404            0.0
405        }
406    }
407
408    /// Calculate position size based on available capital.
409    ///
410    /// `price` **must** be the fully-adjusted entry price (after slippage and
411    /// spread) so that subsequent fill guards (`entry_value + costs > cash`)
412    /// do not over-allocate capital.
413    ///
414    /// When [`commission_fn`] is set the commission component cannot be
415    /// analytically solved for, so only spread and transaction-tax fractions
416    /// are deducted from the denominator; the fill-rejection guard catches any
417    /// remaining over-allocation.
418    ///
419    /// [`commission_fn`]: Self::commission_fn
420    pub fn calculate_position_size(&self, available_capital: f64, price: f64) -> f64 {
421        let capital_to_use = available_capital * self.position_size_pct;
422
423        let adjusted_capital = if self.commission_fn.is_some() {
424            // Can't analytically invert commission_fn; use spread + tax only.
425            // The fill-rejection guard will catch any over-allocation.
426            capital_to_use / (1.0 + self.spread_pct + self.transaction_tax_pct)
427        } else {
428            // Round-trip costs (fraction of trade value):
429            //   - Commission: 2 × commission_pct  (entry + exit)
430            //   - Spread:     spread_pct           (half each way)
431            //   - Tax:        transaction_tax_pct  (buy only — conservative for shorts)
432            let friction =
433                1.0 + 2.0 * self.commission_pct + self.spread_pct + self.transaction_tax_pct;
434            capital_to_use / friction - 2.0 * self.commission
435        };
436
437        (adjusted_capital / price).max(0.0)
438    }
439}
440
441/// Builder for BacktestConfig
442#[derive(Default)]
443pub struct BacktestConfigBuilder {
444    config: BacktestConfig,
445}
446
447impl BacktestConfigBuilder {
448    /// Set initial capital
449    pub fn initial_capital(mut self, capital: f64) -> Self {
450        self.config.initial_capital = capital;
451        self
452    }
453
454    /// Set flat commission per trade
455    pub fn commission(mut self, fee: f64) -> Self {
456        self.config.commission = fee;
457        self
458    }
459
460    /// Set commission as percentage of trade value
461    pub fn commission_pct(mut self, pct: f64) -> Self {
462        self.config.commission_pct = pct;
463        self
464    }
465
466    /// Set slippage as percentage of price
467    pub fn slippage_pct(mut self, pct: f64) -> Self {
468        self.config.slippage_pct = pct;
469        self
470    }
471
472    /// Set position size as fraction of available equity
473    pub fn position_size_pct(mut self, pct: f64) -> Self {
474        self.config.position_size_pct = pct;
475        self
476    }
477
478    /// Set maximum concurrent positions
479    pub fn max_positions(mut self, max: usize) -> Self {
480        self.config.max_positions = Some(max);
481        self
482    }
483
484    /// Allow unlimited concurrent positions
485    pub fn unlimited_positions(mut self) -> Self {
486        self.config.max_positions = None;
487        self
488    }
489
490    /// Allow or disallow short selling
491    pub fn allow_short(mut self, allow: bool) -> Self {
492        self.config.allow_short = allow;
493        self
494    }
495
496    /// Set minimum signal strength threshold
497    pub fn min_signal_strength(mut self, threshold: f64) -> Self {
498        self.config.min_signal_strength = threshold;
499        self
500    }
501
502    /// Set stop-loss percentage (auto-exit if loss exceeds this)
503    pub fn stop_loss_pct(mut self, pct: f64) -> Self {
504        self.config.stop_loss_pct = Some(pct);
505        self
506    }
507
508    /// Set take-profit percentage (auto-exit if profit exceeds this)
509    pub fn take_profit_pct(mut self, pct: f64) -> Self {
510        self.config.take_profit_pct = Some(pct);
511        self
512    }
513
514    /// Set whether to close open positions at end of backtest
515    pub fn close_at_end(mut self, close: bool) -> Self {
516        self.config.close_at_end = close;
517        self
518    }
519
520    /// Set annual risk-free rate for Sharpe/Sortino/Calmar calculations (0.0 - 1.0)
521    ///
522    /// Use the current T-bill rate for accurate ratios (e.g. `0.05` for 5%).
523    pub fn risk_free_rate(mut self, rate: f64) -> Self {
524        self.config.risk_free_rate = rate;
525        self
526    }
527
528    /// Set trailing stop percentage (0.0 - 1.0).
529    ///
530    /// For longs: exits when price drops this fraction below its peak since entry.
531    /// For shorts: exits when price rises this fraction above its trough since entry.
532    pub fn trailing_stop_pct(mut self, pct: f64) -> Self {
533        self.config.trailing_stop_pct = Some(pct);
534        self
535    }
536
537    /// Enable or disable dividend reinvestment
538    ///
539    /// When `true`, dividend income is reinvested (added to P&L as additional hypothetical shares).
540    pub fn reinvest_dividends(mut self, reinvest: bool) -> Self {
541        self.config.reinvest_dividends = reinvest;
542        self
543    }
544
545    /// Set the number of bars per calendar year for annualisation.
546    ///
547    /// Defaults to `252.0` (US equity daily bars). Common values:
548    /// - `252.0` — daily US equity
549    /// - `52.0` — weekly
550    /// - `12.0` — monthly
551    /// - `252.0 * 6.5` (≈ 1638) — hourly (6.5-hour trading day)
552    pub fn bars_per_year(mut self, n: f64) -> Self {
553        self.config.bars_per_year = n;
554        self
555    }
556
557    /// Set symmetric bid-ask spread as a fraction of price (0.0 – 1.0).
558    ///
559    /// Half the spread is applied adversely on entry and half on exit,
560    /// independent of [`slippage_pct`](BacktestConfig::slippage_pct).
561    /// For example, `0.0002` represents a 2-basis-point spread (1 bp per side).
562    pub fn spread_pct(mut self, pct: f64) -> Self {
563        self.config.spread_pct = pct;
564        self
565    }
566
567    /// Set the transaction tax as a fraction of trade value, applied on buys only.
568    ///
569    /// Models purchase taxes such as UK Stamp Duty (0.005 = 0.5 %). Applied on
570    /// long entries and short covers; not applied on sells.
571    pub fn transaction_tax_pct(mut self, pct: f64) -> Self {
572        self.config.transaction_tax_pct = pct;
573        self
574    }
575
576    /// Set a custom commission function `f(size, price) -> commission`.
577    ///
578    /// Replaces the flat [`commission`](BacktestConfig::commission) and
579    /// percentage [`commission_pct`](BacktestConfig::commission_pct) fields.
580    /// Use this to model broker-specific fee schedules.
581    ///
582    /// # Example
583    ///
584    /// ```
585    /// use finance_query::backtesting::BacktestConfig;
586    ///
587    /// // $0.005 per share, minimum $1.00 per order
588    /// let config = BacktestConfig::builder()
589    ///     .commission_fn(|size, price| (size * 0.005_f64).max(1.00))
590    ///     .build()
591    ///     .unwrap();
592    /// ```
593    pub fn commission_fn<F>(mut self, f: F) -> Self
594    where
595        F: Fn(f64, f64) -> f64 + Send + Sync + 'static,
596    {
597        self.config.commission_fn = Some(CommissionFn::new(f));
598        self
599    }
600
601    /// Build and validate the configuration
602    pub fn build(self) -> Result<BacktestConfig> {
603        self.config.validate()?;
604        Ok(self.config)
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn test_default_config() {
614        let config = BacktestConfig::default();
615        assert_eq!(config.initial_capital, 10_000.0);
616        assert!(config.validate().is_ok());
617    }
618
619    #[test]
620    fn test_builder() {
621        let config = BacktestConfig::builder()
622            .initial_capital(50_000.0)
623            .commission_pct(0.002)
624            .allow_short(true)
625            .stop_loss_pct(0.05)
626            .take_profit_pct(0.10)
627            .build()
628            .unwrap();
629
630        assert_eq!(config.initial_capital, 50_000.0);
631        assert_eq!(config.commission_pct, 0.002);
632        assert!(config.allow_short);
633        assert_eq!(config.stop_loss_pct, Some(0.05));
634        assert_eq!(config.take_profit_pct, Some(0.10));
635    }
636
637    #[test]
638    fn test_validation_failures() {
639        assert!(
640            BacktestConfig::builder()
641                .initial_capital(-100.0)
642                .build()
643                .is_err()
644        );
645
646        assert!(
647            BacktestConfig::builder()
648                .commission_pct(1.5)
649                .build()
650                .is_err()
651        );
652
653        assert!(
654            BacktestConfig::builder()
655                .stop_loss_pct(2.0)
656                .build()
657                .is_err()
658        );
659    }
660
661    #[test]
662    fn test_commission_calculation() {
663        let config = BacktestConfig::builder()
664            .commission(5.0)
665            .commission_pct(0.01)
666            .build()
667            .unwrap();
668
669        // For $1000 trade (10 units @ $100): $5 flat + 1% = $5 + $10 = $15
670        let commission = config.calculate_commission(10.0, 100.0);
671        assert!((commission - 15.0).abs() < 0.01);
672    }
673
674    #[test]
675    fn test_slippage() {
676        let config = BacktestConfig::builder()
677            .slippage_pct(0.01) // 1%
678            .build()
679            .unwrap();
680
681        // Long entry: price goes up
682        let entry_price = config.apply_entry_slippage(100.0, true);
683        assert!((entry_price - 101.0).abs() < 0.01);
684
685        // Long exit: price goes down
686        let exit_price = config.apply_exit_slippage(100.0, true);
687        assert!((exit_price - 99.0).abs() < 0.01);
688
689        // Short entry: price goes down (less favorable)
690        let short_entry = config.apply_entry_slippage(100.0, false);
691        assert!((short_entry - 99.0).abs() < 0.01);
692
693        // Short exit: price goes up
694        let short_exit = config.apply_exit_slippage(100.0, false);
695        assert!((short_exit - 101.0).abs() < 0.01);
696    }
697
698    #[test]
699    fn test_position_sizing() {
700        let config = BacktestConfig::builder()
701            .position_size_pct(0.5) // Use 50% of capital
702            .commission_pct(0.0) // No commission for simpler test
703            .build()
704            .unwrap();
705
706        // With $10,000 and price $100, use $5,000 -> 50 shares
707        let size = config.calculate_position_size(10_000.0, 100.0);
708        assert!((size - 50.0).abs() < 0.01);
709    }
710
711    #[test]
712    fn test_risk_free_rate() {
713        let config = BacktestConfig::builder()
714            .risk_free_rate(0.05)
715            .build()
716            .unwrap();
717        assert!((config.risk_free_rate - 0.05).abs() < f64::EPSILON);
718
719        // Out-of-range should fail
720        assert!(
721            BacktestConfig::builder()
722                .risk_free_rate(1.5)
723                .build()
724                .is_err()
725        );
726    }
727
728    #[test]
729    fn test_trailing_stop() {
730        let config = BacktestConfig::builder()
731            .trailing_stop_pct(0.05)
732            .build()
733            .unwrap();
734        assert_eq!(config.trailing_stop_pct, Some(0.05));
735
736        // Out-of-range should fail
737        assert!(
738            BacktestConfig::builder()
739                .trailing_stop_pct(1.5)
740                .build()
741                .is_err()
742        );
743    }
744
745    #[test]
746    fn test_position_sizing_with_commission() {
747        let config = BacktestConfig::builder()
748            .position_size_pct(0.5) // Use 50% of capital
749            .commission_pct(0.001) // 0.1% commission
750            .build()
751            .unwrap();
752
753        // With $10,000 and price $100, use $5,000
754        // But adjusted for entry + exit commission: 5000 / 1.002 = 4990.019960...
755        // So shares = 4990.019960 / 100 = 49.90...
756        let size = config.calculate_position_size(10_000.0, 100.0);
757        let expected = 5000.0 / 1.002 / 100.0;
758        assert!((size - expected).abs() < 0.01);
759    }
760
761    #[test]
762    fn test_position_size_zero_rejected() {
763        assert!(
764            BacktestConfig::builder()
765                .position_size_pct(0.0)
766                .build()
767                .is_err()
768        );
769    }
770
771    #[test]
772    fn test_bars_per_year_validation() {
773        // Default is 252
774        let config = BacktestConfig::default();
775        assert!((config.bars_per_year - 252.0).abs() < f64::EPSILON);
776        assert!(config.validate().is_ok());
777
778        // Valid custom value
779        let config = BacktestConfig::builder()
780            .bars_per_year(52.0)
781            .build()
782            .unwrap();
783        assert!((config.bars_per_year - 52.0).abs() < f64::EPSILON);
784
785        // Zero must be rejected
786        assert!(
787            BacktestConfig::builder()
788                .bars_per_year(0.0)
789                .build()
790                .is_err()
791        );
792
793        // Negative must be rejected
794        assert!(
795            BacktestConfig::builder()
796                .bars_per_year(-1.0)
797                .build()
798                .is_err()
799        );
800    }
801
802    #[test]
803    fn test_position_sizing_accounts_for_exit_commission() {
804        // Verify the denominator is 1 + 2*comm (entry + exit)
805        let comm = 0.01; // 1%
806        let config = BacktestConfig::builder()
807            .commission_pct(comm)
808            .position_size_pct(1.0)
809            .build()
810            .unwrap();
811        let size = config.calculate_position_size(10_000.0, 100.0);
812        let expected = 10_000.0 / (1.0 + 2.0 * comm) / 100.0;
813        assert!((size - expected).abs() < 0.001);
814    }
815
816    #[test]
817    fn test_position_sizing_flat_commission_reduces_size() {
818        // With $10 flat commission per side, $20 total must be reserved
819        let config = BacktestConfig::builder()
820            .commission(10.0)
821            .commission_pct(0.0)
822            .position_size_pct(1.0)
823            .build()
824            .unwrap();
825        let size_with_flat = config.calculate_position_size(10_000.0, 100.0);
826
827        let config_no_flat = BacktestConfig::builder()
828            .commission_pct(0.0)
829            .position_size_pct(1.0)
830            .build()
831            .unwrap();
832        let size_no_flat = config_no_flat.calculate_position_size(10_000.0, 100.0);
833
834        // Flat commission should reduce position size
835        assert!(size_with_flat < size_no_flat);
836        // Expected: (10_000 - 20) / 100 = 99.8
837        let expected = (10_000.0 - 20.0) / 100.0;
838        assert!((size_with_flat - expected).abs() < 0.001);
839    }
840
841    #[test]
842    fn test_position_sizing_flat_commission_exceeds_capital_returns_zero() {
843        // If flat commission alone exceeds available capital, quantity should be 0
844        let config = BacktestConfig::builder()
845            .commission(6_000.0) // $6k/side → $12k total > $10k capital
846            .position_size_pct(1.0)
847            .build()
848            .unwrap();
849        let size = config.calculate_position_size(10_000.0, 100.0);
850        assert_eq!(size, 0.0);
851    }
852
853    // ── Phase 5: Enhanced Broker Simulation ──────────────────────────────────
854
855    #[test]
856    fn test_spread_entry_long() {
857        let config = BacktestConfig::builder()
858            .spread_pct(0.0004) // 4 bps
859            .build()
860            .unwrap();
861        // Long entry pays the ask: price rises by half-spread (2 bps)
862        let price = config.apply_entry_spread(100.0, true);
863        assert!((price - 100.02).abs() < 1e-10);
864    }
865
866    #[test]
867    fn test_spread_exit_long() {
868        let config = BacktestConfig::builder()
869            .spread_pct(0.0004)
870            .build()
871            .unwrap();
872        // Long exit receives the bid: price falls by half-spread
873        let price = config.apply_exit_spread(100.0, true);
874        assert!((price - 99.98).abs() < 1e-10);
875    }
876
877    #[test]
878    fn test_spread_entry_short() {
879        let config = BacktestConfig::builder()
880            .spread_pct(0.0004)
881            .build()
882            .unwrap();
883        // Short entry receives the bid: price falls by half-spread
884        let price = config.apply_entry_spread(100.0, false);
885        assert!((price - 99.98).abs() < 1e-10);
886    }
887
888    #[test]
889    fn test_spread_exit_short() {
890        let config = BacktestConfig::builder()
891            .spread_pct(0.0004)
892            .build()
893            .unwrap();
894        // Short exit pays the ask: price rises by half-spread
895        let price = config.apply_exit_spread(100.0, false);
896        assert!((price - 100.02).abs() < 1e-10);
897    }
898
899    #[test]
900    fn test_spread_zero_is_noop() {
901        let config = BacktestConfig::default(); // spread_pct = 0.0
902        assert!((config.apply_entry_spread(123.45, true) - 123.45).abs() < 1e-10);
903        assert!((config.apply_exit_spread(123.45, false) - 123.45).abs() < 1e-10);
904    }
905
906    #[test]
907    fn test_spread_validation() {
908        assert!(BacktestConfig::builder().spread_pct(1.5).build().is_err());
909        assert!(BacktestConfig::builder().spread_pct(-0.01).build().is_err());
910        assert!(BacktestConfig::builder().spread_pct(0.0).build().is_ok());
911        assert!(BacktestConfig::builder().spread_pct(1.0).build().is_ok());
912    }
913
914    #[test]
915    fn test_transaction_tax_on_buy() {
916        let config = BacktestConfig::builder()
917            .transaction_tax_pct(0.005) // UK stamp duty 0.5%
918            .build()
919            .unwrap();
920        let tax = config.calculate_transaction_tax(10_000.0, true);
921        assert!((tax - 50.0).abs() < 1e-10);
922    }
923
924    #[test]
925    fn test_transaction_tax_not_on_sell() {
926        let config = BacktestConfig::builder()
927            .transaction_tax_pct(0.005)
928            .build()
929            .unwrap();
930        let tax = config.calculate_transaction_tax(10_000.0, false);
931        assert_eq!(tax, 0.0);
932    }
933
934    #[test]
935    fn test_transaction_tax_zero_default() {
936        let config = BacktestConfig::default();
937        assert_eq!(config.calculate_transaction_tax(100_000.0, true), 0.0);
938    }
939
940    #[test]
941    fn test_transaction_tax_validation() {
942        assert!(
943            BacktestConfig::builder()
944                .transaction_tax_pct(1.5)
945                .build()
946                .is_err()
947        );
948        assert!(
949            BacktestConfig::builder()
950                .transaction_tax_pct(-0.001)
951                .build()
952                .is_err()
953        );
954    }
955
956    #[test]
957    fn test_commission_fn_replaces_flat_and_pct() {
958        // Custom fn: $0.005/share minimum $1.00
959        let config = BacktestConfig::builder()
960            .commission_fn(|size, _price| (size * 0.005_f64).max(1.00))
961            .build()
962            .unwrap();
963        // 100 shares: 100 * 0.005 = $0.50 → minimum kicks in → $1.00
964        let comm = config.calculate_commission(100.0, 50.0);
965        assert!((comm - 1.00).abs() < 1e-10);
966        // 500 shares: 500 * 0.005 = $2.50 → above minimum
967        let comm = config.calculate_commission(500.0, 50.0);
968        assert!((comm - 2.50).abs() < 1e-10);
969    }
970
971    #[test]
972    fn test_commission_fn_ignores_flat_and_pct_fields() {
973        // Even with flat=5 and pct=0.01 set, commission_fn should override
974        let config = BacktestConfig::builder()
975            .commission(5.0)
976            .commission_pct(0.01)
977            .commission_fn(|size, price| size * price * 0.0005)
978            .build()
979            .unwrap();
980        // 10 shares @ $100: fn gives 10*100*0.0005 = $0.50
981        let comm = config.calculate_commission(10.0, 100.0);
982        assert!((comm - 0.50).abs() < 1e-10);
983    }
984
985    #[test]
986    fn test_commission_fn_fallback_when_none() {
987        // Without commission_fn, standard flat+pct applies
988        let config = BacktestConfig::builder()
989            .commission(1.0)
990            .commission_pct(0.002)
991            .build()
992            .unwrap();
993        // 10 shares @ $100 = $1000 trade: $1 + $2 = $3
994        let comm = config.calculate_commission(10.0, 100.0);
995        assert!((comm - 3.0).abs() < 1e-10);
996    }
997
998    #[test]
999    fn test_position_sizing_includes_spread_and_tax() {
1000        let spread = 0.0004; // 4 bps round-trip
1001        let tax = 0.005; // 0.5% stamp duty
1002        let config = BacktestConfig::builder()
1003            .commission_pct(0.0)
1004            .spread_pct(spread)
1005            .transaction_tax_pct(tax)
1006            .position_size_pct(1.0)
1007            .build()
1008            .unwrap();
1009
1010        let size = config.calculate_position_size(10_000.0, 100.0);
1011        let expected = 10_000.0 / (1.0 + spread + tax) / 100.0;
1012        assert!((size - expected).abs() < 0.01);
1013    }
1014
1015    #[test]
1016    fn test_zero_cost_clears_new_fields() {
1017        let config = BacktestConfig::zero_cost();
1018        assert_eq!(config.spread_pct, 0.0);
1019        assert_eq!(config.transaction_tax_pct, 0.0);
1020        assert!(config.commission_fn.is_none());
1021    }
1022}