1mod 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#[non_exhaustive]
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct BacktestConfig {
37 pub initial_capital: f64,
39
40 pub commission: f64,
42
43 pub commission_pct: f64,
45
46 pub slippage_pct: f64,
48
49 pub position_size_pct: f64,
51
52 pub max_positions: Option<usize>,
54
55 pub allow_short: bool,
57
58 pub min_signal_strength: f64,
60
61 pub stop_loss_pct: Option<f64>,
63
64 pub take_profit_pct: Option<f64>,
66
67 pub close_at_end: bool,
69
70 pub risk_free_rate: f64,
75
76 pub trailing_stop_pct: Option<f64>,
87
88 pub reinvest_dividends: bool,
95
96 pub bars_per_year: f64,
103
104 pub spread_pct: f64,
116
117 pub transaction_tax_pct: f64,
127
128 #[serde(skip)]
141 pub commission_fn: Option<CommissionFn>,
142
143 #[serde(default = "default_max_leverage")]
155 pub max_leverage: f64,
156
157 #[serde(default = "default_maintenance_margin_pct")]
166 pub maintenance_margin_pct: f64,
167
168 #[serde(default)]
175 pub short_borrow_rate: f64,
176
177 #[serde(default)]
185 pub margin_interest_rate: f64,
186
187 #[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, slippage_pct: 0.001, position_size_pct: 1.0, max_positions: Some(1), 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 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 pub fn builder() -> BacktestConfigBuilder {
255 BacktestConfigBuilder::default()
256 }
257
258 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 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 let config = BacktestConfig::default();
617 assert!((config.bars_per_year - 252.0).abs() < f64::EPSILON);
618 assert!(config.validate().is_ok());
619
620 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 assert!(
629 BacktestConfig::builder()
630 .bars_per_year(0.0)
631 .build()
632 .is_err()
633 );
634
635 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}