1use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::backtesting::config::BacktestConfig;
12use crate::backtesting::error::{BacktestError, Result};
13use crate::backtesting::result::BacktestResult;
14use crate::backtesting::strategy::Strategy;
15use crate::models::chart::Candle;
16
17use super::{BayesianSearch, GridSearch, OptimizationResult, OptimizeMetric, ParamValue};
18
19#[non_exhaustive]
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ParetoPoint {
23 pub params: HashMap<String, ParamValue>,
25 pub result: BacktestResult,
27 pub scores: Vec<f64>,
30}
31
32#[non_exhaustive]
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ParetoReport {
38 pub strategy_name: String,
40 pub objectives: Vec<OptimizeMetric>,
42 pub front: Vec<ParetoPoint>,
44 pub total_evaluated: usize,
46 pub dominated_count: usize,
48 pub non_finite_count: usize,
50}
51
52fn dominates(a: &[f64], b: &[f64]) -> bool {
55 if a.len() != b.len() {
56 return false;
57 }
58
59 let mut strictly_better = false;
60 for (ai, bi) in a.iter().zip(b.iter()) {
61 if ai < bi {
62 return false;
63 }
64 if ai > bi {
65 strictly_better = true;
66 }
67 }
68 strictly_better
69}
70
71pub(super) fn validate_objectives(objectives: &[OptimizeMetric]) -> Result<()> {
72 if objectives.len() < 2 {
73 return Err(BacktestError::invalid_param(
74 "objectives",
75 "Pareto search requires at least 2 objectives",
76 ));
77 }
78 Ok(())
79}
80
81pub(super) fn build_pareto_report(
83 results: Vec<OptimizationResult>,
84 objectives: &[OptimizeMetric],
85) -> Result<ParetoReport> {
86 validate_objectives(objectives)?;
87
88 if results.is_empty() {
89 return Err(BacktestError::invalid_param(
90 "candles",
91 "no parameter combination had enough data to run",
92 ));
93 }
94
95 let total_evaluated = results.len();
96 let strategy_name = results[0].result.strategy_name.clone();
97
98 let candidates: Vec<(Vec<f64>, OptimizationResult)> = results
99 .into_iter()
100 .filter_map(|r| {
101 let scores: Vec<f64> = objectives.iter().map(|m| m.score(&r.result)).collect();
102 scores.iter().all(|s| s.is_finite()).then_some((scores, r))
103 })
104 .collect();
105
106 if candidates.is_empty() {
107 return Err(BacktestError::invalid_param(
108 "objectives",
109 "every evaluation produced a non-finite score on at least one objective",
110 ));
111 }
112
113 let non_finite_count = total_evaluated - candidates.len();
114
115 let dominated: Vec<bool> = (0..candidates.len())
116 .map(|i| {
117 candidates
118 .iter()
119 .enumerate()
120 .any(|(j, other)| j != i && dominates(&other.0, &candidates[i].0))
121 })
122 .collect();
123
124 let mut front: Vec<ParetoPoint> = candidates
125 .into_iter()
126 .zip(dominated)
127 .filter(|(_, is_dominated)| !is_dominated)
128 .map(|((scores, r), _)| ParetoPoint {
129 params: r.params,
130 result: r.result,
131 scores,
132 })
133 .collect();
134
135 front.sort_by(|a, b| {
136 b.scores[0]
137 .partial_cmp(&a.scores[0])
138 .unwrap_or(std::cmp::Ordering::Equal)
139 });
140
141 let dominated_count = total_evaluated - non_finite_count - front.len();
142
143 Ok(ParetoReport {
144 strategy_name,
145 objectives: objectives.to_vec(),
146 front,
147 total_evaluated,
148 dominated_count,
149 non_finite_count,
150 })
151}
152
153impl GridSearch {
154 pub fn run_pareto<S, F>(
159 &self,
160 symbol: &str,
161 candles: &[Candle],
162 config: &BacktestConfig,
163 objectives: &[OptimizeMetric],
164 factory: F,
165 ) -> Result<ParetoReport>
166 where
167 S: Strategy + Send,
168 F: Fn(&HashMap<String, ParamValue>) -> S + Send + Sync,
169 {
170 validate_objectives(objectives)?;
171 let (results, _, _) = self.evaluate_all(symbol, candles, config, factory)?;
172 build_pareto_report(results, objectives)
173 }
174}
175
176impl BayesianSearch {
177 pub fn run_pareto<S, F>(
182 &self,
183 symbol: &str,
184 candles: &[Candle],
185 config: &BacktestConfig,
186 objectives: &[OptimizeMetric],
187 factory: F,
188 ) -> Result<ParetoReport>
189 where
190 S: Strategy,
191 F: Fn(&HashMap<String, ParamValue>) -> S,
192 {
193 validate_objectives(objectives)?;
194 let (results, _, _) = self.search(symbol, candles, config, objectives[0], &factory)?;
195 build_pareto_report(results, objectives)
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::backtesting::result::PerformanceMetrics;
203 use crate::backtesting::{ParamRange, SmaCrossover};
204
205 fn make_candles(prices: &[f64]) -> Vec<Candle> {
206 prices
207 .iter()
208 .enumerate()
209 .map(|(i, &p)| Candle {
210 timestamp: i as i64,
211 open: p,
212 high: p * 1.01,
213 low: p * 0.99,
214 close: p,
215 volume: 1000,
216 adj_close: Some(p),
217 provider_id: None,
218 })
219 .collect()
220 }
221
222 fn trending_prices(n: usize) -> Vec<f64> {
223 (0..n).map(|i| 100.0 + i as f64 * 0.5).collect()
224 }
225
226 fn opt_result(label: &str, sharpe: f64, max_drawdown_pct: f64) -> OptimizationResult {
227 let mut result = BacktestResult {
228 symbol: "TEST".to_string(),
229 strategy_name: "Synthetic".to_string(),
230 config: BacktestConfig::default(),
231 start_timestamp: 0,
232 end_timestamp: 1,
233 initial_capital: 10_000.0,
234 final_equity: 10_000.0,
235 metrics: PerformanceMetrics::calculate(&[], &[], 10_000.0, 0, 0, 0.0, 252.0),
236 trades: vec![],
237 equity_curve: vec![],
238 signals: vec![],
239 open_position: None,
240 benchmark: None,
241 diagnostics: vec![],
242 max_leverage_used: 0.0,
243 };
244 result.metrics.sharpe_ratio = sharpe;
245 result.metrics.max_drawdown_pct = max_drawdown_pct;
246
247 let mut params = HashMap::new();
248 params.insert("label".to_string(), ParamValue::Int(label.len() as i64));
249 OptimizationResult { params, result }
250 }
251
252 const TWO: [OptimizeMetric; 2] = [OptimizeMetric::SharpeRatio, OptimizeMetric::MinDrawdown];
253
254 #[test]
255 fn test_dominates_requires_no_worse_and_one_better() {
256 assert!(dominates(&[2.0, 1.0], &[1.0, 1.0]));
257 assert!(dominates(&[2.0, 2.0], &[1.0, 1.0]));
258 assert!(!dominates(&[2.0, 0.5], &[1.0, 1.0]));
259 assert!(!dominates(&[1.0, 1.0], &[1.0, 1.0]));
260 }
261
262 #[test]
263 fn test_dominates_is_false_on_length_mismatch() {
264 assert!(!dominates(&[1.0, 2.0], &[1.0]));
265 assert!(!dominates(&[1.0], &[1.0, 2.0]));
266 }
267
268 #[test]
269 fn test_front_keeps_only_non_dominated_points() {
270 let results = vec![
271 opt_result("a", 2.0, 0.30),
272 opt_result("b", 0.5, 0.05),
273 opt_result("c", 0.4, 0.35),
274 opt_result("d", 1.0, 0.30),
275 ];
276 let report = build_pareto_report(results, &TWO).unwrap();
277
278 assert_eq!(report.front.len(), 2);
279 assert_eq!(report.dominated_count, 2);
280 assert_eq!(report.non_finite_count, 0);
281 assert!((report.front[0].scores[0] - 2.0).abs() < 1e-12);
282 }
283
284 #[test]
285 fn test_counts_partition_every_evaluation() {
286 let mut results = vec![
287 opt_result("a", 2.0, 0.30),
288 opt_result("b", 0.5, 0.05),
289 opt_result("c", 0.4, 0.35),
290 ];
291 results.push(opt_result("nan", f64::NAN, 0.10));
292 let report = build_pareto_report(results, &TWO).unwrap();
293
294 assert_eq!(report.total_evaluated, 4);
295 assert_eq!(report.non_finite_count, 1);
296 assert_eq!(
297 report.total_evaluated,
298 report.front.len() + report.dominated_count + report.non_finite_count
299 );
300 }
301
302 #[test]
303 fn test_fewer_than_two_objectives_is_rejected() {
304 let results = vec![opt_result("a", 1.0, 0.1)];
305 assert!(build_pareto_report(results.clone(), &[]).is_err());
306 assert!(build_pareto_report(results, &[OptimizeMetric::SharpeRatio]).is_err());
307 }
308
309 #[test]
310 fn test_empty_results_are_rejected() {
311 assert!(build_pareto_report(vec![], &TWO).is_err());
312 }
313
314 #[test]
315 fn test_all_non_finite_scores_are_rejected() {
316 let results = vec![
317 opt_result("a", f64::NAN, 0.1),
318 opt_result("b", f64::NAN, 0.2),
319 ];
320 assert!(build_pareto_report(results, &TWO).is_err());
321 }
322
323 #[test]
324 fn test_grid_run_pareto_returns_a_non_dominated_front() {
325 let candles = make_candles(&trending_prices(120));
326 let report = GridSearch::new()
327 .param("fast", ParamRange::int_range(3, 9, 3))
328 .param("slow", ParamRange::int_range(12, 24, 6))
329 .run_pareto("TEST", &candles, &BacktestConfig::default(), &TWO, |p| {
330 SmaCrossover::new(p["fast"].as_int() as usize, p["slow"].as_int() as usize)
331 })
332 .unwrap();
333
334 assert!(!report.front.is_empty());
335 assert_eq!(report.objectives, TWO.to_vec());
336 for point in &report.front {
337 assert_eq!(point.scores.len(), 2);
338 }
339 }
340
341 #[test]
342 fn test_bayesian_run_pareto_returns_a_non_dominated_front() {
343 let candles = make_candles(&trending_prices(120));
344 let report = BayesianSearch::new()
345 .param("fast", ParamRange::int_bounds(3, 10))
346 .param("slow", ParamRange::int_bounds(12, 30))
347 .max_evaluations(12)
348 .initial_points(4)
349 .seed(42)
350 .run_pareto("TEST", &candles, &BacktestConfig::default(), &TWO, |p| {
351 SmaCrossover::new(p["fast"].as_int() as usize, p["slow"].as_int() as usize)
352 })
353 .unwrap();
354
355 assert!(!report.front.is_empty());
356 for point in &report.front {
357 assert!(point.scores.iter().all(|s| s.is_finite()));
358 }
359 }
360
361 #[test]
362 fn test_objectives_are_validated_before_the_search_runs() {
363 let candles = make_candles(&trending_prices(60));
364 let err = GridSearch::new()
365 .run_pareto(
366 "TEST",
367 &candles,
368 &BacktestConfig::default(),
369 &[OptimizeMetric::SharpeRatio],
370 |_| SmaCrossover::new(3, 12),
371 )
372 .unwrap_err();
373 assert!(err.to_string().contains("objectives"));
374
375 let err = BayesianSearch::new()
376 .run_pareto(
377 "TEST",
378 &candles,
379 &BacktestConfig::default(),
380 &[OptimizeMetric::SharpeRatio],
381 |_| SmaCrossover::new(3, 12),
382 )
383 .unwrap_err();
384 assert!(err.to_string().contains("objectives"));
385 }
386}