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}