Skip to main content

finance_query/backtesting/optimizer/
grid.rs

1//! Exhaustive grid-search parameter optimisation.
2//!
3//! Use [`GridSearch`] to sweep over all combinations of named parameter ranges
4//! and rank them by a chosen metric. All combinations run in parallel via
5//! `rayon`, and results are returned sorted best-first.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use finance_query::backtesting::{
11//!     BacktestConfig, SmaCrossover,
12//!     optimizer::{GridSearch, OptimizeMetric, ParamRange, ParamValue},
13//! };
14//!
15//! # fn example(candles: &[finance_query::models::chart::Candle]) {
16//! let report = GridSearch::new()
17//!     .param("fast", ParamRange::int_range(5, 50, 5))
18//!     .param("slow", ParamRange::int_range(20, 200, 10))
19//!     .optimize_for(OptimizeMetric::SharpeRatio)
20//!     .run("AAPL", candles, &BacktestConfig::default(), |params| {
21//!         SmaCrossover::new(
22//!             params["fast"].as_int() as usize,
23//!             params["slow"].as_int() as usize,
24//!         )
25//!     })
26//!     .unwrap();
27//!
28//! println!("Best params: {:?}", report.best.params);
29//! println!("Best Sharpe: {:.2}", report.best.result.metrics.sharpe_ratio);
30//! # }
31//! ```
32
33use std::collections::HashMap;
34use std::sync::atomic::{AtomicUsize, Ordering};
35
36use rayon::prelude::*;
37
38use crate::models::chart::Candle;
39
40use super::super::config::BacktestConfig;
41use super::super::engine::{BacktestEngine, validate_series_order};
42use super::super::error::{BacktestError, Result};
43use super::super::strategy::Strategy;
44use super::{
45    OptimizationReport, OptimizationResult, OptimizeMetric, ParamRange, ParamValue,
46    sort_results_best_first,
47};
48
49// ── GridSearch ────────────────────────────────────────────────────────────────
50
51/// Exhaustive grid-search optimiser for backtesting strategy parameters.
52///
53/// Evaluates every combination of the supplied parameter ranges in parallel.
54/// Use [`BayesianSearch`] instead when the cartesian product would exceed
55/// a few thousand combinations or when float ranges without a step are needed.
56///
57/// # Overfitting Warning
58///
59/// Results are **in-sample only**. Follow up with [`WalkForwardConfig`] or a
60/// held-out test window to obtain an unbiased out-of-sample estimate.
61///
62/// [`BayesianSearch`]: super::BayesianSearch
63/// [`WalkForwardConfig`]: super::super::walk_forward::WalkForwardConfig
64#[derive(Debug, Clone, Default)]
65pub struct GridSearch {
66    /// Named parameter ranges, in insertion order (for reproducibility).
67    params: Vec<(String, ParamRange)>,
68    /// Metric to maximise (defaults to `SharpeRatio`).
69    metric: Option<OptimizeMetric>,
70}
71
72impl GridSearch {
73    /// Create a new grid search with no parameters defined yet.
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Add a named parameter range to sweep.
79    ///
80    /// Parameters are expanded in cartesian-product order: the last parameter
81    /// added cycles fastest (inner loop).
82    pub fn param(mut self, name: impl Into<String>, range: ParamRange) -> Self {
83        self.params.push((name.into(), range));
84        self
85    }
86
87    /// Set the metric to optimise for (defaults to [`OptimizeMetric::SharpeRatio`]).
88    pub fn optimize_for(mut self, metric: OptimizeMetric) -> Self {
89        self.metric = Some(metric);
90        self
91    }
92
93    /// Run the grid search.
94    ///
95    /// `symbol` is used only for labelling in the returned results.
96    ///
97    /// `factory` receives the current parameter map and returns a strategy
98    /// instance. Combinations that exceed the strategy's warmup period are
99    /// silently skipped.
100    ///
101    /// Returns an error when the grid is empty or all combinations were skipped.
102    pub fn run<S, F>(
103        &self,
104        symbol: &str,
105        candles: &[Candle],
106        config: &BacktestConfig,
107        factory: F,
108    ) -> Result<OptimizationReport>
109    where
110        S: Strategy + Send,
111        F: Fn(&HashMap<String, ParamValue>) -> S + Send + Sync,
112    {
113        let metric = self.metric.unwrap_or(OptimizeMetric::SharpeRatio);
114        let (mut results, skipped_errors, total_combinations) =
115            self.evaluate_all(symbol, candles, config, factory)?;
116
117        sort_results_best_first(&mut results, metric);
118
119        if metric.score(&results[0].result).is_nan() {
120            return Err(BacktestError::invalid_param(
121                "metric",
122                "all parameter combinations produced NaN for the target metric",
123            ));
124        }
125
126        let strategy_name = results[0].result.strategy_name.clone();
127        let best = results[0].clone();
128        let n_evaluations = total_combinations;
129
130        Ok(OptimizationReport {
131            strategy_name,
132            total_combinations,
133            results,
134            best,
135            skipped_errors,
136            // GridSearch runs all combinations in parallel — no sequential ordering,
137            // so the convergence curve is meaningless and left empty.
138            convergence_curve: vec![],
139            n_evaluations,
140        })
141    }
142
143    /// Run every grid combination, returning the successful results, the count
144    /// skipped on unexpected errors, and the total combinations tried.
145    pub(super) fn evaluate_all<S, F>(
146        &self,
147        symbol: &str,
148        candles: &[Candle],
149        config: &BacktestConfig,
150        factory: F,
151    ) -> Result<(Vec<OptimizationResult>, usize, usize)>
152    where
153        S: Strategy + Send,
154        F: Fn(&HashMap<String, ParamValue>) -> S + Send + Sync,
155    {
156        if self.params.is_empty() {
157            return Err(BacktestError::invalid_param(
158                "params",
159                "grid search requires at least one parameter range",
160            ));
161        }
162
163        // Checked once here rather than inside every combination's backtest.
164        validate_series_order(candles, &[])?;
165
166        let expanded: Vec<(&str, Vec<ParamValue>)> = self
167            .params
168            .iter()
169            .map(|(name, range)| (name.as_str(), range.expand()))
170            .collect();
171
172        let combinations = cartesian_product(&expanded);
173        let total_combinations = combinations.len();
174
175        if total_combinations == 0 {
176            return Err(BacktestError::invalid_param(
177                "params",
178                "all parameter ranges produced empty value sets \
179                 (hint: float_bounds is not compatible with GridSearch — use BayesianSearch)",
180            ));
181        }
182
183        if total_combinations > 10_000 {
184            tracing::warn!(
185                total_combinations,
186                "grid search: large combination count — consider BayesianSearch or wider steps"
187            );
188        }
189
190        let skipped_errors = AtomicUsize::new(0);
191        let results: Vec<OptimizationResult> = combinations
192            .into_par_iter()
193            .filter_map(|params| {
194                let strategy = factory(&params);
195                match BacktestEngine::new(config.clone()).simulate(symbol, candles, strategy, &[]) {
196                    Ok(result) => Some(OptimizationResult { params, result }),
197                    Err(BacktestError::InsufficientData { .. }) => None,
198                    Err(e) => {
199                        tracing::warn!(
200                            params = ?params,
201                            error = %e,
202                            "grid search: skipping combination due to unexpected error"
203                        );
204                        skipped_errors.fetch_add(1, Ordering::Relaxed);
205                        None
206                    }
207                }
208            })
209            .collect();
210        let skipped_errors = skipped_errors.into_inner();
211
212        if results.is_empty() {
213            return Err(BacktestError::invalid_param(
214                "candles",
215                "no parameter combination had enough data to run",
216            ));
217        }
218
219        Ok((results, skipped_errors, total_combinations))
220    }
221}
222
223// ── Internal helpers ──────────────────────────────────────────────────────────
224
225/// Compute the cartesian product of named parameter value lists.
226///
227/// Returns a `Vec` of `HashMap`s, one per combination. The last parameter
228/// cycles fastest (inner loop).
229fn cartesian_product(params: &[(&str, Vec<ParamValue>)]) -> Vec<HashMap<String, ParamValue>> {
230    if params.is_empty() {
231        return vec![];
232    }
233
234    let mut result: Vec<HashMap<String, ParamValue>> = vec![HashMap::new()];
235
236    for (name, values) in params {
237        let mut next = Vec::with_capacity(result.len() * values.len());
238        for existing in &result {
239            for value in values {
240                let mut combo = existing.clone();
241                combo.insert(name.to_string(), value.clone());
242                next.push(combo);
243            }
244        }
245        result = next;
246    }
247
248    result
249}
250
251// ── Tests ─────────────────────────────────────────────────────────────────────
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::backtesting::{BacktestConfig, SmaCrossover};
257    use crate::models::chart::Candle;
258
259    fn make_candles(prices: &[f64]) -> Vec<Candle> {
260        prices
261            .iter()
262            .enumerate()
263            .map(|(i, &p)| Candle {
264                timestamp: i as i64,
265                open: p,
266                high: p * 1.01,
267                low: p * 0.99,
268                close: p,
269                volume: 1000,
270                adj_close: Some(p),
271                provider_id: None,
272            })
273            .collect()
274    }
275
276    fn trending_prices(n: usize) -> Vec<f64> {
277        (0..n).map(|i| 100.0 + i as f64 * 0.5).collect()
278    }
279
280    // ── ParamValue ────────────────────────────────────────────────────────────
281
282    #[test]
283    fn test_param_value_conversion() {
284        let iv = ParamValue::Int(10);
285        assert_eq!(iv.as_int(), 10);
286        assert!((iv.as_float() - 10.0).abs() < f64::EPSILON);
287
288        let fv = ParamValue::Float(1.5);
289        assert_eq!(fv.as_int(), 1);
290        assert!((fv.as_float() - 1.5).abs() < f64::EPSILON);
291    }
292
293    // ── ParamRange expansion (grid path) ─────────────────────────────────────
294
295    #[test]
296    fn test_int_range_expand() {
297        let r = ParamRange::int_range(5, 20, 5);
298        let vals = r.expand();
299        assert_eq!(
300            vals,
301            vec![
302                ParamValue::Int(5),
303                ParamValue::Int(10),
304                ParamValue::Int(15),
305                ParamValue::Int(20),
306            ]
307        );
308    }
309
310    #[test]
311    fn test_float_range_expand() {
312        let r = ParamRange::float_range(0.1, 0.3, 0.1);
313        let vals = r.expand();
314        assert_eq!(vals.len(), 3);
315        assert!((vals[0].as_float() - 0.1).abs() < 1e-9);
316        assert!((vals[2].as_float() - 0.3).abs() < 1e-9);
317    }
318
319    /// Floating-point arithmetic can produce `start + N * step` slightly above
320    /// `end`. The endpoint must be clamped exactly to `end` with no extra values.
321    #[test]
322    fn test_float_range_endpoint_clamping() {
323        let vals = ParamRange::float_range(0.1, 0.5, 0.1).expand();
324        assert_eq!(vals.len(), 5, "should have exactly 5 values [0.1…0.5]");
325        assert!(
326            (vals[4].as_float() - 0.5).abs() < 1e-12,
327            "endpoint must be exactly 0.5"
328        );
329
330        // step that doesn't evenly divide the range
331        let vals2 = ParamRange::float_range(0.1, 0.5, 0.15).expand();
332        assert_eq!(vals2.len(), 4);
333        assert!((vals2[3].as_float() - 0.5).abs() < 1e-12);
334    }
335
336    #[test]
337    fn test_float_range_step_exceeding_span_keeps_start() {
338        let vals = ParamRange::float_range(0.1, 0.12, 0.1).expand();
339        assert_eq!(vals.len(), 1);
340        assert!((vals[0].as_float() - 0.1).abs() < 1e-12);
341    }
342
343    #[test]
344    fn test_float_bounds_expand_returns_empty() {
345        // float_bounds has step=0.0, which is intentionally invalid for GridSearch.
346        let r = ParamRange::float_bounds(0.1, 0.9);
347        assert!(r.expand().is_empty());
348    }
349
350    // ── ParamRange sampling (Bayesian path) ───────────────────────────────────
351
352    #[test]
353    fn test_int_bounds_sample_at() {
354        let r = ParamRange::int_bounds(5, 50);
355        assert_eq!(r.sample_at(0.0), ParamValue::Int(5));
356        assert_eq!(r.sample_at(1.0), ParamValue::Int(50));
357        assert!(matches!(r.sample_at(0.5), ParamValue::Int(_)));
358    }
359
360    #[test]
361    fn test_float_bounds_sample_at() {
362        let r = ParamRange::float_bounds(0.3, 0.7);
363        assert!((r.sample_at(0.0).as_float() - 0.3).abs() < 1e-12);
364        assert!((r.sample_at(1.0).as_float() - 0.7).abs() < 1e-12);
365        assert!((r.sample_at(0.5).as_float() - 0.5).abs() < 1e-12);
366        assert!(matches!(r.sample_at(0.5), ParamValue::Float(_)));
367    }
368
369    #[test]
370    fn test_sample_at_int_range() {
371        let r = ParamRange::int_bounds(0, 9);
372        assert_eq!(r.sample_at(0.0), ParamValue::Int(0));
373        assert_eq!(r.sample_at(1.0), ParamValue::Int(9));
374        assert_eq!(r.sample_at(0.5), ParamValue::Int(5));
375    }
376
377    #[test]
378    fn test_sample_at_values_range() {
379        let r = ParamRange::Values(vec![
380            ParamValue::Int(10),
381            ParamValue::Int(20),
382            ParamValue::Int(30),
383        ]);
384        assert_eq!(r.sample_at(0.0), ParamValue::Int(10));
385        assert_eq!(r.sample_at(1.0), ParamValue::Int(30));
386        assert_eq!(r.sample_at(0.5), ParamValue::Int(20));
387    }
388
389    // ── cartesian_product ─────────────────────────────────────────────────────
390
391    #[test]
392    fn test_cartesian_product() {
393        let params: Vec<(&str, Vec<ParamValue>)> = vec![
394            ("a", vec![ParamValue::Int(1), ParamValue::Int(2)]),
395            ("b", vec![ParamValue::Int(10), ParamValue::Int(20)]),
396        ];
397        let combos = cartesian_product(&params);
398        assert_eq!(combos.len(), 4);
399    }
400
401    // ── GridSearch integration ────────────────────────────────────────────────
402
403    #[test]
404    fn test_grid_search_runs() {
405        let prices = trending_prices(100);
406        let candles = make_candles(&prices);
407        let config = BacktestConfig::builder()
408            .commission_pct(0.0)
409            .slippage_pct(0.0)
410            .build()
411            .unwrap();
412
413        let report = GridSearch::new()
414            .param("fast", ParamRange::int_range(3, 10, 3))
415            .param("slow", ParamRange::int_range(10, 20, 10))
416            .optimize_for(OptimizeMetric::TotalReturn)
417            .run("TEST", &candles, &config, |params| {
418                SmaCrossover::new(
419                    params["fast"].as_int() as usize,
420                    params["slow"].as_int() as usize,
421                )
422            })
423            .unwrap();
424
425        assert!(!report.results.is_empty());
426        assert_eq!(report.strategy_name, "SMA Crossover");
427        assert!(
428            report.convergence_curve.is_empty(),
429            "GridSearch curve should be empty"
430        );
431        assert_eq!(report.n_evaluations, report.total_combinations);
432
433        if report.results.len() > 1 {
434            let first = OptimizeMetric::TotalReturn.score(&report.results[0].result);
435            let second = OptimizeMetric::TotalReturn.score(&report.results[1].result);
436            assert!(first >= second);
437        }
438    }
439
440    #[test]
441    fn test_grid_search_no_params_errors() {
442        let candles = make_candles(&trending_prices(50));
443        let config = BacktestConfig::default();
444        let result = GridSearch::new().run("TEST", &candles, &config, |_| SmaCrossover::new(5, 10));
445        assert!(result.is_err());
446    }
447
448    #[test]
449    fn test_grid_search_float_bounds_errors() {
450        // float_bounds is incompatible with GridSearch (step=0.0 → empty expansion).
451        let candles = make_candles(&trending_prices(100));
452        let config = BacktestConfig::default();
453        let result = GridSearch::new()
454            .param("x", ParamRange::float_bounds(0.1, 0.9))
455            .run("TEST", &candles, &config, |_| SmaCrossover::new(5, 20));
456        assert!(result.is_err());
457    }
458
459    #[test]
460    fn test_optimize_metric_min_drawdown() {
461        let prices = trending_prices(60);
462        let candles = make_candles(&prices);
463        let config = BacktestConfig::builder()
464            .commission_pct(0.0)
465            .slippage_pct(0.0)
466            .build()
467            .unwrap();
468
469        let report = GridSearch::new()
470            .param("fast", ParamRange::int_range(3, 9, 3))
471            .param("slow", ParamRange::int_range(10, 20, 10))
472            .optimize_for(OptimizeMetric::MinDrawdown)
473            .run("TEST", &candles, &config, |params| {
474                SmaCrossover::new(
475                    params["fast"].as_int() as usize,
476                    params["slow"].as_int() as usize,
477                )
478            })
479            .unwrap();
480
481        assert!(!report.results.is_empty());
482        if report.results.len() > 1 {
483            let first = report.results[0].result.metrics.max_drawdown_pct;
484            let second = report.results[1].result.metrics.max_drawdown_pct;
485            assert!(first <= second + 1e-9, "best has smallest drawdown");
486        }
487    }
488}