Skip to main content

finance_query/backtesting/optimizer/
bayesian.rs

1//! Sequential model-based (Bayesian) parameter optimisation.
2//!
3//! [`BayesianSearch`] finds near-optimal strategy parameters in far fewer
4//! backtests than exhaustive [`GridSearch`] — typically 50–200 evaluations
5//! instead of thousands — by building a statistical surrogate model of the
6//! objective and directing search toward promising, under-explored regions.
7//!
8//! # Algorithm (SAMBO — Sequential Adaptive Model-Based Optimisation)
9//!
10//! 1. **Exploration phase** — Sample `initial_points` parameter sets using
11//!    [Latin Hypercube Sampling] (LHS) to guarantee good initial coverage of
12//!    the search space.
13//! 2. **Sequential phase** — Fit a [Nadaraya-Watson kernel regression]
14//!    surrogate to all `(params, score)` observations. Generate `N_CANDIDATES`
15//!    random candidates and score each with the [Upper Confidence Bound] (UCB)
16//!    acquisition function `a(x) = μ(x) + β·σ(x)`. Run the backtest for the
17//!    highest-scoring candidate, add the observation, and repeat.
18//! 3. **Convergence** — Stop when `max_evaluations` is reached.
19//!
20//! [Latin Hypercube Sampling]: https://en.wikipedia.org/wiki/Latin_hypercube_sampling
21//! [Nadaraya-Watson kernel regression]: https://en.wikipedia.org/wiki/Kernel_regression
22//! [Upper Confidence Bound]: https://en.wikipedia.org/wiki/Multi-armed_bandit#Upper_confidence_bound
23//!
24//! # Example
25//!
26//! ```ignore
27//! use finance_query::backtesting::{
28//!     BacktestConfig, SmaCrossover,
29//!     optimizer::{BayesianSearch, OptimizeMetric, ParamRange},
30//! };
31//!
32//! # fn example(candles: &[finance_query::models::chart::Candle]) {
33//! let report = BayesianSearch::new()
34//!     .param("fast", ParamRange::int_bounds(5, 50))
35//!     .param("slow", ParamRange::int_bounds(20, 200))
36//!     .param("rsi_period", ParamRange::int_bounds(7, 21))
37//!     .param("threshold", ParamRange::float_bounds(0.3, 0.7))
38//!     .optimize_for(OptimizeMetric::SharpeRatio)
39//!     .max_evaluations(100)
40//!     .run("AAPL", &candles, &BacktestConfig::default(), |params| {
41//!         SmaCrossover::new(
42//!             params["fast"].as_int() as usize,
43//!             params["slow"].as_int() as usize,
44//!         )
45//!     })
46//!     .unwrap();
47//!
48//! println!("Best params:  {:?}", report.best.params);
49//! println!("Best Sharpe:  {:.2}", report.best.result.metrics.sharpe_ratio);
50//! println!("Evaluations:  {}", report.n_evaluations);
51//! # }
52//! ```
53
54use 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
70// ── Defaults ──────────────────────────────────────────────────────────────────
71
72const DEFAULT_MAX_EVALUATIONS: usize = 100;
73const DEFAULT_INITIAL_POINTS: usize = 10;
74/// β = 2.0 balances exploitation and exploration for objectives in [0, 1].
75const DEFAULT_UCB_BETA: f64 = 2.0;
76const DEFAULT_SEED: u64 = 42;
77/// Candidates evaluated per acquisition step. 1 000 reliably finds the UCB
78/// maximum without meaningful overhead (pure floating-point math, no backtests).
79const N_CANDIDATES: usize = 1_000;
80/// Observation count at which parallel candidate scoring starts to pay off.
81/// `Surrogate::predict` loops every observation, so per-call work scales with
82/// this. Below it, rayon's dispatch cost exceeds the benefit: measured
83/// 3.4→4.3 ms at 30 observations and 11.8→12.3 ms at 60 (both regressions),
84/// versus 43.2→22.7 ms at 120 and 114.9→44.0 ms at 200. 120 is the first
85/// measured win, so the gate sits there rather than at the last measured loss.
86const MIN_OBSERVATIONS_FOR_PARALLEL: usize = 120;
87
88// ── BayesianSearch ────────────────────────────────────────────────────────────
89
90/// Sequential model-based (Bayesian) parameter optimiser.
91///
92/// Finds near-optimal strategy parameters in a fraction of the evaluations
93/// required by exhaustive [`GridSearch`](super::grid::GridSearch), making it practical for
94/// high-dimensional spaces or continuous float ranges.
95///
96/// Returns the same [`OptimizationReport`] as [`GridSearch`](super::grid::GridSearch), so the two are
97/// drop-in interchangeable and both work with [`WalkForwardConfig`].
98///
99/// # Overfitting Warning
100///
101/// Results are **in-sample only**. Follow up with [`WalkForwardConfig`] or a
102/// held-out test window to obtain an unbiased out-of-sample estimate.
103///
104/// [`WalkForwardConfig`]: super::super::walk_forward::WalkForwardConfig
105#[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    /// Create a new Bayesian search with no parameters defined yet.
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Add a named parameter range to search over.
122    ///
123    /// Use [`ParamRange::int_bounds`] / [`ParamRange::float_bounds`] for
124    /// continuous ranges (recommended) or any [`ParamRange`] variant.
125    pub fn param(mut self, name: impl Into<String>, range: ParamRange) -> Self {
126        self.params.push((name.into(), range));
127        self
128    }
129
130    /// Set the metric to optimise for (defaults to [`OptimizeMetric::SharpeRatio`]).
131    pub fn optimize_for(mut self, metric: OptimizeMetric) -> Self {
132        self.metric = Some(metric);
133        self
134    }
135
136    /// Maximum total strategy evaluations, including the initial LHS phase (default: 100).
137    pub fn max_evaluations(mut self, n: usize) -> Self {
138        self.max_evaluations = Some(n);
139        self
140    }
141
142    /// Number of initial random (LHS) samples before the surrogate is fitted (default: 10).
143    ///
144    /// Clamped to `[2, max_evaluations]`. More initial points improve surrogate
145    /// quality at the cost of fewer sequential refinement steps.
146    pub fn initial_points(mut self, n: usize) -> Self {
147        self.initial_points = Some(n);
148        self
149    }
150
151    /// UCB exploration–exploitation coefficient β (default: 2.0).
152    ///
153    /// Higher values drive broader exploration of uncertain regions;
154    /// lower values concentrate search near already-good parameter sets.
155    pub fn ucb_beta(mut self, beta: f64) -> Self {
156        self.ucb_beta = Some(beta);
157        self
158    }
159
160    /// PRNG seed for reproducible runs (default: 42).
161    pub fn seed(mut self, seed: u64) -> Self {
162        self.seed = Some(seed);
163        self
164    }
165
166    /// Run the Bayesian search.
167    ///
168    /// `symbol` is used only for labelling in the returned results.
169    ///
170    /// `factory` receives the current parameter map and returns a strategy
171    /// instance. Parameter sets incompatible with the candle series (warmup
172    /// too long) are silently skipped.
173    ///
174    /// Returns an error only when no parameters are defined or every evaluation
175    /// was skipped due to insufficient data.
176    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    /// Drive the surrogate search, returning every successful evaluation, the
216    /// running-best convergence curve, and the number of candidates tried.
217    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        // Checked once here rather than inside every candidate's backtest.
237        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        // (unit-hypercube coords, metric score) for all successful evaluations.
251        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        // Running best score after each successful evaluation (non-decreasing).
254        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        // ── Latin Hypercube initial sampling ───────────────────────────────────
259
260        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        // ── Sequential surrogate-guided search ─────────────────────────────────
284
285        for _ in 0..max_eval.saturating_sub(n_init) {
286            let norm_point = if observations.len() < 2 {
287                // Too few observations for a reliable surrogate — fall back to random.
288                (0..d).map(|_| rng.next_f64_positive()).collect()
289            } else {
290                let surrogate = Surrogate::fit(&observations, beta);
291                // Draw every candidate serially into one flat buffer so the RNG
292                // consumption order — and hence seeded reproducibility — is
293                // identical to a per-candidate sequential scan.
294                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        // ── Finalise ───────────────────────────────────────────────────────────
327
328        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// ── Internal helpers ──────────────────────────────────────────────────────────
340
341#[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
350/// Index of the highest-UCB candidate in a flat `d`-strided buffer, or `None`
351/// when no candidate is eligible.
352///
353/// Exactly reproduces a sequential `if ucb > best_ucb` scan seeded with
354/// `f64::NEG_INFINITY`: candidates scoring `NaN` or `-∞` never beat that
355/// initial value, so they are mapped to the reduction identity and lose to any
356/// finite score. Ties are broken by lowest index, matching the strict `>` of
357/// the sequential scan. Both comparisons are order-independent, so rayon's
358/// non-deterministic combination order cannot change the winner.
359///
360/// Rayon is engaged only at [`MIN_OBSERVATIONS_FOR_PARALLEL`] observations or
361/// more. Both branches share the same scoring and combining closures, so the
362/// selected index is identical either way.
363fn 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
399/// Run one backtest for a unit-hypercube point; returns `None` for
400/// `InsufficientData` errors (silently skipped).
401fn 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(&params);
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
430/// Convert unit-hypercube coordinates `t[i] ∈ (0, 1]` into named [`ParamValue`]s.
431fn 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
442// ── Latin Hypercube Sampling ──────────────────────────────────────────────────
443
444/// Generate `n` stratified random samples in the `d`-dimensional unit hypercube.
445///
446/// Each dimension is divided into `n` equal strata; exactly one sample is drawn
447/// from each stratum per dimension. Stratum assignments are independently
448/// shuffled across dimensions, giving good marginal coverage with low
449/// inter-dimension correlation — significantly better than IID uniform sampling.
450fn 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        // One value per stratum [i/n, (i+1)/n).
460        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        // Fisher-Yates shuffle of stratum assignments for this dimension.
469        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
482// ── Surrogate model ───────────────────────────────────────────────────────────
483
484/// Nadaraya-Watson kernel regression surrogate with UCB acquisition.
485///
486/// Given observed `(x, y)` pairs (unit-hypercube coords and metric scores),
487/// models the objective surface as a Gaussian-kernel-weighted average.
488///
489/// **Why kernel regression?** It is dependency-free, numerically stable,
490/// non-parametric, and the mean/variance formulas are five lines of arithmetic.
491/// The trade-off vs. a Gaussian Process is that it does not provide a
492/// calibrated predictive distribution, but UCB acquisition works well in
493/// practice for backtesting parameter search.
494struct Surrogate<'a> {
495    observations: &'a [(Vec<f64>, f64)],
496    beta: f64,
497    /// Pre-computed `2h²` denominator for the RBF kernel exponent.
498    bandwidth_sq: f64,
499}
500
501impl<'a> Surrogate<'a> {
502    /// Fit the surrogate to a set of `(unit-hypercube coords, score)` pairs.
503    ///
504    /// Bandwidth: `h = n^(-1/(d+4))` (Silverman-inspired), floored at 0.1 to
505    /// avoid near-degenerate kernels with very few data points.
506    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    /// UCB acquisition: `μ(x) + β·σ(x)`.
518    fn acquisition(&self, x: &[f64]) -> f64 {
519        let (mean, std) = self.predict(x);
520        mean + self.beta * std
521    }
522
523    /// Nadaraya-Watson mean and weighted standard deviation at `x`.
524    ///
525    /// Returns `(0.0, 1.0)` — maximum uncertainty — when all observations are
526    /// too distant to contribute meaningful kernel weight.
527    ///
528    /// Uses Chan's single-pass online weighted mean+variance algorithm,
529    /// evaluating each RBF weight exactly once (vs. the two-pass approach
530    /// that would call `rbf` twice per observation).
531    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; // weighted sum of squared deviations
535
536        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    /// Gaussian (RBF) kernel: `exp(-‖x − xᵢ‖² / (2h²))`.
557    #[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// ── Tests ─────────────────────────────────────────────────────────────────────
565
566#[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    // ── LHS ───────────────────────────────────────────────────────────────────
594
595    #[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    // ── Surrogate ─────────────────────────────────────────────────────────────
633
634    #[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    /// A point so far from all observations that `exp(-dist²/2h²) < ε` triggers
643    /// the maximum-uncertainty fallback path, returning `(0.0, 1.0)`.
644    #[test]
645    fn test_surrogate_max_uncertainty_fallback_for_very_distant_point() {
646        // At x=100 the kernel weight is exp(-10000/bandwidth_sq) which underflows
647        // to exactly 0.0 in f64, so w_sum < EPSILON and the fallback is taken.
648        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    /// When two nearby observations have very different scores, the surrogate
662    /// should report non-trivial variance at the midpoint.
663    #[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]); // midpoint — equal weight to both
668        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); // high β → exploration-heavy
678        assert!(
679            s.acquisition(&[1.0]) > s.acquisition(&[0.05]),
680            "far point should have higher UCB with β=10"
681        );
682    }
683
684    // ── BayesianSearch integration ────────────────────────────────────────────
685
686    #[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    /// Reference implementation: the exact sequential scan this module used
814    /// before candidate scoring was parallelised.
815    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    /// Straddles `MIN_OBSERVATIONS_FOR_PARALLEL` so both the sequential fold and
861    /// the rayon reduce are exercised, and both must equal the reference scan.
862    #[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    /// Duplicated candidates score identically; the lowest index must win in
899    /// both the parallel and sequential formulations.
900    #[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        // Three copies of the UCB-maximal point, then a clearly worse one.
906        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    /// β = NaN makes every acquisition NaN, so no candidate ever beats the
916    /// initial `NEG_INFINITY`. Both formulations must report "none selected",
917    /// which the caller turns into `vec![0.0; d]`.
918    #[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    /// Pins the *selected candidates*, not just the aggregate score: a
930    /// tie-break or RNG-order regression changes which parameter sets get
931    /// evaluated, which shows up as a per-element mismatch here.
932    #[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}