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