Skip to main content

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}