1use std::collections::HashMap;
55
56use rayon::prelude::*;
57
58use crate::models::chart::Candle;
59
60use super::super::config::BacktestConfig;
61use super::super::engine::{BacktestEngine, validate_series_order};
62use super::super::error::{BacktestError, Result};
63use super::super::monte_carlo::Xorshift64;
64use super::super::strategy::Strategy;
65use super::{
66 OptimizationReport, OptimizationResult, OptimizeMetric, ParamRange, ParamValue,
67 sort_results_best_first,
68};
69
70const DEFAULT_MAX_EVALUATIONS: usize = 100;
73const DEFAULT_INITIAL_POINTS: usize = 10;
74const DEFAULT_UCB_BETA: f64 = 2.0;
76const DEFAULT_SEED: u64 = 42;
77const N_CANDIDATES: usize = 1_000;
80const MIN_OBSERVATIONS_FOR_PARALLEL: usize = 120;
87
88#[derive(Debug, Clone, Default)]
106pub struct BayesianSearch {
107 params: Vec<(String, ParamRange)>,
108 metric: Option<OptimizeMetric>,
109 max_evaluations: Option<usize>,
110 initial_points: Option<usize>,
111 ucb_beta: Option<f64>,
112 seed: Option<u64>,
113}
114
115impl BayesianSearch {
116 pub fn new() -> Self {
118 Self::default()
119 }
120
121 pub fn param(mut self, name: impl Into<String>, range: ParamRange) -> Self {
126 self.params.push((name.into(), range));
127 self
128 }
129
130 pub fn optimize_for(mut self, metric: OptimizeMetric) -> Self {
132 self.metric = Some(metric);
133 self
134 }
135
136 pub fn max_evaluations(mut self, n: usize) -> Self {
138 self.max_evaluations = Some(n);
139 self
140 }
141
142 pub fn initial_points(mut self, n: usize) -> Self {
147 self.initial_points = Some(n);
148 self
149 }
150
151 pub fn ucb_beta(mut self, beta: f64) -> Self {
156 self.ucb_beta = Some(beta);
157 self
158 }
159
160 pub fn seed(mut self, seed: u64) -> Self {
162 self.seed = Some(seed);
163 self
164 }
165
166 pub fn run<S, F>(
177 &self,
178 symbol: &str,
179 candles: &[Candle],
180 config: &BacktestConfig,
181 factory: F,
182 ) -> Result<OptimizationReport>
183 where
184 S: Strategy,
185 F: Fn(&HashMap<String, ParamValue>) -> S,
186 {
187 let metric = self.metric.unwrap_or(OptimizeMetric::SharpeRatio);
188 let (mut all_results, convergence_curve, n_evaluations) =
189 self.search(symbol, candles, config, metric, &factory)?;
190
191 sort_results_best_first(&mut all_results, metric);
192
193 if metric.score(&all_results[0].result).is_nan() {
194 return Err(BacktestError::invalid_param(
195 "metric",
196 "all parameter sets produced NaN for the target metric",
197 ));
198 }
199
200 let strategy_name = all_results[0].result.strategy_name.clone();
201 let best = all_results[0].clone();
202 let total_combinations = all_results.len();
203
204 Ok(OptimizationReport {
205 strategy_name,
206 total_combinations,
207 results: all_results,
208 best,
209 skipped_errors: 0,
210 convergence_curve,
211 n_evaluations,
212 })
213 }
214
215 pub(super) fn search<S, F>(
218 &self,
219 symbol: &str,
220 candles: &[Candle],
221 config: &BacktestConfig,
222 metric: OptimizeMetric,
223 factory: &F,
224 ) -> Result<(Vec<OptimizationResult>, Vec<f64>, usize)>
225 where
226 S: Strategy,
227 F: Fn(&HashMap<String, ParamValue>) -> S,
228 {
229 if self.params.is_empty() {
230 return Err(BacktestError::invalid_param(
231 "params",
232 "BayesianSearch requires at least one parameter range",
233 ));
234 }
235
236 validate_series_order(candles, &[])?;
238
239 let d = self.params.len();
240 let max_eval = self.max_evaluations.unwrap_or(DEFAULT_MAX_EVALUATIONS);
241 let n_init = self
242 .initial_points
243 .unwrap_or(DEFAULT_INITIAL_POINTS)
244 .max(2)
245 .min(max_eval);
246 let beta = self.ucb_beta.unwrap_or(DEFAULT_UCB_BETA);
247 let seed = self.seed.unwrap_or(DEFAULT_SEED);
248
249 let mut rng = Xorshift64::new(seed);
250 let mut observations: Vec<(Vec<f64>, f64)> = Vec::with_capacity(max_eval);
252 let mut all_results: Vec<OptimizationResult> = Vec::with_capacity(max_eval);
253 let mut convergence_curve: Vec<f64> = Vec::with_capacity(max_eval);
255 let mut n_evaluations: usize = 0;
256 let mut best_score: Option<f64> = None;
257
258 for norm_point in latin_hypercube_sample(n_init, d, &mut rng) {
261 n_evaluations += 1;
262 if let Some(opt_result) = run_one(
263 symbol,
264 candles,
265 config,
266 &metric,
267 factory,
268 &norm_point,
269 &self.params,
270 ) {
271 let score = metric.score(&opt_result.result);
272 if score.is_finite() {
273 update_best(&mut best_score, score);
274 observations.push((norm_point, score));
275 }
276 if let Some(b) = best_score {
277 convergence_curve.push(b);
278 }
279 all_results.push(opt_result);
280 }
281 }
282
283 for _ in 0..max_eval.saturating_sub(n_init) {
286 let norm_point = if observations.len() < 2 {
287 (0..d).map(|_| rng.next_f64_positive()).collect()
289 } else {
290 let surrogate = Surrogate::fit(&observations, beta);
291 let mut candidates = vec![0.0_f64; N_CANDIDATES * d];
295 for xi in candidates.iter_mut() {
296 *xi = rng.next_f64_positive();
297 }
298 match argmax_acquisition(&surrogate, &candidates, d) {
299 Some(i) => candidates[i * d..(i + 1) * d].to_vec(),
300 None => vec![0.0_f64; d],
301 }
302 };
303
304 n_evaluations += 1;
305 if let Some(opt_result) = run_one(
306 symbol,
307 candles,
308 config,
309 &metric,
310 factory,
311 &norm_point,
312 &self.params,
313 ) {
314 let score = metric.score(&opt_result.result);
315 if score.is_finite() {
316 update_best(&mut best_score, score);
317 observations.push((norm_point, score));
318 }
319 if let Some(b) = best_score {
320 convergence_curve.push(b);
321 }
322 all_results.push(opt_result);
323 }
324 }
325
326 if all_results.is_empty() {
329 return Err(BacktestError::invalid_param(
330 "candles",
331 "no parameter set had enough data to run a backtest",
332 ));
333 }
334
335 Ok((all_results, convergence_curve, n_evaluations))
336 }
337}
338
339#[inline]
342fn update_best(best: &mut Option<f64>, score: f64) {
343 match best {
344 None => *best = Some(score),
345 Some(b) if score > *b => *b = score,
346 _ => {}
347 }
348}
349
350fn argmax_acquisition(surrogate: &Surrogate<'_>, candidates: &[f64], d: usize) -> Option<usize> {
364 const NONE: (f64, usize) = (f64::NEG_INFINITY, usize::MAX);
365
366 let score = |(i, c): (usize, &[f64])| {
367 let ucb = surrogate.acquisition(c);
368 if ucb > f64::NEG_INFINITY {
369 (ucb, i)
370 } else {
371 NONE
372 }
373 };
374 let combine = |a: (f64, usize), b: (f64, usize)| {
375 if b.0 > a.0 || (b.0 == a.0 && b.1 < a.1) {
376 b
377 } else {
378 a
379 }
380 };
381
382 let (_, best) = if surrogate.observations.len() >= MIN_OBSERVATIONS_FOR_PARALLEL {
383 candidates
384 .par_chunks(d)
385 .enumerate()
386 .map(score)
387 .reduce(|| NONE, combine)
388 } else {
389 candidates
390 .chunks(d)
391 .enumerate()
392 .map(score)
393 .fold(NONE, combine)
394 };
395
396 (best != usize::MAX).then_some(best)
397}
398
399fn run_one<S, F>(
402 symbol: &str,
403 candles: &[Candle],
404 config: &BacktestConfig,
405 _metric: &OptimizeMetric,
406 factory: &F,
407 norm_point: &[f64],
408 param_specs: &[(String, ParamRange)],
409) -> Option<OptimizationResult>
410where
411 S: Strategy,
412 F: Fn(&HashMap<String, ParamValue>) -> S,
413{
414 let params = denormalize(norm_point, param_specs);
415 let strategy = factory(¶ms);
416 match BacktestEngine::new(config.clone()).simulate(symbol, candles, strategy, &[]) {
417 Ok(result) => Some(OptimizationResult { params, result }),
418 Err(BacktestError::InsufficientData { .. }) => None,
419 Err(e) => {
420 tracing::warn!(
421 params = ?params,
422 error = %e,
423 "BayesianSearch: skipping candidate due to unexpected error"
424 );
425 None
426 }
427 }
428}
429
430fn denormalize(
432 norm_point: &[f64],
433 param_specs: &[(String, ParamRange)],
434) -> HashMap<String, ParamValue> {
435 norm_point
436 .iter()
437 .zip(param_specs.iter())
438 .map(|(&t, (name, range))| (name.clone(), range.sample_at(t)))
439 .collect()
440}
441
442fn latin_hypercube_sample(n: usize, d: usize, rng: &mut Xorshift64) -> Vec<Vec<f64>> {
451 if n == 0 {
452 return vec![];
453 }
454
455 let mut samples = vec![vec![0.0_f64; d]; n];
456
457 #[allow(clippy::needless_range_loop)]
458 for dim in 0..d {
459 let mut stratum_values: Vec<f64> = (0..n)
461 .map(|i| {
462 let lo = i as f64 / n as f64;
463 let hi = (i + 1) as f64 / n as f64;
464 lo + rng.next_f64_positive() * (hi - lo)
465 })
466 .collect();
467
468 for i in (1..n).rev() {
470 let j = rng.next_usize(i + 1);
471 stratum_values.swap(i, j);
472 }
473
474 for i in 0..n {
475 samples[i][dim] = stratum_values[i];
476 }
477 }
478
479 samples
480}
481
482struct Surrogate<'a> {
495 observations: &'a [(Vec<f64>, f64)],
496 beta: f64,
497 bandwidth_sq: f64,
499}
500
501impl<'a> Surrogate<'a> {
502 fn fit(observations: &'a [(Vec<f64>, f64)], beta: f64) -> Self {
507 let n = observations.len() as f64;
508 let d = observations.first().map_or(1, |(x, _)| x.len()) as f64;
509 let h = n.powf(-1.0 / (d + 4.0)).max(0.1);
510 Self {
511 observations,
512 beta,
513 bandwidth_sq: 2.0 * h * h,
514 }
515 }
516
517 fn acquisition(&self, x: &[f64]) -> f64 {
519 let (mean, std) = self.predict(x);
520 mean + self.beta * std
521 }
522
523 fn predict(&self, x: &[f64]) -> (f64, f64) {
532 let mut w_sum = 0.0_f64;
533 let mut mean = 0.0_f64;
534 let mut s = 0.0_f64; for (xi, yi) in self.observations {
537 let w = self.rbf(x, xi);
538 if w < f64::EPSILON {
539 continue;
540 }
541 let w_new = w_sum + w;
542 let delta = yi - mean;
543 mean += (w / w_new) * delta;
544 s += w * delta * (yi - mean);
545 w_sum = w_new;
546 }
547
548 if w_sum < f64::EPSILON {
549 return (0.0, 1.0);
550 }
551
552 let std = (s / w_sum).max(0.0).sqrt();
553 (mean, std)
554 }
555
556 #[inline]
558 fn rbf(&self, x: &[f64], xi: &[f64]) -> f64 {
559 let dist_sq: f64 = x.iter().zip(xi.iter()).map(|(a, b)| (a - b).powi(2)).sum();
560 (-dist_sq / self.bandwidth_sq).exp()
561 }
562}
563
564#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::backtesting::{BacktestConfig, SmaCrossover};
570 use crate::models::chart::Candle;
571
572 fn make_candles(prices: &[f64]) -> Vec<Candle> {
573 prices
574 .iter()
575 .enumerate()
576 .map(|(i, &p)| Candle {
577 timestamp: i as i64,
578 open: p,
579 high: p * 1.01,
580 low: p * 0.99,
581 close: p,
582 volume: 1_000,
583 adj_close: Some(p),
584 provider_id: None,
585 })
586 .collect()
587 }
588
589 fn trending_prices(n: usize) -> Vec<f64> {
590 (0..n).map(|i| 100.0 + i as f64 * 0.5).collect()
591 }
592
593 #[test]
596 fn test_lhs_shape() {
597 let mut rng = Xorshift64::new(1);
598 let samples = latin_hypercube_sample(8, 3, &mut rng);
599 assert_eq!(samples.len(), 8);
600 assert!(samples.iter().all(|p| p.len() == 3));
601 }
602
603 #[test]
604 fn test_lhs_stratification() {
605 let n = 10;
606 let mut rng = Xorshift64::new(99);
607 let samples = latin_hypercube_sample(n, 2, &mut rng);
608
609 for dim in 0..2 {
610 let mut counts = vec![0usize; n];
611 for point in &samples {
612 let stratum = (point[dim] * n as f64).floor() as usize;
613 counts[stratum.min(n - 1)] += 1;
614 }
615 assert!(
616 counts.iter().all(|&c| c == 1),
617 "dim {dim}: expected one sample per stratum, got {counts:?}"
618 );
619 }
620 }
621
622 #[test]
623 fn test_lhs_values_in_unit_cube() {
624 let mut rng = Xorshift64::new(7);
625 for point in latin_hypercube_sample(20, 4, &mut rng) {
626 for v in point {
627 assert!(v > 0.0 && v <= 1.0, "value {v} outside (0, 1]");
628 }
629 }
630 }
631
632 #[test]
635 fn test_surrogate_predicts_near_observation() {
636 let obs = vec![(vec![0.5_f64], 1.0_f64)];
637 let s = Surrogate::fit(&obs, 2.0);
638 let (mean, _) = s.predict(&[0.5]);
639 assert!((mean - 1.0).abs() < 1e-6);
640 }
641
642 #[test]
645 fn test_surrogate_max_uncertainty_fallback_for_very_distant_point() {
646 let obs = vec![(vec![0.0_f64], 0.5_f64), (vec![0.1], 0.6)];
649 let s = Surrogate::fit(&obs, 2.0);
650 let (mean, std) = s.predict(&[100.0]);
651 assert!(
652 (mean - 0.0).abs() < 1e-6,
653 "expected fallback mean=0.0, got {mean}"
654 );
655 assert!(
656 (std - 1.0).abs() < 1e-6,
657 "expected fallback std=1.0, got {std}"
658 );
659 }
660
661 #[test]
664 fn test_surrogate_std_nonzero_with_disagreeing_observations() {
665 let obs = vec![(vec![0.0_f64], 0.1_f64), (vec![0.05], 0.9)];
666 let s = Surrogate::fit(&obs, 2.0);
667 let (_, std) = s.predict(&[0.025]); assert!(
669 std > 0.1,
670 "expected non-trivial std for disagreeing observations, got {std}"
671 );
672 }
673
674 #[test]
675 fn test_acquisition_favours_uncertain_regions_with_high_beta() {
676 let obs = vec![(vec![0.0_f64], 0.5_f64), (vec![0.1], 0.6)];
677 let s = Surrogate::fit(&obs, 10.0); assert!(
679 s.acquisition(&[1.0]) > s.acquisition(&[0.05]),
680 "far point should have higher UCB with β=10"
681 );
682 }
683
684 #[test]
687 fn test_bayesian_search_runs() {
688 let candles = make_candles(&trending_prices(200));
689 let config = BacktestConfig::builder()
690 .commission_pct(0.0)
691 .slippage_pct(0.0)
692 .build()
693 .unwrap();
694
695 let report = BayesianSearch::new()
696 .param("fast", ParamRange::int_bounds(3, 10))
697 .param("slow", ParamRange::int_bounds(10, 30))
698 .optimize_for(OptimizeMetric::TotalReturn)
699 .max_evaluations(20)
700 .seed(1)
701 .run("TEST", &candles, &config, |params| {
702 SmaCrossover::new(
703 params["fast"].as_int() as usize,
704 params["slow"].as_int() as usize,
705 )
706 })
707 .unwrap();
708
709 assert!(!report.results.is_empty());
710 assert_eq!(report.strategy_name, "SMA Crossover");
711 assert!(report.n_evaluations > 0);
712 assert!(!report.convergence_curve.is_empty());
713 }
714
715 #[test]
716 fn test_convergence_curve_is_nondecreasing() {
717 let candles = make_candles(&trending_prices(200));
718 let config = BacktestConfig::builder()
719 .commission_pct(0.0)
720 .slippage_pct(0.0)
721 .build()
722 .unwrap();
723
724 let report = BayesianSearch::new()
725 .param("fast", ParamRange::int_bounds(3, 15))
726 .param("slow", ParamRange::int_bounds(15, 40))
727 .max_evaluations(30)
728 .seed(2)
729 .run("TEST", &candles, &config, |params| {
730 SmaCrossover::new(
731 params["fast"].as_int() as usize,
732 params["slow"].as_int() as usize,
733 )
734 })
735 .unwrap();
736
737 for window in report.convergence_curve.windows(2) {
738 assert!(
739 window[1] >= window[0] - 1e-12,
740 "convergence curve not non-decreasing: {window:?}"
741 );
742 }
743 }
744
745 #[test]
746 fn test_results_sorted_best_first() {
747 let candles = make_candles(&trending_prices(150));
748 let config = BacktestConfig::builder()
749 .commission_pct(0.0)
750 .slippage_pct(0.0)
751 .build()
752 .unwrap();
753
754 let report = BayesianSearch::new()
755 .param("fast", ParamRange::int_bounds(3, 10))
756 .param("slow", ParamRange::int_bounds(10, 25))
757 .optimize_for(OptimizeMetric::TotalReturn)
758 .max_evaluations(15)
759 .seed(3)
760 .run("TEST", &candles, &config, |params| {
761 SmaCrossover::new(
762 params["fast"].as_int() as usize,
763 params["slow"].as_int() as usize,
764 )
765 })
766 .unwrap();
767
768 if report.results.len() > 1 {
769 let first = OptimizeMetric::TotalReturn.score(&report.results[0].result);
770 let second = OptimizeMetric::TotalReturn.score(&report.results[1].result);
771 assert!(first >= second - 1e-12);
772 }
773 }
774
775 #[test]
776 fn test_best_matches_results_first() {
777 let candles = make_candles(&trending_prices(150));
778 let config = BacktestConfig::builder()
779 .commission_pct(0.0)
780 .slippage_pct(0.0)
781 .build()
782 .unwrap();
783
784 let report = BayesianSearch::new()
785 .param("fast", ParamRange::int_bounds(3, 10))
786 .param("slow", ParamRange::int_bounds(10, 25))
787 .max_evaluations(15)
788 .seed(4)
789 .run("TEST", &candles, &config, |params| {
790 SmaCrossover::new(
791 params["fast"].as_int() as usize,
792 params["slow"].as_int() as usize,
793 )
794 })
795 .unwrap();
796
797 let best = OptimizeMetric::SharpeRatio.score(&report.best.result);
798 let first = OptimizeMetric::SharpeRatio.score(&report.results[0].result);
799 assert!((best - first).abs() < 1e-12);
800 }
801
802 #[test]
803 fn test_no_params_returns_error() {
804 let candles = make_candles(&trending_prices(100));
805 let config = BacktestConfig::default();
806 assert!(
807 BayesianSearch::new()
808 .run("TEST", &candles, &config, |_| SmaCrossover::new(5, 20))
809 .is_err()
810 );
811 }
812
813 fn argmax_acquisition_sequential(
816 surrogate: &Surrogate<'_>,
817 candidates: &[f64],
818 d: usize,
819 ) -> Option<usize> {
820 let mut best_ucb = f64::NEG_INFINITY;
821 let mut best = None;
822 for (i, c) in candidates.chunks(d).enumerate() {
823 let ucb = surrogate.acquisition(c);
824 if ucb > best_ucb {
825 best_ucb = ucb;
826 best = Some(i);
827 }
828 }
829 best
830 }
831
832 #[test]
833 fn test_argmax_acquisition_matches_sequential_scan() {
834 let mut rng = Xorshift64::new(12345);
835 let obs: Vec<(Vec<f64>, f64)> = (0..12)
836 .map(|_| {
837 (
838 vec![rng.next_f64_positive(), rng.next_f64_positive()],
839 rng.next_f64_positive(),
840 )
841 })
842 .collect();
843
844 for seed in [1_u64, 2, 3, 77, 9_999] {
845 let mut rng = Xorshift64::new(seed);
846 let d = 2;
847 let mut candidates = vec![0.0_f64; N_CANDIDATES * d];
848 for xi in candidates.iter_mut() {
849 *xi = rng.next_f64_positive();
850 }
851 let s = Surrogate::fit(&obs, DEFAULT_UCB_BETA);
852 assert_eq!(
853 argmax_acquisition(&s, &candidates, d),
854 argmax_acquisition_sequential(&s, &candidates, d),
855 "seed {seed}: parallel argmax diverged from sequential scan"
856 );
857 }
858 }
859
860 #[test]
863 fn test_argmax_acquisition_agrees_across_parallel_threshold() {
864 let d = 3;
865 let mut rng = Xorshift64::new(4242);
866 let mut candidates = vec![0.0_f64; N_CANDIDATES * d];
867 for xi in candidates.iter_mut() {
868 *xi = rng.next_f64_positive();
869 }
870
871 let all_obs: Vec<(Vec<f64>, f64)> = (0..MIN_OBSERVATIONS_FOR_PARALLEL + 5)
872 .map(|_| {
873 (
874 (0..d).map(|_| rng.next_f64_positive()).collect(),
875 rng.next_f64_positive(),
876 )
877 })
878 .collect();
879
880 for n in [
881 MIN_OBSERVATIONS_FOR_PARALLEL - 1,
882 MIN_OBSERVATIONS_FOR_PARALLEL,
883 MIN_OBSERVATIONS_FOR_PARALLEL + 5,
884 ] {
885 let obs = &all_obs[..n];
886 let s = Surrogate::fit(obs, DEFAULT_UCB_BETA);
887 let got = argmax_acquisition(&s, &candidates, d);
888 assert!(got.is_some(), "n={n}: expected a winning candidate");
889 assert_eq!(
890 got,
891 argmax_acquisition_sequential(&s, &candidates, d),
892 "n={n}: diverged from sequential reference (parallel branch taken: {})",
893 n >= MIN_OBSERVATIONS_FOR_PARALLEL
894 );
895 }
896 }
897
898 #[test]
901 fn test_argmax_acquisition_breaks_ties_by_lowest_index() {
902 let obs = vec![(vec![0.3_f64], 0.4_f64), (vec![0.7], 0.9)];
903 let s = Surrogate::fit(&obs, DEFAULT_UCB_BETA);
904
905 let candidates = vec![0.7_f64, 0.7, 0.7, 0.3];
907 assert_eq!(argmax_acquisition(&s, &candidates, 1), Some(0));
908 assert_eq!(
909 argmax_acquisition_sequential(&s, &candidates, 1),
910 Some(0),
911 "reference scan disagrees, test fixture is wrong"
912 );
913 }
914
915 #[test]
919 fn test_argmax_acquisition_all_nan_selects_nothing() {
920 let obs = vec![(vec![0.3_f64], 0.4_f64), (vec![0.7], 0.9)];
921 let s = Surrogate::fit(&obs, f64::NAN);
922 let candidates = vec![0.1_f64, 0.4, 0.6, 0.9];
923
924 assert!(s.acquisition(&[0.1]).is_nan(), "fixture must produce NaN");
925 assert_eq!(argmax_acquisition(&s, &candidates, 1), None);
926 assert_eq!(argmax_acquisition_sequential(&s, &candidates, 1), None);
927 }
928
929 #[test]
933 fn seeded_bayesian_selects_identical_candidates() {
934 let candles = make_candles(&trending_prices(200));
935 let config = BacktestConfig::builder()
936 .commission_pct(0.0)
937 .slippage_pct(0.0)
938 .build()
939 .unwrap();
940
941 let search = BayesianSearch::new()
942 .param("fast", ParamRange::int_bounds(3, 12))
943 .param("slow", ParamRange::int_bounds(12, 30))
944 .param("threshold", ParamRange::float_bounds(0.1, 0.9))
945 .max_evaluations(25)
946 .seed(77);
947
948 let factory = |p: &HashMap<String, ParamValue>| {
949 SmaCrossover::new(p["fast"].as_int() as usize, p["slow"].as_int() as usize)
950 };
951
952 let r1 = search
953 .clone()
954 .run("TEST", &candles, &config, factory)
955 .unwrap();
956 let r2 = search.run("TEST", &candles, &config, factory).unwrap();
957
958 assert_eq!(r1.results.len(), r2.results.len());
959 assert!(
960 r1.results.len() > 10,
961 "too few evaluations to be meaningful"
962 );
963
964 for (i, (a, b)) in r1.results.iter().zip(r2.results.iter()).enumerate() {
965 assert_eq!(a.params, b.params, "params diverged at result {i}");
966 assert_eq!(
967 a.result.metrics.total_return_pct, b.result.metrics.total_return_pct,
968 "metrics diverged at result {i}"
969 );
970 }
971 assert_eq!(r1.convergence_curve, r2.convergence_curve);
972 assert_eq!(r1.n_evaluations, r2.n_evaluations);
973 }
974
975 #[test]
976 fn test_seeded_runs_are_reproducible() {
977 let candles = make_candles(&trending_prices(200));
978 let config = BacktestConfig::builder()
979 .commission_pct(0.0)
980 .slippage_pct(0.0)
981 .build()
982 .unwrap();
983
984 let search = BayesianSearch::new()
985 .param("fast", ParamRange::int_bounds(3, 12))
986 .param("slow", ParamRange::int_bounds(12, 30))
987 .max_evaluations(15)
988 .seed(77);
989
990 let factory = |p: &HashMap<String, ParamValue>| {
991 SmaCrossover::new(p["fast"].as_int() as usize, p["slow"].as_int() as usize)
992 };
993
994 let r1 = search
995 .clone()
996 .run("TEST", &candles, &config, factory)
997 .unwrap();
998 let r2 = search.run("TEST", &candles, &config, factory).unwrap();
999
1000 assert_eq!(r1.n_evaluations, r2.n_evaluations);
1001 assert_eq!(r1.convergence_curve, r2.convergence_curve);
1002 assert_eq!(
1003 r1.best.result.metrics.total_return_pct,
1004 r2.best.result.metrics.total_return_pct
1005 );
1006 }
1007}