Skip to main content

egui_charts/strategies/
optimize.rs

1//! Lazy trials over a Cartesian product of strategy input values.
2
3use super::{Backtest, Strategy};
4use crate::model::{Bar, StrategyPerformance};
5use crate::studies::InputValue;
6
7/// The input values and report metrics of one optimisation trial.
8#[derive(Clone, Debug, PartialEq)]
9#[non_exhaustive]
10pub struct StrategyTrial {
11    /// Input ids and the values applied, in the grid's order.
12    pub inputs: Vec<(String, InputValue)>,
13    /// Metrics computed by [`crate::model::StrategyReport::performance`].
14    pub performance: StrategyPerformance,
15}
16
17/// Runs one fresh strategy per combination of `inputs`, on the same backtest and bars.
18///
19/// `create_strategy` must return an independent strategy with the same initial configuration
20/// and state each time. For a configured, cloneable strategy, pass `|| Box::new(strategy.clone())`
21/// to leave the original unchanged; a [`super::StrategyFactory`] can supply boxed strategies.
22/// Returned strategies may borrow host data for the iterator's lifetime.
23///
24/// No strategy is created or run until `next`. Each `next` runs one complete backtest and
25/// computes its report's performance. Consume a few trials per frame, or create and consume
26/// the iterator on a worker thread. Dropping it stops the remaining trials.
27///
28/// Values run in the supplied order, with the last input varying fastest. No inputs means
29/// one unmodified trial; any input with no values means no trials. Duplicate ids yield
30/// `Err("duplicate strategy input")`. A rejected assignment yields the strategy's exact
31/// [`Strategy::set_input`] error without running that trial. Either error ends the iterator.
32/// `lower` has the same meaning as in [`Backtest::run`]; pass `&[]` for chart bars alone.
33///
34/// # Panics
35///
36/// As with [`Backtest::run`], a trial panics if the starting capital is not finite.
37///
38/// ```
39/// use egui_charts::model::Timeframe;
40/// use egui_charts::strategies::{Backtest, StrategyFactory, optimise};
41/// use egui_charts::studies::InputValue;
42///
43/// let factory = StrategyFactory::new();
44/// let backtest = Backtest::new("Consecutive Up/Down", "TEST", Timeframe::Min1, 10_000.0);
45/// let grid = [("length".into(), vec![InputValue::Length(2), InputValue::Length(3)])];
46/// // Supply the host's chart bars and optional finer bars in place of the empty slices.
47/// let mut trials = optimise(
48///     || factory.create("Consecutive Up/Down").unwrap(), &backtest, &[], &[], &grid,
49/// );
50/// assert_eq!(trials.next().unwrap().unwrap().inputs[0].1, InputValue::Length(2));
51/// assert_eq!(trials.next().unwrap().unwrap().inputs[0].1, InputValue::Length(3));
52/// assert!(trials.next().is_none());
53/// ```
54pub fn optimise<'a>(
55    create_strategy: impl Fn() -> Box<dyn Strategy + 'a> + 'a,
56    backtest: &'a Backtest,
57    bars: &'a [Bar],
58    lower: &'a [Bar],
59    inputs: &'a [(String, Vec<InputValue>)],
60) -> impl Iterator<Item = Result<StrategyTrial, &'static str>> + 'a {
61    let mut indices = vec![0; inputs.len()];
62    let mut finished = inputs.iter().any(|(_, values)| values.is_empty());
63    let duplicate = inputs
64        .iter()
65        .enumerate()
66        .any(|(i, (id, _))| inputs[..i].iter().any(|(other, _)| other == id));
67    std::iter::from_fn(move || {
68        if finished {
69            return None;
70        }
71        if duplicate {
72            finished = true;
73            return Some(Err("duplicate strategy input"));
74        }
75        let mut strategy = create_strategy();
76        let mut values = Vec::with_capacity(inputs.len());
77        for ((id, options), &index) in inputs.iter().zip(&indices) {
78            let value = options[index].clone();
79            if let Err(error) = strategy.set_input(id, value.clone()) {
80                finished = true;
81                return Some(Err(error));
82            }
83            values.push((id.clone(), value));
84        }
85        let performance = backtest.run(strategy.as_mut(), bars, lower).performance();
86        finished = true;
87        for i in (0..indices.len()).rev() {
88            indices[i] += 1;
89            if indices[i] < inputs[i].1.len() {
90                finished = false;
91                break;
92            }
93            indices[i] = 0;
94        }
95        Some(Ok(StrategyTrial {
96            inputs: values,
97            performance,
98        }))
99    })
100}
101
102#[cfg(test)]
103mod tests {
104    use std::sync::atomic::{AtomicUsize, Ordering};
105
106    use chrono::{DateTime, TimeDelta};
107
108    use crate::model::{Bar, OrderSide, OrderType, Timeframe};
109    use crate::strategies::{Backtest, Commission, OrderSize, Strategy, StrategyContext, optimise};
110    use crate::studies::InputValue;
111
112    #[derive(Clone)]
113    struct Timed<'a> {
114        entry: usize,
115        hold: usize,
116        entered: bool,
117        runs: &'a AtomicUsize,
118    }
119
120    impl Strategy for Timed<'_> {
121        fn set_input(&mut self, id: &str, value: InputValue) -> Result<(), &'static str> {
122            let target = match id {
123                "entry" => &mut self.entry,
124                "hold" => &mut self.hold,
125                _ => return Err("unknown timed input"),
126            };
127            let InputValue::Length(value) = value else {
128                return Err("expected a length");
129            };
130            if id == "hold" && value == 0 {
131                return Err("hold must be positive");
132            }
133            *target = value;
134            Ok(())
135        }
136
137        fn prepare(&mut self, _: &[Bar]) {
138            assert!(!self.entered, "each run needs a fresh strategy");
139            self.runs.fetch_add(1, Ordering::SeqCst);
140        }
141
142        fn on_bar(&mut self, context: &mut StrategyContext<'_>) {
143            let index = context.bars().len() - 1;
144            if index == self.entry && !self.entered {
145                context.entry("Long", OrderSide::Buy, None, OrderType::Market);
146                self.entered = true;
147            } else if index == self.entry + self.hold {
148                context.exit("Close", None, None, None);
149            }
150        }
151    }
152
153    fn axis(id: &str, values: &[usize]) -> (String, Vec<InputValue>) {
154        (
155            id.into(),
156            values.iter().copied().map(InputValue::Length).collect(),
157        )
158    }
159
160    fn source() -> Vec<Bar> {
161        [100.0, 102.0, 107.0, 99.0, 111.0]
162            .into_iter()
163            .enumerate()
164            .map(|(i, price)| {
165                Bar::new(
166                    DateTime::UNIX_EPOCH + TimeDelta::minutes(i as i64),
167                    price,
168                    price + 5.0,
169                    price - 5.0,
170                    price,
171                    1.0,
172                )
173            })
174            .collect()
175    }
176
177    #[test]
178    fn grid_trials_are_lazy_independent_and_match_separate_runs() {
179        let bars = source();
180        // Only the latter half of each chart bar is supplied, at a different price.
181        let lower: Vec<_> = bars
182            .iter()
183            .enumerate()
184            .map(|(i, bar)| {
185                let price = bar.close + i as f64;
186                Bar::new(
187                    bar.time + TimeDelta::seconds(30),
188                    price,
189                    price,
190                    price,
191                    price,
192                    1.0,
193                )
194            })
195            .collect();
196        let grid = [axis("entry", &[0, 1]), axis("hold", &[1, 2])];
197        for (fill_on_close, finer, profits) in [
198            (true, &[][..], [2.0, 12.0, 8.0, -8.0]),
199            (false, &[][..], [8.0, -8.0, -18.0, 6.0]),
200            (false, lower.as_slice(), [10.0, -4.0, -16.0, 10.0]),
201        ] {
202            let runs = AtomicUsize::new(0);
203            let base = Timed {
204                entry: 0,
205                hold: 1,
206                entered: false,
207                runs: &runs,
208            };
209            let mut backtest = Backtest::new("Grid", "TEST", Timeframe::Min1, 1_000.0);
210            backtest.settings.fill_on_close = fill_on_close;
211            backtest.settings.order_size = OrderSize::Fixed(2.0);
212            backtest.settings.commission = Commission::PerUnit(0.5);
213            let mut trials = optimise(|| Box::new(base.clone()), &backtest, &bars, finer, &grid);
214            assert_eq!(base.runs.load(Ordering::SeqCst), 0);
215            for (i, (entry, hold)) in [(0, 1), (0, 2), (1, 1), (1, 2)].into_iter().enumerate() {
216                let trial = trials.next().unwrap().unwrap();
217                assert_eq!(base.runs.load(Ordering::SeqCst), i + 1);
218                assert_eq!(
219                    trial.inputs,
220                    vec![
221                        ("entry".into(), InputValue::Length(entry)),
222                        ("hold".into(), InputValue::Length(hold)),
223                    ]
224                );
225                // Two units times the exit minus entry price, less 1 on each fill.
226                assert_eq!(trial.performance.net_profit, profits[i]);
227                let separate_runs = AtomicUsize::new(0);
228                let mut separate = Timed {
229                    entry,
230                    hold,
231                    runs: &separate_runs,
232                    ..base.clone()
233                };
234                assert_eq!(
235                    trial.performance,
236                    backtest.run(&mut separate, &bars, finer).performance()
237                );
238            }
239            assert!(trials.next().is_none());
240            assert!(trials.next().is_none());
241            assert_eq!((base.entry, base.hold, base.entered), (0, 1, false));
242            let mut partial = optimise(|| Box::new(base.clone()), &backtest, &bars, finer, &grid);
243            std::thread::scope(|scope| {
244                scope
245                    .spawn(move || partial.next().unwrap().unwrap())
246                    .join()
247                    .unwrap();
248            });
249            assert_eq!(base.runs.load(Ordering::SeqCst), 5);
250        }
251    }
252
253    #[test]
254    fn empty_grids_and_rejected_inputs_end_iteration_without_extra_runs() {
255        let bars = source();
256        let backtest = Backtest::new("Grid", "TEST", Timeframe::Min1, 1_000.0);
257        for (grid, expected_runs, error) in [
258            (vec![], 1, None),
259            (vec![axis("entry", &[])], 0, None),
260            (vec![axis("entry", &[0, 1, 2]), axis("hold", &[])], 0, None),
261            (
262                vec![axis("unknown", &[0]), axis("hold", &[1])],
263                0,
264                Some("unknown timed input"),
265            ),
266            (
267                vec![axis("entry", &[0, 1]), axis("unknown", &[1])],
268                0,
269                Some("unknown timed input"),
270            ),
271            (
272                vec![("hold".into(), vec![InputValue::Toggle(true)])],
273                0,
274                Some("expected a length"),
275            ),
276            (
277                vec![axis("hold", &[0, 1])],
278                0,
279                Some("hold must be positive"),
280            ),
281            (
282                vec![axis("hold", &[1, 0, 2])],
283                1,
284                Some("hold must be positive"),
285            ),
286            (
287                vec![axis("hold", &[1, 2]), axis("hold", &[2])],
288                0,
289                Some("duplicate strategy input"),
290            ),
291        ] {
292            let runs = AtomicUsize::new(0);
293            let base = Timed {
294                entry: 0,
295                hold: 1,
296                entered: false,
297                runs: &runs,
298            };
299            let mut trials = optimise(|| Box::new(base.clone()), &backtest, &bars, &[], &grid);
300            assert_eq!(base.runs.load(Ordering::SeqCst), 0);
301            for _ in 0..expected_runs {
302                trials.next().unwrap().unwrap();
303            }
304            if let Some(error) = error {
305                assert_eq!(trials.next().unwrap().unwrap_err(), error);
306            }
307            assert!(trials.next().is_none(), "{grid:?}");
308            assert!(trials.next().is_none(), "{grid:?}");
309            assert_eq!(base.runs.load(Ordering::SeqCst), expected_runs, "{grid:?}");
310            assert_eq!((base.entry, base.hold, base.entered), (0, 1, false));
311        }
312    }
313}