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}