1use 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#[derive(Debug, Clone, Default)]
65pub struct GridSearch {
66 params: Vec<(String, ParamRange)>,
68 metric: Option<OptimizeMetric>,
70}
71
72impl GridSearch {
73 pub fn new() -> Self {
75 Self::default()
76 }
77
78 pub fn param(mut self, name: impl Into<String>, range: ParamRange) -> Self {
83 self.params.push((name.into(), range));
84 self
85 }
86
87 pub fn optimize_for(mut self, metric: OptimizeMetric) -> Self {
89 self.metric = Some(metric);
90 self
91 }
92
93 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 convergence_curve: vec![],
139 n_evaluations,
140 })
141 }
142
143 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 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(¶ms);
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
223fn 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#[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 #[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 #[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 #[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 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 let r = ParamRange::float_bounds(0.1, 0.9);
347 assert!(r.expand().is_empty());
348 }
349
350 #[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 #[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(¶ms);
398 assert_eq!(combos.len(), 4);
399 }
400
401 #[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 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}