Skip to main content

finance_query/backtesting/optimizer/
pareto.rs

1//! Multi-objective parameter search over completed backtests.
2//!
3//! [`GridSearch::run_pareto`] and [`BayesianSearch::run_pareto`] score finished
4//! evaluations against two or more objectives and return the non-dominated set
5//! as a [`ParetoReport`].
6
7use 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/// One non-dominated parameter set and its score on each objective.
20#[non_exhaustive]
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ParetoPoint {
23    /// Parameter set that produced this result.
24    pub params: HashMap<String, ParamValue>,
25    /// The completed backtest.
26    pub result: BacktestResult,
27    /// Score per objective, in the order the objectives were requested. Higher
28    /// is better on every entry, matching [`OptimizeMetric`].
29    pub scores: Vec<f64>,
30}
31
32/// The Pareto front of a multi-objective search.
33///
34/// `total_evaluated == front.len() + dominated_count + non_finite_count`.
35#[non_exhaustive]
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ParetoReport {
38    /// Name of the strategy under test.
39    pub strategy_name: String,
40    /// Objectives that were optimised, in request order.
41    pub objectives: Vec<OptimizeMetric>,
42    /// Non-dominated parameter sets, sorted best-first on the first objective.
43    pub front: Vec<ParetoPoint>,
44    /// Parameter sets that produced a completed backtest.
45    pub total_evaluated: usize,
46    /// Candidates beaten outright by another candidate.
47    pub dominated_count: usize,
48    /// Candidates excluded for scoring non-finite on at least one objective.
49    pub non_finite_count: usize,
50}
51
52/// True when `a` is at least as good as `b` everywhere and strictly better
53/// somewhere. Mismatched lengths never dominate.
54fn 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
81/// Reduce completed evaluations to their Pareto front.
82pub(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    /// Run the full grid and return the Pareto front over `objectives`.
155    ///
156    /// Requires at least two objectives; [`run`](Self::run) covers the single
157    /// metric case.
158    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    /// Run the surrogate search and return the Pareto front over `objectives`.
178    ///
179    /// The search itself is guided by `objectives[0]`; the remaining objectives
180    /// filter the completed evaluations. Requires at least two objectives.
181    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}