1use super::{Backtest, Strategy};
4use crate::model::{Bar, StrategyPerformance};
5use crate::studies::InputValue;
6
7#[derive(Clone, Debug, PartialEq)]
9#[non_exhaustive]
10pub struct StrategyTrial {
11 pub inputs: Vec<(String, InputValue)>,
13 pub performance: StrategyPerformance,
15}
16
17pub 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 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 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}