finance_query/backtesting/strategy/mod.rs
1//! Strategy trait and context for building trading strategies.
2//!
3//! This module provides the core `Strategy` trait and `StrategyContext` for
4//! implementing custom trading strategies, as well as pre-built strategies
5//! and a fluent builder API.
6//!
7//! # Building Custom Strategies
8//!
9//! Use the `StrategyBuilder` for creating strategies with conditions:
10//!
11//! ```ignore
12//! use finance_query::backtesting::strategy::StrategyBuilder;
13//! use finance_query::backtesting::refs::*;
14//! use finance_query::backtesting::condition::*;
15//!
16//! let strategy = StrategyBuilder::new("My Strategy")
17//! .entry(rsi(14).crosses_below(30.0))
18//! .exit(rsi(14).crosses_above(70.0).or(stop_loss(0.05)))
19//! .build();
20//! ```
21
22mod builder;
23mod ensemble;
24pub mod prebuilt;
25
26use std::collections::HashMap;
27
28use crate::backtesting::condition::HtfIndicatorSpec;
29use crate::indicators::Indicator;
30use crate::models::chart::Candle;
31
32use super::position::{Position, PositionSide};
33use super::signal::Signal;
34
35// Re-export builder
36pub use builder::{CustomStrategy, StrategyBuilder};
37
38// Re-export ensemble
39pub use ensemble::{EnsembleMode, EnsembleStrategy};
40
41// Re-export prebuilt strategies
42pub use prebuilt::{
43 BollingerMeanReversion, DonchianBreakout, MacdSignal, RsiReversal, SmaCrossover,
44 SuperTrendFollow,
45};
46
47/// Price extremes reached since the open position was entered.
48///
49/// The peak since entry belongs to the position, not to any one condition, so
50/// the engine tracks it once per bar and every trailing condition reads the same
51/// value instead of each keeping its own running scan.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct PositionExtremes {
54 /// Highest bar high since entry.
55 pub high: f64,
56 /// Lowest bar low since entry.
57 pub low: f64,
58 /// Highest bar close since entry.
59 pub close_high: f64,
60 /// Lowest bar close since entry.
61 pub close_low: f64,
62}
63
64impl PositionExtremes {
65 /// Seed the extremes from the entry bar.
66 pub(crate) fn new(candle: &Candle) -> Self {
67 Self {
68 high: candle.high,
69 low: candle.low,
70 close_high: candle.close,
71 close_low: candle.close,
72 }
73 }
74
75 /// Fold another bar into the running extremes.
76 pub(crate) fn update(&mut self, candle: &Candle) {
77 self.high = self.high.max(candle.high);
78 self.low = self.low.min(candle.low);
79 self.close_high = self.close_high.max(candle.close);
80 self.close_low = self.close_low.min(candle.close);
81 }
82
83 /// Fold every candle in `range` into fresh extremes.
84 ///
85 /// Used when a context is built outside the engine's bar loop and no running
86 /// value is available.
87 pub(crate) fn from_candles(candles: &[Candle]) -> Option<Self> {
88 let (first, rest) = candles.split_first()?;
89 let mut extremes = Self::new(first);
90 for c in rest {
91 extremes.update(c);
92 }
93 Some(extremes)
94 }
95}
96
97/// Context passed to strategy on each candle.
98///
99/// Provides access to historical data, current position, and pre-computed indicators.
100#[non_exhaustive]
101pub struct StrategyContext<'a> {
102 /// All candles up to and including current
103 pub candles: &'a [Candle],
104
105 /// Current candle index (0-based)
106 pub index: usize,
107
108 /// Current position (if any)
109 pub position: Option<&'a Position>,
110
111 /// Current portfolio equity
112 pub equity: f64,
113
114 /// Pre-computed indicator values (keyed by indicator name)
115 pub indicators: &'a HashMap<String, Vec<Option<f64>>>,
116
117 /// Price extremes since the open position was entered.
118 ///
119 /// `None` when no position is open, when no condition in the strategy
120 /// reads it, or when the context was built outside the engine's bar loop —
121 /// conditions that need it fall back to scanning from the entry bar.
122 pub extremes: Option<&'a PositionExtremes>,
123
124 /// Index override for [`indicator`](Self::indicator) /
125 /// [`indicator_prev`](Self::indicator_prev) lookups; `None` means `index`.
126 ///
127 /// [`htf()`](crate::backtesting::refs::htf) keeps candles in base-bar index
128 /// space while its indicator map holds a per-bar `[prev, curr]` pair, so
129 /// indicator lookups address slot 1 while candle lookups keep `index`.
130 pub indicator_index: Option<usize>,
131}
132
133impl<'a> StrategyContext<'a> {
134 /// Get current candle
135 pub fn current_candle(&self) -> &Candle {
136 &self.candles[self.index]
137 }
138
139 /// Get previous candle (None if at start)
140 pub fn previous_candle(&self) -> Option<&Candle> {
141 if self.index > 0 {
142 Some(&self.candles[self.index - 1])
143 } else {
144 None
145 }
146 }
147
148 /// Get candle at specific index (None if out of bounds)
149 pub fn candle_at(&self, index: usize) -> Option<&Candle> {
150 self.candles.get(index)
151 }
152
153 /// Get indicator value at current index
154 pub fn indicator(&self, name: &str) -> Option<f64> {
155 self.indicators
156 .get(name)
157 .and_then(|v| v.get(self.indicator_index.unwrap_or(self.index)))
158 .and_then(|&v| v)
159 }
160
161 /// Get indicator value at specific index
162 pub fn indicator_at(&self, name: &str, index: usize) -> Option<f64> {
163 self.indicators
164 .get(name)
165 .and_then(|v| v.get(index))
166 .and_then(|&v| v)
167 }
168
169 /// Get indicator value at previous index
170 pub fn indicator_prev(&self, name: &str) -> Option<f64> {
171 let idx = self.indicator_index.unwrap_or(self.index);
172 if idx > 0 {
173 self.indicator_at(name, idx - 1)
174 } else {
175 None
176 }
177 }
178
179 /// Check if we have a position
180 pub fn has_position(&self) -> bool {
181 self.position.is_some()
182 }
183
184 /// Check if we have a long position
185 pub fn is_long(&self) -> bool {
186 self.position
187 .map(|p| matches!(p.side, PositionSide::Long))
188 .unwrap_or(false)
189 }
190
191 /// Check if we have a short position
192 pub fn is_short(&self) -> bool {
193 self.position
194 .map(|p| matches!(p.side, PositionSide::Short))
195 .unwrap_or(false)
196 }
197
198 /// Get current close price
199 pub fn close(&self) -> f64 {
200 self.current_candle().close
201 }
202
203 /// Get current open price
204 pub fn open(&self) -> f64 {
205 self.current_candle().open
206 }
207
208 /// Get current high price
209 pub fn high(&self) -> f64 {
210 self.current_candle().high
211 }
212
213 /// Get current low price
214 pub fn low(&self) -> f64 {
215 self.current_candle().low
216 }
217
218 /// Get current volume
219 pub fn volume(&self) -> i64 {
220 self.current_candle().volume
221 }
222
223 /// Get current timestamp
224 pub fn timestamp(&self) -> i64 {
225 self.current_candle().timestamp
226 }
227
228 /// Create a Long signal from the current candle's timestamp and close price.
229 pub fn signal_long(&self) -> Signal {
230 Signal::long(self.timestamp(), self.close())
231 }
232
233 /// Create a Short signal from the current candle's timestamp and close price.
234 pub fn signal_short(&self) -> Signal {
235 Signal::short(self.timestamp(), self.close())
236 }
237
238 /// Create an Exit signal from the current candle's timestamp and close price.
239 pub fn signal_exit(&self) -> Signal {
240 Signal::exit(self.timestamp(), self.close())
241 }
242
243 /// Check if crossover occurred (fast crosses above slow)
244 pub fn crossed_above(&self, fast_name: &str, slow_name: &str) -> bool {
245 let fast_now = self.indicator(fast_name);
246 let slow_now = self.indicator(slow_name);
247 let fast_prev = self.indicator_prev(fast_name);
248 let slow_prev = self.indicator_prev(slow_name);
249
250 match (fast_now, slow_now, fast_prev, slow_prev) {
251 (Some(f), Some(s), Some(fp), Some(sp)) => fp <= sp && f > s,
252 _ => false,
253 }
254 }
255
256 /// Check if crossover occurred (fast crosses below slow)
257 pub fn crossed_below(&self, fast_name: &str, slow_name: &str) -> bool {
258 let fast_now = self.indicator(fast_name);
259 let slow_now = self.indicator(slow_name);
260 let fast_prev = self.indicator_prev(fast_name);
261 let slow_prev = self.indicator_prev(slow_name);
262
263 match (fast_now, slow_now, fast_prev, slow_prev) {
264 (Some(f), Some(s), Some(fp), Some(sp)) => fp >= sp && f < s,
265 _ => false,
266 }
267 }
268
269 /// Check if indicator crossed above a threshold.
270 ///
271 /// Returns `true` when `prev <= threshold` **and** `current > threshold`.
272 /// The inclusive lower bound (`<=`) means a signal fires even when the
273 /// previous bar sat exactly on the threshold, the same inclusive-previous
274 /// convention [`crossed_above`](Self::crossed_above) uses for
275 /// indicator-vs-indicator crossings.
276 pub fn indicator_crossed_above(&self, name: &str, threshold: f64) -> bool {
277 let now = self.indicator(name);
278 let prev = self.indicator_prev(name);
279
280 match (now, prev) {
281 (Some(n), Some(p)) => p <= threshold && n > threshold,
282 _ => false,
283 }
284 }
285
286 /// Check if indicator crossed below a threshold.
287 ///
288 /// Returns `true` when `prev >= threshold` **and** `current < threshold`.
289 /// See [`indicator_crossed_above`](Self::indicator_crossed_above) for the
290 /// rationale behind the inclusive/exclusive choice on each side.
291 pub fn indicator_crossed_below(&self, name: &str, threshold: f64) -> bool {
292 let now = self.indicator(name);
293 let prev = self.indicator_prev(name);
294
295 match (now, prev) {
296 (Some(n), Some(p)) => p >= threshold && n < threshold,
297 _ => false,
298 }
299 }
300}
301
302/// Core strategy trait - implement this for custom strategies.
303///
304/// # Example
305///
306/// ```ignore
307/// use finance_query::backtesting::{Strategy, StrategyContext, Signal};
308/// use finance_query::indicators::Indicator;
309///
310/// struct MyStrategy {
311/// sma_period: usize,
312/// }
313///
314/// impl Strategy for MyStrategy {
315/// fn name(&self) -> &str {
316/// "My Custom Strategy"
317/// }
318///
319/// fn required_indicators(&self) -> Vec<(String, Indicator)> {
320/// vec![
321/// (format!("sma_{}", self.sma_period), Indicator::Sma(self.sma_period)),
322/// ]
323/// }
324///
325/// fn on_candle(&self, ctx: &StrategyContext) -> Signal {
326/// let sma = ctx.indicator(&format!("sma_{}", self.sma_period));
327/// let close = ctx.close();
328///
329/// match sma {
330/// Some(sma_val) if close > sma_val && !ctx.has_position() => {
331/// Signal::long(ctx.timestamp(), close)
332/// }
333/// Some(sma_val) if close < sma_val && ctx.is_long() => {
334/// Signal::exit(ctx.timestamp(), close)
335/// }
336/// _ => Signal::hold(),
337/// }
338/// }
339/// }
340/// ```
341pub trait Strategy: Send + Sync {
342 /// Strategy name (for reporting)
343 fn name(&self) -> &str;
344
345 /// Required indicators this strategy needs.
346 ///
347 /// Returns list of (indicator_name, Indicator) pairs.
348 /// The engine will pre-compute these and make them available via `StrategyContext::indicator()`.
349 fn required_indicators(&self) -> Vec<(String, Indicator)>;
350
351 /// Higher-timeframe indicators required by this strategy.
352 ///
353 /// The engine resamples candles to each unique interval, computes the
354 /// listed indicators on the resampled data, and stores stretched
355 /// (base-timeframe-length) arrays in `StrategyContext::indicators` under
356 /// the `htf_key` names. Strategies built with [`StrategyBuilder`] implement
357 /// this automatically; raw [`Strategy`] implementations that use HTF
358 /// conditions should override this to avoid the O(n²) dynamic fallback.
359 ///
360 /// [`StrategyBuilder`]: crate::backtesting::strategy::StrategyBuilder
361 fn htf_requirements(&self) -> Vec<HtfIndicatorSpec> {
362 vec![]
363 }
364
365 /// Called once by the engine after indicator pre-computation, before the
366 /// simulation loop. Strategies may cache references into the indicator
367 /// map here to avoid per-bar HashMap lookups. The default implementation
368 /// does nothing; pre-built strategies override this for performance.
369 fn setup(&mut self, _indicators: &HashMap<String, Vec<Option<f64>>>) {}
370
371 /// Called on each candle to generate a signal.
372 ///
373 /// Return `Signal::hold()` for no action, `Signal::long()` to enter long,
374 /// `Signal::short()` to enter short, or `Signal::exit()` to close position.
375 fn on_candle(&self, ctx: &StrategyContext) -> Signal;
376
377 /// Optional: minimum candles required before strategy can generate signals.
378 /// Default is 1 (strategy can run from first candle).
379 fn warmup_period(&self) -> usize {
380 1
381 }
382
383 /// Whether any of this strategy's conditions read
384 /// [`StrategyContext::extremes`].
385 ///
386 /// The engine folds the running peak/trough per bar only when this is
387 /// `true`, so a strategy with no trailing condition pays nothing for the
388 /// feature. Strategies built with [`StrategyBuilder`] compute this
389 /// automatically. A raw implementation that uses `TrailingStop` or
390 /// `TrailingTakeProfit` still behaves correctly without overriding it —
391 /// those conditions fall back to scanning from the entry bar — but should
392 /// override it to avoid the O(bars²) fallback.
393 ///
394 /// [`StrategyBuilder`]: crate::backtesting::strategy::StrategyBuilder
395 fn tracks_position_extremes(&self) -> bool {
396 false
397 }
398}
399
400impl Strategy for Box<dyn Strategy> {
401 fn name(&self) -> &str {
402 (**self).name()
403 }
404 fn required_indicators(&self) -> Vec<(String, Indicator)> {
405 (**self).required_indicators()
406 }
407 fn htf_requirements(&self) -> Vec<HtfIndicatorSpec> {
408 (**self).htf_requirements()
409 }
410 fn setup(&mut self, indicators: &HashMap<String, Vec<Option<f64>>>) {
411 (**self).setup(indicators)
412 }
413 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
414 (**self).on_candle(ctx)
415 }
416 fn warmup_period(&self) -> usize {
417 (**self).warmup_period()
418 }
419 fn tracks_position_extremes(&self) -> bool {
420 (**self).tracks_position_extremes()
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 struct TestStrategy;
429
430 impl Strategy for TestStrategy {
431 fn name(&self) -> &str {
432 "Test Strategy"
433 }
434
435 fn required_indicators(&self) -> Vec<(String, Indicator)> {
436 vec![("sma_10".to_string(), Indicator::Sma(10))]
437 }
438
439 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
440 if ctx.index == 5 {
441 Signal::long(ctx.timestamp(), ctx.close())
442 } else {
443 Signal::hold()
444 }
445 }
446 }
447
448 #[test]
449 fn test_strategy_trait() {
450 let strategy = TestStrategy;
451 assert_eq!(strategy.name(), "Test Strategy");
452 assert_eq!(strategy.required_indicators().len(), 1);
453 assert_eq!(strategy.warmup_period(), 1);
454 }
455
456 #[test]
457 fn test_context_crossover_detection() {
458 let candles = vec![
459 Candle {
460 timestamp: 1,
461 open: 100.0,
462 high: 101.0,
463 low: 99.0,
464 close: 100.0,
465 volume: 1000,
466 adj_close: None,
467 provider_id: None,
468 },
469 Candle {
470 timestamp: 2,
471 open: 100.0,
472 high: 102.0,
473 low: 99.0,
474 close: 101.0,
475 volume: 1000,
476 adj_close: None,
477 provider_id: None,
478 },
479 ];
480
481 let mut indicators = HashMap::new();
482 indicators.insert("fast".to_string(), vec![Some(9.0), Some(11.0)]);
483 indicators.insert("slow".to_string(), vec![Some(10.0), Some(10.0)]);
484
485 let ctx = StrategyContext {
486 candles: &candles,
487 index: 1,
488 position: None,
489 equity: 10000.0,
490 indicators: &indicators,
491 extremes: None,
492 indicator_index: None,
493 };
494
495 // fast was 9 (below slow 10), now 11 (above slow 10) -> crossed above
496 assert!(ctx.crossed_above("fast", "slow"));
497 assert!(!ctx.crossed_below("fast", "slow"));
498 }
499
500 #[test]
501 fn test_crossed_above_fires_on_touch() {
502 let candles = vec![
503 Candle {
504 timestamp: 1,
505 open: 100.0,
506 high: 101.0,
507 low: 99.0,
508 close: 100.0,
509 volume: 1000,
510 adj_close: None,
511 provider_id: None,
512 },
513 Candle {
514 timestamp: 2,
515 open: 100.0,
516 high: 102.0,
517 low: 99.0,
518 close: 101.0,
519 volume: 1000,
520 adj_close: None,
521 provider_id: None,
522 },
523 ];
524
525 let mut indicators = HashMap::new();
526 indicators.insert("fast".to_string(), vec![Some(10.0), Some(11.0)]);
527 indicators.insert("slow".to_string(), vec![Some(10.0), Some(10.0)]);
528
529 let ctx = StrategyContext {
530 candles: &candles,
531 index: 1,
532 position: None,
533 equity: 10000.0,
534 indicators: &indicators,
535 extremes: None,
536 indicator_index: None,
537 };
538
539 // fast was exactly equal to slow (10 == 10), now above (11 > 10) -> crosses above
540 assert!(ctx.crossed_above("fast", "slow"));
541 }
542}