Skip to main content

finance_query/backtesting/optimizer/
mod.rs

1//! Parameter optimisation for backtesting strategies.
2//!
3//! Two optimisers are available, both returning the same [`OptimizationReport`]
4//! so they are drop-in interchangeable and both work with [`WalkForwardConfig`]:
5//!
6//! | Optimiser | Evaluations | When to use |
7//! |-----------|-------------|-------------|
8//! | [`GridSearch`] | O(nᵏ) — all combinations | ≤ 3 parameters, small step counts |
9//! | [`BayesianSearch`] | configurable (default 100) | 4+ parameters or continuous float ranges |
10//!
11//! [`WalkForwardConfig`]: super::walk_forward::WalkForwardConfig
12
13mod bayesian;
14mod grid;
15mod pareto;
16
17pub use bayesian::BayesianSearch;
18pub use grid::GridSearch;
19pub use pareto::{ParetoPoint, ParetoReport};
20
21use std::collections::HashMap;
22
23use serde::{Deserialize, Serialize};
24
25use super::result::BacktestResult;
26
27// ── Parameter types ───────────────────────────────────────────────────────────
28
29/// A single parameter value — either an integer period or a float multiplier.
30#[non_exhaustive]
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub enum ParamValue {
33    /// Integer parameter (e.g. a period length)
34    Int(i64),
35    /// Floating-point parameter (e.g. a multiplier or percentage)
36    Float(f64),
37}
38
39impl ParamValue {
40    /// Return the value as `i64`, truncating floats.
41    pub fn as_int(&self) -> i64 {
42        match self {
43            ParamValue::Int(v) => *v,
44            ParamValue::Float(v) => *v as i64,
45        }
46    }
47
48    /// Return the value as `f64`.
49    pub fn as_float(&self) -> f64 {
50        match self {
51            ParamValue::Int(v) => *v as f64,
52            ParamValue::Float(v) => *v,
53        }
54    }
55}
56
57impl std::fmt::Display for ParamValue {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            ParamValue::Int(v) => write!(f, "{v}"),
61            ParamValue::Float(v) => write!(f, "{v:.4}"),
62        }
63    }
64}
65
66// ── Parameter ranges ──────────────────────────────────────────────────────────
67
68/// Defines the search space for a single strategy parameter.
69///
70/// | Constructor | Compatible with | Typical use |
71/// |-------------|-----------------|-------------|
72/// | [`int_range(start, end, step)`] | GridSearch + BayesianSearch | Integer period with explicit grid step |
73/// | [`float_range(start, end, step)`] | GridSearch + BayesianSearch | Float multiplier with explicit grid step |
74/// | [`int_bounds(start, end)`] | GridSearch (step=1) + BayesianSearch | Integer period, let Bayesian sample freely |
75/// | [`float_bounds(start, end)`] | **BayesianSearch only** | Continuous float range |
76/// | [`Values(vec)`] | GridSearch + BayesianSearch | Explicit list of values |
77///
78/// [`int_range(start, end, step)`]: ParamRange::int_range
79/// [`float_range(start, end, step)`]: ParamRange::float_range
80/// [`int_bounds(start, end)`]: ParamRange::int_bounds
81/// [`float_bounds(start, end)`]: ParamRange::float_bounds
82/// [`Values(vec)`]: ParamRange::Values
83#[non_exhaustive]
84#[derive(Debug, Clone)]
85pub enum ParamRange {
86    /// Inclusive integer range with a step size.
87    IntRange {
88        /// First value to include
89        start: i64,
90        /// Last value to include (inclusive)
91        end: i64,
92        /// Increment between values
93        step: i64,
94    },
95    /// Inclusive float range with a step size.
96    FloatRange {
97        /// First value in the range
98        start: f64,
99        /// Last value to include (inclusive)
100        end: f64,
101        /// Increment between values
102        step: f64,
103    },
104    /// Explicit list of values.
105    Values(Vec<ParamValue>),
106}
107
108impl ParamRange {
109    /// Stepped integer range — compatible with both [`GridSearch`] and [`BayesianSearch`].
110    pub fn int_range(start: i64, end: i64, step: i64) -> Self {
111        Self::IntRange { start, end, step }
112    }
113
114    /// Stepped float range — compatible with both [`GridSearch`] and [`BayesianSearch`].
115    pub fn float_range(start: f64, end: f64, step: f64) -> Self {
116        Self::FloatRange { start, end, step }
117    }
118
119    /// Continuous integer bounds for [`BayesianSearch`].
120    ///
121    /// Equivalent to `int_range(start, end, 1)`. Also usable with [`GridSearch`]
122    /// (enumerates every integer in `[start, end]`), but prefer `int_range` with a
123    /// wider step when the grid would be very large.
124    pub fn int_bounds(start: i64, end: i64) -> Self {
125        Self::IntRange {
126            start,
127            end,
128            step: 1,
129        }
130    }
131
132    /// Continuous float bounds — **[`BayesianSearch`] only**.
133    ///
134    /// A step of `0.0` intentionally makes [`GridSearch`] return an error, giving
135    /// a clear signal when the wrong optimiser is used with this range type.
136    pub fn float_bounds(start: f64, end: f64) -> Self {
137        Self::FloatRange {
138            start,
139            end,
140            step: 0.0,
141        }
142    }
143
144    /// Map a normalised position `t ∈ [0.0, 1.0]` to a concrete [`ParamValue`].
145    ///
146    /// Used by [`BayesianSearch`] to translate unit-hypercube coordinates into
147    /// the actual parameter space.
148    pub(crate) fn sample_at(&self, t: f64) -> ParamValue {
149        let t = t.clamp(0.0, 1.0);
150        match self {
151            ParamRange::IntRange { start, end, .. } => {
152                // Map t uniformly over [start, end] (inclusive on both ends).
153                let span = (*end - *start) as f64;
154                let v = *start + (t * (span + 1.0)).floor() as i64;
155                ParamValue::Int(v.min(*end))
156            }
157            ParamRange::FloatRange { start, end, .. } => {
158                ParamValue::Float(start + t * (end - start))
159            }
160            ParamRange::Values(vals) if vals.is_empty() => ParamValue::Int(0),
161            ParamRange::Values(vals) => {
162                let idx = (t * vals.len() as f64).floor() as usize;
163                vals[idx.min(vals.len() - 1)].clone()
164            }
165        }
166    }
167
168    /// Expand the range into a flat `Vec<ParamValue>` for grid enumeration.
169    ///
170    /// Returns an empty `Vec` when the step is `≤ 0`, which causes [`GridSearch`]
171    /// to return an error. This is intentional for [`float_bounds`] ranges.
172    ///
173    /// [`float_bounds`]: ParamRange::float_bounds
174    pub(crate) fn expand(&self) -> Vec<ParamValue> {
175        match self {
176            ParamRange::IntRange { start, end, step } => {
177                if *step <= 0 {
178                    return vec![];
179                }
180                let mut v = Vec::new();
181                let mut cur = *start;
182                while cur <= *end {
183                    v.push(ParamValue::Int(cur));
184                    cur += step;
185                }
186                v
187            }
188            ParamRange::FloatRange { start, end, step } => {
189                if *step <= 0.0 {
190                    return vec![];
191                }
192                // Round the step count to avoid accumulated floating-point error.
193                // The last value is clamped to exactly `end` regardless of rounding.
194                let steps = ((end - start) / step).round() as usize;
195                if steps == 0 && start != end {
196                    return vec![ParamValue::Float(*start)];
197                }
198                (0..=steps)
199                    .map(|i| {
200                        let v = if i == steps {
201                            *end
202                        } else {
203                            start + i as f64 * step
204                        };
205                        ParamValue::Float(v)
206                    })
207                    .collect()
208            }
209            ParamRange::Values(vals) => vals.clone(),
210        }
211    }
212}
213
214// ── Metric selection ──────────────────────────────────────────────────────────
215
216/// Which performance metric to optimise for.
217///
218/// All metrics are maximised internally; [`MinDrawdown`] is negated so that
219/// a smaller drawdown produces a higher score.
220///
221/// [`MinDrawdown`]: OptimizeMetric::MinDrawdown
222#[non_exhaustive]
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224pub enum OptimizeMetric {
225    /// Maximise total return percentage
226    TotalReturn,
227    /// Maximise Sharpe ratio (risk-adjusted, uses `risk_free_rate` from config)
228    SharpeRatio,
229    /// Maximise Sortino ratio
230    SortinoRatio,
231    /// Maximise Calmar ratio
232    CalmarRatio,
233    /// Maximise profit factor (gross profit / gross loss)
234    ProfitFactor,
235    /// Maximise win rate
236    WinRate,
237    /// Minimise maximum drawdown (negated internally — lower drawdown = higher score)
238    MinDrawdown,
239    /// Maximise Omega ratio (probability-weighted gains over losses)
240    OmegaRatio,
241    /// Maximise expectancy (expected profit per trade)
242    Expectancy,
243}
244
245impl OptimizeMetric {
246    /// Extract the target score from a [`BacktestResult`]. Higher is always better.
247    pub(crate) fn score(&self, result: &BacktestResult) -> f64 {
248        match self {
249            OptimizeMetric::TotalReturn => result.metrics.total_return_pct,
250            OptimizeMetric::SharpeRatio => result.metrics.sharpe_ratio,
251            OptimizeMetric::SortinoRatio => result.metrics.sortino_ratio,
252            OptimizeMetric::CalmarRatio => result.metrics.calmar_ratio,
253            OptimizeMetric::ProfitFactor => result.metrics.profit_factor,
254            OptimizeMetric::WinRate => result.metrics.win_rate,
255            OptimizeMetric::MinDrawdown => -result.metrics.max_drawdown_pct,
256            OptimizeMetric::OmegaRatio => result.metrics.omega_ratio,
257            OptimizeMetric::Expectancy => result.metrics.expectancy,
258        }
259    }
260}
261
262// ── Result types ──────────────────────────────────────────────────────────────
263
264/// Result of a single parameter set evaluation.
265#[non_exhaustive]
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct OptimizationResult {
268    /// Parameter values used for this run
269    pub params: HashMap<String, ParamValue>,
270    /// The backtest result for these parameter values
271    pub result: BacktestResult,
272}
273
274/// Optimisation report returned by both [`GridSearch`] and [`BayesianSearch`].
275///
276/// # Overfitting Warning
277///
278/// All metrics are **in-sample** — the same candle data used to optimise the
279/// parameters is used to score them. In-sample results almost always overstate
280/// real-world performance.
281///
282/// **Always validate best parameters on unseen data** — use [`WalkForwardConfig`]
283/// for an unbiased out-of-sample estimate, or reserve a held-out test period.
284///
285/// [`WalkForwardConfig`]: super::walk_forward::WalkForwardConfig
286#[non_exhaustive]
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct OptimizationReport {
289    /// Name of the strategy being optimised
290    pub strategy_name: String,
291    /// Total number of successful parameter evaluations
292    pub total_combinations: usize,
293    /// All results sorted best-first by the target metric.
294    ///
295    /// Sets that fail due to insufficient data are silently skipped.
296    /// **In-sample only** — see struct-level docs.
297    pub results: Vec<OptimizationResult>,
298    /// The single best result (same object as `results[0]`).
299    ///
300    /// **In-sample only** — see struct-level docs.
301    pub best: OptimizationResult,
302    /// Number of combinations skipped due to unexpected errors (not insufficient
303    /// data). A non-zero value indicates a configuration problem.
304    pub skipped_errors: usize,
305    /// Running best metric value after each **successful** evaluation, in order.
306    ///
307    /// [`BayesianSearch`] populates this as a non-decreasing convergence trace.
308    /// [`GridSearch`] leaves it empty — parallel execution has no sequential order.
309    pub convergence_curve: Vec<f64>,
310    /// Total strategy evaluations **attempted** (including those skipped for
311    /// insufficient data).
312    ///
313    /// For [`GridSearch`]: equals `total_combinations`.
314    /// For [`BayesianSearch`]: equals `max_evaluations` (or fewer if data is short).
315    pub n_evaluations: usize,
316}
317
318/// Sort a `Vec<OptimizationResult>` best-first for a given metric.
319///
320/// NaN scores sort last so they never appear as "best".  Shared by both
321/// [`GridSearch`] and [`BayesianSearch`] to keep the sorting logic in one place.
322pub(crate) fn sort_results_best_first(results: &mut [OptimizationResult], metric: OptimizeMetric) {
323    results.sort_by(|a, b| {
324        let sa = metric.score(&a.result);
325        let sb = metric.score(&b.result);
326        match (sa.is_nan(), sb.is_nan()) {
327            (true, true) => std::cmp::Ordering::Equal,
328            (true, false) => std::cmp::Ordering::Greater, // NaN → last
329            (false, true) => std::cmp::Ordering::Less,    // non-NaN → first
330            (false, false) => sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal),
331        }
332    });
333}