Skip to main content

finance_query/backtesting/strategy/
builder.rs

1//! Fluent strategy builder for creating custom strategies from conditions.
2//!
3//! This module provides a builder pattern for creating custom trading strategies
4//! using entry and exit conditions.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use finance_query::backtesting::strategy::StrategyBuilder;
10//! use finance_query::backtesting::refs::*;
11//! use finance_query::backtesting::condition::*;
12//!
13//! let strategy = StrategyBuilder::new("RSI Mean Reversion")
14//!     .entry(
15//!         rsi(14).crosses_below(30.0)
16//!             .and(price().above_ref(sma(200)))
17//!     )
18//!     .exit(
19//!         rsi(14).crosses_above(70.0)
20//!             .or(stop_loss(0.05))
21//!     )
22//!     .build();
23//! ```
24
25use std::collections::HashSet;
26
27use crate::backtesting::condition::{Condition, HtfIndicatorSpec};
28use crate::backtesting::signal::Signal;
29use crate::indicators::Indicator;
30
31use super::{Strategy, StrategyContext};
32
33/// Type-erased condition wrapper for storing heterogeneous conditions.
34struct BoxedCondition {
35    evaluate_fn: Box<dyn Fn(&StrategyContext) -> bool + Send + Sync>,
36    required_indicators: Vec<(String, Indicator)>,
37    htf_requirements: Vec<HtfIndicatorSpec>,
38    tracks_position_extremes: bool,
39    description: String,
40}
41
42impl BoxedCondition {
43    fn new<C: Condition>(cond: C) -> Self {
44        let required_indicators = cond.required_indicators();
45        let htf_requirements = cond.htf_requirements();
46        let tracks_position_extremes = cond.tracks_position_extremes();
47        let description = cond.description();
48        Self {
49            evaluate_fn: Box::new(move |ctx| cond.evaluate(ctx)),
50            required_indicators,
51            htf_requirements,
52            tracks_position_extremes,
53            description,
54        }
55    }
56
57    fn evaluate(&self, ctx: &StrategyContext) -> bool {
58        (self.evaluate_fn)(ctx)
59    }
60
61    fn required_indicators(&self) -> &[(String, Indicator)] {
62        &self.required_indicators
63    }
64
65    fn htf_requirements(&self) -> &[HtfIndicatorSpec] {
66        &self.htf_requirements
67    }
68
69    fn tracks_position_extremes(&self) -> bool {
70        self.tracks_position_extremes
71    }
72
73    fn description(&self) -> &str {
74        &self.description
75    }
76}
77
78/// Builder for creating custom strategies with entry/exit conditions.
79///
80/// The builder enforces that both entry and exit conditions are provided
81/// before a strategy can be built.
82///
83/// An optional regime filter can be set at any point in the chain via
84/// [`.regime_filter()`](StrategyBuilder::regime_filter). When set, the filter
85/// is evaluated on every bar; if it returns `false`, all entry signals are
86/// suppressed. Exit signals are **never** blocked by the regime filter.
87pub struct StrategyBuilder<E = (), X = ()> {
88    name: String,
89    entry_condition: E,
90    exit_condition: X,
91    short_entry_condition: Option<BoxedCondition>,
92    short_exit_condition: Option<BoxedCondition>,
93    regime_filter: Option<BoxedCondition>,
94    warmup_override: Option<usize>,
95}
96
97impl StrategyBuilder<(), ()> {
98    /// Create a new strategy builder with a name.
99    ///
100    /// # Example
101    ///
102    /// ```ignore
103    /// let builder = StrategyBuilder::new("My Strategy");
104    /// ```
105    pub fn new(name: impl Into<String>) -> Self {
106        Self {
107            name: name.into(),
108            entry_condition: (),
109            exit_condition: (),
110            short_entry_condition: None,
111            short_exit_condition: None,
112            regime_filter: None,
113            warmup_override: None,
114        }
115    }
116}
117
118impl<X> StrategyBuilder<(), X> {
119    /// Set the entry condition for long positions.
120    ///
121    /// # Example
122    ///
123    /// ```ignore
124    /// let builder = StrategyBuilder::new("RSI Strategy")
125    ///     .entry(rsi(14).crosses_below(30.0));
126    /// ```
127    pub fn entry<C: Condition>(self, condition: C) -> StrategyBuilder<C, X> {
128        StrategyBuilder {
129            name: self.name,
130            entry_condition: condition,
131            exit_condition: self.exit_condition,
132            short_entry_condition: self.short_entry_condition,
133            short_exit_condition: self.short_exit_condition,
134            regime_filter: self.regime_filter,
135            warmup_override: self.warmup_override,
136        }
137    }
138}
139
140impl<E> StrategyBuilder<E, ()> {
141    /// Set the exit condition for long positions.
142    ///
143    /// # Example
144    ///
145    /// ```ignore
146    /// let builder = StrategyBuilder::new("RSI Strategy")
147    ///     .entry(rsi(14).crosses_below(30.0))
148    ///     .exit(rsi(14).crosses_above(70.0));
149    /// ```
150    pub fn exit<C: Condition>(self, condition: C) -> StrategyBuilder<E, C> {
151        StrategyBuilder {
152            name: self.name,
153            entry_condition: self.entry_condition,
154            exit_condition: condition,
155            short_entry_condition: self.short_entry_condition,
156            short_exit_condition: self.short_exit_condition,
157            regime_filter: self.regime_filter,
158            warmup_override: self.warmup_override,
159        }
160    }
161}
162
163impl<E, X> StrategyBuilder<E, X> {
164    /// Set a market regime filter.
165    ///
166    /// When set, entry signals (long and short) are suppressed on any bar
167    /// where the filter evaluates to `false`. Exit signals are **never**
168    /// blocked by the regime filter, ensuring open positions can always be
169    /// closed regardless of market conditions.
170    ///
171    /// The regime filter's indicators are included in `required_indicators()`
172    /// and therefore pre-computed by the engine like any other indicator.
173    ///
174    /// # Example
175    ///
176    /// ```rust,no_run
177    /// use finance_query::backtesting::strategy::StrategyBuilder;
178    /// use finance_query::backtesting::refs::*;
179    ///
180    /// // Only trade when price is above the 200-period SMA
181    /// let strategy = StrategyBuilder::new("Trend Following")
182    ///     .regime_filter(sma(200).above_ref(sma(400)))
183    ///     .entry(ema(10).crosses_above_ref(ema(30)))
184    ///     .exit(ema(10).crosses_below_ref(ema(30)))
185    ///     .build();
186    /// ```
187    pub fn regime_filter<C: Condition>(mut self, condition: C) -> Self {
188        self.regime_filter = Some(BoxedCondition::new(condition));
189        self
190    }
191}
192
193impl<E: Condition, X: Condition> StrategyBuilder<E, X> {
194    /// Enable short positions with entry and exit conditions.
195    ///
196    /// # Example
197    ///
198    /// ```ignore
199    /// let strategy = StrategyBuilder::new("RSI Strategy")
200    ///     .entry(rsi(14).crosses_below(30.0))
201    ///     .exit(rsi(14).crosses_above(70.0))
202    ///     .with_short(
203    ///         rsi(14).crosses_above(70.0),  // Short entry
204    ///         rsi(14).crosses_below(30.0),  // Short exit
205    ///     )
206    ///     .build();
207    /// ```
208    pub fn with_short<SE: Condition, SX: Condition>(mut self, entry: SE, exit: SX) -> Self {
209        self.short_entry_condition = Some(BoxedCondition::new(entry));
210        self.short_exit_condition = Some(BoxedCondition::new(exit));
211        self
212    }
213
214    /// Override the automatic warmup period with an explicit bar count.
215    ///
216    /// By default the warmup period is inferred from each indicator's
217    /// [`Indicator::warmup_bars()`] method. Use this override when the
218    /// automatic value doesn't match your specific needs.
219    ///
220    /// # Example
221    ///
222    /// ```ignore
223    /// let strategy = StrategyBuilder::new("MACD + RSI")
224    ///     .entry(macd(12, 26, 9).crosses_above_zero())
225    ///     .exit(rsi(14).crosses_above(70.0))
226    ///     .warmup(36) // explicit override
227    ///     .build();
228    /// ```
229    pub fn warmup(mut self, bars: usize) -> Self {
230        self.warmup_override = Some(bars);
231        self
232    }
233
234    /// Build the strategy.
235    ///
236    /// # Example
237    ///
238    /// ```ignore
239    /// let strategy = StrategyBuilder::new("My Strategy")
240    ///     .entry(rsi(14).crosses_below(30.0))
241    ///     .exit(rsi(14).crosses_above(70.0))
242    ///     .build();
243    /// ```
244    pub fn build(self) -> CustomStrategy<E, X> {
245        CustomStrategy {
246            name: self.name,
247            entry_condition: self.entry_condition,
248            exit_condition: self.exit_condition,
249            short_entry_condition: self.short_entry_condition,
250            short_exit_condition: self.short_exit_condition,
251            regime_filter: self.regime_filter,
252            warmup_override: self.warmup_override,
253        }
254    }
255}
256
257/// A custom strategy built from conditions.
258///
259/// This strategy evaluates entry and exit conditions on each candle
260/// and generates appropriate signals.
261pub struct CustomStrategy<E: Condition, X: Condition> {
262    name: String,
263    entry_condition: E,
264    exit_condition: X,
265    short_entry_condition: Option<BoxedCondition>,
266    short_exit_condition: Option<BoxedCondition>,
267    /// Optional market regime filter.
268    ///
269    /// When `Some`, entry signals are suppressed on bars where the filter
270    /// evaluates to `false`. Exit signals are unaffected.
271    regime_filter: Option<BoxedCondition>,
272    /// Explicit warmup period set via [`StrategyBuilder::warmup`].
273    ///
274    /// Overrides the heuristic in [`warmup_period`] when set.
275    warmup_override: Option<usize>,
276}
277
278impl<E: Condition, X: Condition> Strategy for CustomStrategy<E, X> {
279    fn name(&self) -> &str {
280        &self.name
281    }
282
283    fn required_indicators(&self) -> Vec<(String, Indicator)> {
284        let mut indicators = self.entry_condition.required_indicators();
285        indicators.extend(self.exit_condition.required_indicators());
286
287        if let Some(ref se) = self.short_entry_condition {
288            indicators.extend(se.required_indicators().iter().cloned());
289        }
290        if let Some(ref sx) = self.short_exit_condition {
291            indicators.extend(sx.required_indicators().iter().cloned());
292        }
293        if let Some(ref rf) = self.regime_filter {
294            indicators.extend(rf.required_indicators().iter().cloned());
295        }
296
297        // Deduplicate by (key, Indicator) pair: same-family strategies with
298        // different params share a key but must both survive.
299        let mut seen: Vec<(String, Indicator)> = Vec::new();
300        indicators.retain(|item| {
301            let is_new = !seen.contains(item);
302            if is_new {
303                seen.push(item.clone());
304            }
305            is_new
306        });
307
308        indicators
309    }
310
311    fn htf_requirements(&self) -> Vec<HtfIndicatorSpec> {
312        let mut reqs = self.entry_condition.htf_requirements();
313        reqs.extend(self.exit_condition.htf_requirements());
314
315        if let Some(ref se) = self.short_entry_condition {
316            reqs.extend(se.htf_requirements().iter().cloned());
317        }
318        if let Some(ref sx) = self.short_exit_condition {
319            reqs.extend(sx.htf_requirements().iter().cloned());
320        }
321        if let Some(ref rf) = self.regime_filter {
322            reqs.extend(rf.htf_requirements().iter().cloned());
323        }
324
325        // Deduplicate by htf_key — same stretched array cannot be stored twice
326        let mut seen = HashSet::new();
327        reqs.retain(|spec| seen.insert(spec.htf_key.clone()));
328        reqs
329    }
330
331    fn tracks_position_extremes(&self) -> bool {
332        self.entry_condition.tracks_position_extremes()
333            || self.exit_condition.tracks_position_extremes()
334            || self
335                .short_entry_condition
336                .as_ref()
337                .is_some_and(|c| c.tracks_position_extremes())
338            || self
339                .short_exit_condition
340                .as_ref()
341                .is_some_and(|c| c.tracks_position_extremes())
342            || self
343                .regime_filter
344                .as_ref()
345                .is_some_and(|c| c.tracks_position_extremes())
346    }
347
348    fn warmup_period(&self) -> usize {
349        // Explicit override wins — use it directly.
350        if let Some(n) = self.warmup_override {
351            return n;
352        }
353
354        // Use each indicator's own warmup calculation instead of parsing
355        // key suffixes (which fails for compound indicators like MACD and
356        // Bollinger).  `.warmup(n)` on the builder still overrides this.
357        let max_warmup = self
358            .required_indicators()
359            .iter()
360            .map(|(_, indicator)| indicator.warmup_bars())
361            .max()
362            .unwrap_or(1);
363
364        max_warmup + 1
365    }
366
367    fn on_candle(&self, ctx: &StrategyContext) -> Signal {
368        let candle = ctx.current_candle();
369
370        // Check exit conditions first (for existing positions)
371        if ctx.is_long() && self.exit_condition.evaluate(ctx) {
372            return Signal::exit(candle.timestamp, candle.close)
373                .with_reason(self.exit_condition.description());
374        }
375
376        if ctx.is_short()
377            && let Some(ref exit) = self.short_exit_condition
378            && exit.evaluate(ctx)
379        {
380            return Signal::exit(candle.timestamp, candle.close)
381                .with_reason(exit.description().to_string());
382        }
383
384        // Check entry conditions (when no position)
385        if !ctx.has_position() {
386            // Regime filter gates all entries; exits are never suppressed.
387            let regime_ok = self
388                .regime_filter
389                .as_ref()
390                .is_none_or(|rf| rf.evaluate(ctx));
391
392            if regime_ok {
393                // Long entry
394                if self.entry_condition.evaluate(ctx) {
395                    return Signal::long(candle.timestamp, candle.close)
396                        .with_reason(self.entry_condition.description());
397                }
398
399                // Short entry
400                if let Some(ref entry) = self.short_entry_condition
401                    && entry.evaluate(ctx)
402                {
403                    return Signal::short(candle.timestamp, candle.close)
404                        .with_reason(entry.description().to_string());
405                }
406            }
407        }
408
409        Signal::hold()
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use std::collections::HashMap;
416
417    use super::*;
418    use crate::backtesting::condition::{always_false, always_true};
419    use crate::backtesting::signal::SignalDirection;
420    use crate::models::chart::Candle;
421
422    fn make_candle(ts: i64, close: f64) -> Candle {
423        Candle {
424            timestamp: ts,
425            open: close,
426            high: close,
427            low: close,
428            close,
429            volume: 1000,
430            adj_close: None,
431            provider_id: None,
432        }
433    }
434
435    fn make_ctx<'a>(
436        candles: &'a [Candle],
437        indicators: &'a HashMap<String, Vec<Option<f64>>>,
438    ) -> StrategyContext<'a> {
439        StrategyContext {
440            candles,
441            index: 0,
442            position: None,
443            equity: 10_000.0,
444            indicators,
445            extremes: None,
446            indicator_index: None,
447        }
448    }
449
450    #[test]
451    fn test_strategy_builder() {
452        let strategy = StrategyBuilder::new("Test Strategy")
453            .entry(always_true())
454            .exit(always_false())
455            .build();
456
457        assert_eq!(strategy.name(), "Test Strategy");
458    }
459
460    #[test]
461    fn test_strategy_builder_with_short() {
462        let strategy = StrategyBuilder::new("Test Strategy")
463            .entry(always_true())
464            .exit(always_false())
465            .with_short(always_false(), always_true())
466            .build();
467
468        assert_eq!(strategy.name(), "Test Strategy");
469        assert!(strategy.short_entry_condition.is_some());
470        assert!(strategy.short_exit_condition.is_some());
471    }
472
473    #[test]
474    fn test_required_indicators_deduplication() {
475        use crate::backtesting::condition::Above;
476        use crate::backtesting::refs::rsi;
477
478        // Create two conditions using the same indicator
479        let entry = Above::new(rsi(14), 70.0);
480        let exit = Above::new(rsi(14), 30.0);
481
482        let strategy = StrategyBuilder::new("Test").entry(entry).exit(exit).build();
483
484        let indicators = strategy.required_indicators();
485        // Should be deduplicated to just one rsi_14
486        assert_eq!(indicators.len(), 1);
487        assert_eq!(indicators[0].0, "rsi_14");
488    }
489
490    // ── Regime filter tests ────────────────────────────────────────────
491
492    #[test]
493    fn test_regime_filter_suppresses_entry_when_false() {
494        let strategy = StrategyBuilder::new("Regime Test")
495            .regime_filter(always_false()) // regime is never active
496            .entry(always_true())
497            .exit(always_false())
498            .build();
499
500        let candles = vec![make_candle(1, 100.0)];
501        let indicators = HashMap::new();
502        let ctx = make_ctx(&candles, &indicators);
503
504        // Entry should be blocked by the regime filter
505        assert_eq!(strategy.on_candle(&ctx).direction, SignalDirection::Hold);
506    }
507
508    #[test]
509    fn test_regime_filter_allows_entry_when_true() {
510        let strategy = StrategyBuilder::new("Regime Test")
511            .regime_filter(always_true()) // regime always active
512            .entry(always_true())
513            .exit(always_false())
514            .build();
515
516        let candles = vec![make_candle(1, 100.0)];
517        let indicators = HashMap::new();
518        let ctx = make_ctx(&candles, &indicators);
519
520        assert_eq!(strategy.on_candle(&ctx).direction, SignalDirection::Long);
521    }
522
523    #[test]
524    fn test_no_regime_filter_behaves_normally() {
525        let strategy = StrategyBuilder::new("No Regime")
526            .entry(always_true())
527            .exit(always_false())
528            .build();
529
530        let candles = vec![make_candle(1, 100.0)];
531        let indicators = HashMap::new();
532        let ctx = make_ctx(&candles, &indicators);
533
534        assert_eq!(strategy.on_candle(&ctx).direction, SignalDirection::Long);
535    }
536
537    #[test]
538    fn test_regime_filter_does_not_block_exit() {
539        use crate::backtesting::position::{Position, PositionSide};
540
541        let strategy = StrategyBuilder::new("Regime Exit Test")
542            .regime_filter(always_false()) // regime is off
543            .entry(always_false())
544            .exit(always_true()) // exit condition always fires
545            .build();
546
547        let candles = vec![make_candle(1, 100.0)];
548        let indicators = HashMap::new();
549
550        // Simulate an open long position using the public constructor
551        let position = Position::new(
552            PositionSide::Long,
553            1,
554            90.0,
555            10.0,
556            0.0,
557            Signal::long(1, 90.0),
558        );
559
560        let ctx = StrategyContext {
561            candles: &candles,
562            index: 0,
563            position: Some(&position),
564            equity: 10_000.0,
565            indicators: &indicators,
566            extremes: None,
567            indicator_index: None,
568        };
569
570        // Exit must fire even though regime filter is false
571        assert_eq!(strategy.on_candle(&ctx).direction, SignalDirection::Exit);
572    }
573
574    #[test]
575    fn test_regime_filter_indicators_included_in_required() {
576        use crate::backtesting::refs::{IndicatorRefExt, sma};
577        use crate::indicators::Indicator;
578
579        let strategy = StrategyBuilder::new("Regime Indicators")
580            .regime_filter(sma(200).above_ref(sma(400)))
581            .entry(always_true())
582            .exit(always_false())
583            .build();
584
585        let indicators = strategy.required_indicators();
586        let keys: Vec<&str> = indicators.iter().map(|(k, _)| k.as_str()).collect();
587
588        assert!(
589            keys.contains(&"sma_200"),
590            "sma_200 must be in required_indicators"
591        );
592        assert!(
593            keys.contains(&"sma_400"),
594            "sma_400 must be in required_indicators"
595        );
596
597        // Verify correct Indicator variants
598        let sma_200 = indicators.iter().find(|(k, _)| k == "sma_200").unwrap();
599        assert!(matches!(sma_200.1, Indicator::Sma(200)));
600    }
601
602    #[test]
603    fn test_regime_filter_callable_before_entry() {
604        // Verify the builder chain compiles when regime_filter is called first
605        let strategy = StrategyBuilder::new("Order Test")
606            .regime_filter(always_true())
607            .entry(always_true())
608            .exit(always_false())
609            .build();
610
611        assert!(strategy.regime_filter.is_some());
612    }
613
614    #[test]
615    fn test_regime_filter_warmup_accounts_for_filter_indicators() {
616        use crate::backtesting::refs::{IndicatorRefExt, sma};
617
618        let strategy = StrategyBuilder::new("Warmup Test")
619            .regime_filter(sma(400).above_ref(sma(200)))
620            .entry(always_true())
621            .exit(always_false())
622            .build();
623
624        // Warmup must be at least sma(400).warmup_bars() + 1 = 401
625        assert!(
626            strategy.warmup_period() >= 401,
627            "warmup_period must account for sma(400): got {}",
628            strategy.warmup_period()
629        );
630    }
631}