Skip to main content

finance_query/backtesting/strategy/
prebuilt.rs

1//! Pre-built trading strategies.
2//!
3//! Ready-to-use strategy implementations that can be used directly with the backtest engine.
4//! Each strategy implements the [`Strategy`] trait and can be customized via builder methods.
5//!
6//! Short signals are always emitted when the condition is met. Whether they are
7//! *executed* is controlled solely by [`BacktestConfig::allow_short`](crate::backtesting::BacktestConfig).
8//!
9//! # Available Strategies
10//!
11//! | Strategy | Description |
12//! |----------|-------------|
13//! | [`SmaCrossover`] | Dual SMA crossover (trend following) |
14//! | [`RsiReversal`] | RSI mean reversion |
15//! | [`MacdSignal`] | MACD line crossover |
16//! | [`BollingerMeanReversion`] | Bollinger Bands mean reversion |
17//! | [`SuperTrendFollow`] | SuperTrend trend following |
18//! | [`DonchianBreakout`] | Donchian channel breakout |
19//!
20//! # Example
21//!
22//! ```ignore
23//! use finance_query::backtesting::{SmaCrossover, BacktestConfig};
24//!
25//! let strategy = SmaCrossover::new(10, 20);
26//! let config = BacktestConfig::builder().allow_short(true).build().unwrap();
27//! ```
28
29use std::collections::HashMap;
30
31use crate::indicators::Indicator;
32
33use super::{Signal, Strategy, StrategyContext};
34use crate::backtesting::signal::SignalStrength;
35
36// ── Per-key pointer cache ─────────────────────────────────────────────────────
37
38/// One-slot pointer cache for a pre-computed indicator `Vec`.
39///
40/// Set once by [`Strategy::setup`] before the simulation loop; dereferenced on
41/// every bar in [`Strategy::on_candle`].  Clones as `None` so a cloned strategy
42/// is safe to pass to a fresh `BacktestEngine::run` call (the engine will call
43/// `setup` again).
44///
45/// # Safety invariant
46/// The pointer is valid for the duration of the enclosing `simulate()` call: it
47/// is taken from the `indicators` HashMap which is owned by that frame, never
48/// mutated during the loop, and outlives all `on_candle` calls.
49#[derive(Debug, Default)]
50struct IndicatorSlot(Option<*const Vec<Option<f64>>>);
51
52// SAFETY: The pointer is only read inside the engine's simulation loop (single-
53// threaded).  We never send it across threads while it could be dangling.
54unsafe impl Send for IndicatorSlot {}
55unsafe impl Sync for IndicatorSlot {}
56
57impl Clone for IndicatorSlot {
58    /// Returns an empty slot — the clone must go through `setup()` before use.
59    fn clone(&self) -> Self {
60        IndicatorSlot(None)
61    }
62}
63
64impl IndicatorSlot {
65    fn set(&mut self, v: &Vec<Option<f64>>) {
66        self.0 = Some(v as *const _);
67    }
68
69    /// Returns the cached slice, if set.
70    ///
71    /// # Safety
72    /// Must only be called during a simulation loop whose `setup()` populated
73    /// this slot from a HashMap that is still alive and unmodified.
74    #[inline]
75    unsafe fn get(&self) -> Option<&Vec<Option<f64>>> {
76        self.0.map(|p| unsafe { &*p })
77    }
78}
79
80/// SMA Crossover Strategy
81///
82/// Goes long when fast SMA crosses above slow SMA.
83/// Exits when fast SMA crosses below slow SMA.
84/// Emits short signals on bearish crossovers (execution gated by
85/// [`BacktestConfig::allow_short`](crate::backtesting::BacktestConfig)).
86#[derive(Debug, Clone)]
87pub struct SmaCrossover {
88    /// Fast SMA period
89    pub fast_period: usize,
90    /// Slow SMA period
91    pub slow_period: usize,
92    fast_key: String,
93    slow_key: String,
94    fast_slot: IndicatorSlot,
95    slow_slot: IndicatorSlot,
96}
97
98impl SmaCrossover {
99    /// Create a new SMA crossover strategy
100    pub fn new(fast_period: usize, slow_period: usize) -> Self {
101        Self {
102            fast_period,
103            slow_period,
104            fast_key: format!("sma_{fast_period}"),
105            slow_key: format!("sma_{slow_period}"),
106            fast_slot: IndicatorSlot::default(),
107            slow_slot: IndicatorSlot::default(),
108        }
109    }
110}
111
112impl Default for SmaCrossover {
113    fn default() -> Self {
114        Self::new(10, 20)
115    }
116}
117
118impl Strategy for SmaCrossover {
119    fn name(&self) -> &str {
120        "SMA Crossover"
121    }
122
123    fn required_indicators(&self) -> Vec<(String, Indicator)> {
124        vec![
125            (self.fast_key.clone(), Indicator::Sma(self.fast_period)),
126            (self.slow_key.clone(), Indicator::Sma(self.slow_period)),
127        ]
128    }
129
130    fn setup(&mut self, indicators: &HashMap<String, Vec<Option<f64>>>) {
131        if let Some(v) = indicators.get(&self.fast_key) {
132            self.fast_slot.set(v);
133        }
134        if let Some(v) = indicators.get(&self.slow_key) {
135            self.slow_slot.set(v);
136        }
137    }
138
139    fn warmup_period(&self) -> usize {
140        self.slow_period.max(self.fast_period) + 1
141    }
142
143    fn on_candle(&self, ctx: &StrategyContext) -> Signal {
144        let candle = ctx.current_candle();
145        let i = ctx.index;
146        if i == 0 {
147            return Signal::hold();
148        }
149
150        // Use cached pointer (0 HashMap lookups); fall back to map lookup if
151        // setup() was not called (e.g., strategy used outside the engine).
152        // SAFETY: setup() was called from simulate() with the indicators map
153        // that is alive and unmodified for the duration of the loop.
154        let fast_vals =
155            unsafe { self.fast_slot.get() }.or_else(|| ctx.indicators.get(&self.fast_key));
156        let slow_vals =
157            unsafe { self.slow_slot.get() }.or_else(|| ctx.indicators.get(&self.slow_key));
158        let (Some(fast_vals), Some(slow_vals)) = (fast_vals, slow_vals) else {
159            return Signal::hold();
160        };
161
162        let get = |vals: &Vec<Option<f64>>, idx: usize| vals.get(idx).and_then(|&v| v);
163        let (Some(fn_), Some(sn), Some(fp), Some(sp)) = (
164            get(fast_vals, i),
165            get(slow_vals, i),
166            get(fast_vals, i - 1),
167            get(slow_vals, i - 1),
168        ) else {
169            return Signal::hold();
170        };
171
172        // Bullish crossover: fast crosses above slow
173        if fp <= sp && fn_ > sn {
174            if ctx.is_short() {
175                return Signal::exit(candle.timestamp, candle.close)
176                    .with_reason("SMA bullish crossover - close short");
177            }
178            if !ctx.has_position() {
179                return Signal::long(candle.timestamp, candle.close)
180                    .with_reason("SMA bullish crossover");
181            }
182        }
183
184        // Bearish crossover: fast crosses below slow
185        if fp >= sp && fn_ < sn {
186            if ctx.is_long() {
187                return Signal::exit(candle.timestamp, candle.close)
188                    .with_reason("SMA bearish crossover - close long");
189            }
190            if !ctx.has_position() {
191                return Signal::short(candle.timestamp, candle.close)
192                    .with_reason("SMA bearish crossover");
193            }
194        }
195
196        Signal::hold()
197    }
198}
199
200/// RSI Reversal Strategy
201///
202/// Goes long when RSI crosses above oversold level.
203/// Exits the long when RSI crosses back below the overbought level.
204/// Emits short signals when RSI crosses below overbought (execution gated by
205/// [`BacktestConfig::allow_short`](crate::backtesting::BacktestConfig)).
206#[derive(Debug, Clone)]
207pub struct RsiReversal {
208    /// RSI period
209    pub period: usize,
210    /// Oversold threshold (default 30)
211    pub oversold: f64,
212    /// Overbought threshold (default 70)
213    pub overbought: f64,
214    rsi_key: String,
215    rsi_slot: IndicatorSlot,
216}
217
218impl RsiReversal {
219    /// Create a new RSI reversal strategy
220    pub fn new(period: usize) -> Self {
221        Self {
222            period,
223            oversold: 30.0,
224            overbought: 70.0,
225            rsi_key: format!("rsi_{period}"),
226            rsi_slot: IndicatorSlot::default(),
227        }
228    }
229
230    /// Set custom oversold/overbought thresholds
231    pub fn with_thresholds(mut self, oversold: f64, overbought: f64) -> Self {
232        self.oversold = oversold;
233        self.overbought = overbought;
234        self
235    }
236}
237
238impl Default for RsiReversal {
239    fn default() -> Self {
240        Self::new(14)
241    }
242}
243
244impl Strategy for RsiReversal {
245    fn name(&self) -> &str {
246        "RSI Reversal"
247    }
248
249    fn required_indicators(&self) -> Vec<(String, Indicator)> {
250        vec![(self.rsi_key.clone(), Indicator::Rsi(self.period))]
251    }
252
253    fn setup(&mut self, indicators: &HashMap<String, Vec<Option<f64>>>) {
254        if let Some(v) = indicators.get(&self.rsi_key) {
255            self.rsi_slot.set(v);
256        }
257    }
258
259    fn warmup_period(&self) -> usize {
260        self.period + 1
261    }
262
263    fn on_candle(&self, ctx: &StrategyContext) -> Signal {
264        let candle = ctx.current_candle();
265        let i = ctx.index;
266
267        // SAFETY: see SmaCrossover::on_candle.
268        let rsi_vals = unsafe { self.rsi_slot.get() }.or_else(|| ctx.indicators.get(&self.rsi_key));
269        let Some(rsi_vals) = rsi_vals else {
270            return Signal::hold();
271        };
272        let get = |idx: usize| rsi_vals.get(idx).and_then(|&v| v);
273        let Some(rsi_val) = get(i) else {
274            return Signal::hold();
275        };
276        let rsi_prev = if i > 0 { get(i - 1) } else { None };
277
278        // Bullish: RSI crosses above oversold
279        if let Some(prev) = rsi_prev
280            && prev <= self.oversold
281            && rsi_val > self.oversold
282        {
283            // Graded on the pre-cross value: the deeper RSI dipped before
284            // reversing, the stronger the signal.
285            let strength = if prev < 20.0 {
286                SignalStrength::strong()
287            } else if prev < 25.0 {
288                SignalStrength::medium()
289            } else {
290                SignalStrength::weak()
291            };
292            if ctx.is_short() {
293                return Signal::exit(candle.timestamp, candle.close)
294                    .with_strength(strength)
295                    .with_reason(format!(
296                        "RSI crossed above {:.0} - close short",
297                        self.oversold
298                    ));
299            }
300            if !ctx.has_position() {
301                return Signal::long(candle.timestamp, candle.close)
302                    .with_strength(strength)
303                    .with_reason(format!("RSI crossed above {:.0}", self.oversold));
304            }
305        }
306
307        // Bearish: RSI crosses below overbought
308        if let Some(prev) = rsi_prev
309            && prev >= self.overbought
310            && rsi_val < self.overbought
311        {
312            let strength = if prev > 80.0 {
313                SignalStrength::strong()
314            } else if prev > 75.0 {
315                SignalStrength::medium()
316            } else {
317                SignalStrength::weak()
318            };
319            if ctx.is_long() {
320                return Signal::exit(candle.timestamp, candle.close)
321                    .with_strength(strength)
322                    .with_reason(format!(
323                        "RSI crossed below {:.0} - close long",
324                        self.overbought
325                    ));
326            }
327            if !ctx.has_position() {
328                return Signal::short(candle.timestamp, candle.close)
329                    .with_strength(strength)
330                    .with_reason(format!("RSI crossed below {:.0}", self.overbought));
331            }
332        }
333
334        Signal::hold()
335    }
336}
337
338/// MACD Signal Strategy
339///
340/// Goes long when MACD line crosses above signal line.
341/// Exits when MACD line crosses below signal line.
342/// Emits short signals on bearish crossovers (execution gated by
343/// [`BacktestConfig::allow_short`](crate::backtesting::BacktestConfig)).
344#[derive(Debug, Clone)]
345pub struct MacdSignal {
346    /// Fast EMA period
347    pub fast: usize,
348    /// Slow EMA period
349    pub slow: usize,
350    /// Signal line period
351    pub signal: usize,
352    line_key: String,
353    sig_key: String,
354    line_slot: IndicatorSlot,
355    sig_slot: IndicatorSlot,
356}
357
358impl MacdSignal {
359    /// Create a new MACD signal strategy
360    pub fn new(fast: usize, slow: usize, signal: usize) -> Self {
361        Self {
362            fast,
363            slow,
364            signal,
365            line_key: format!("macd_line_{fast}_{slow}_{signal}"),
366            sig_key: format!("macd_signal_{fast}_{slow}_{signal}"),
367            line_slot: IndicatorSlot::default(),
368            sig_slot: IndicatorSlot::default(),
369        }
370    }
371}
372
373impl Default for MacdSignal {
374    fn default() -> Self {
375        Self::new(12, 26, 9)
376    }
377}
378
379impl Strategy for MacdSignal {
380    fn name(&self) -> &str {
381        "MACD Signal"
382    }
383
384    fn required_indicators(&self) -> Vec<(String, Indicator)> {
385        vec![(
386            "macd".to_string(),
387            Indicator::Macd {
388                fast: self.fast,
389                slow: self.slow,
390                signal: self.signal,
391            },
392        )]
393    }
394
395    fn setup(&mut self, indicators: &HashMap<String, Vec<Option<f64>>>) {
396        if let Some(v) = indicators.get(&self.line_key) {
397            self.line_slot.set(v);
398        }
399        if let Some(v) = indicators.get(&self.sig_key) {
400            self.sig_slot.set(v);
401        }
402    }
403
404    fn warmup_period(&self) -> usize {
405        self.slow + self.signal
406    }
407
408    fn on_candle(&self, ctx: &StrategyContext) -> Signal {
409        let candle = ctx.current_candle();
410        let i = ctx.index;
411        if i == 0 {
412            return Signal::hold();
413        }
414
415        // SAFETY: see SmaCrossover::on_candle.
416        let line_vals =
417            unsafe { self.line_slot.get() }.or_else(|| ctx.indicators.get(&self.line_key));
418        let sig_vals = unsafe { self.sig_slot.get() }.or_else(|| ctx.indicators.get(&self.sig_key));
419        let (Some(line_vals), Some(sig_vals)) = (line_vals, sig_vals) else {
420            return Signal::hold();
421        };
422
423        let get = |vals: &Vec<Option<f64>>, idx: usize| vals.get(idx).and_then(|&v| v);
424        let (Some(ln), Some(sn), Some(lp), Some(sp)) = (
425            get(line_vals, i),
426            get(sig_vals, i),
427            get(line_vals, i - 1),
428            get(sig_vals, i - 1),
429        ) else {
430            return Signal::hold();
431        };
432
433        // MACD line and signal line are stored separately by the engine
434        // Bullish crossover
435        if lp <= sp && ln > sn {
436            if ctx.is_short() {
437                return Signal::exit(candle.timestamp, candle.close)
438                    .with_reason("MACD bullish crossover - close short");
439            }
440            if !ctx.has_position() {
441                return Signal::long(candle.timestamp, candle.close)
442                    .with_reason("MACD bullish crossover");
443            }
444        }
445
446        // Bearish crossover
447        if lp >= sp && ln < sn {
448            if ctx.is_long() {
449                return Signal::exit(candle.timestamp, candle.close)
450                    .with_reason("MACD bearish crossover - close long");
451            }
452            if !ctx.has_position() {
453                return Signal::short(candle.timestamp, candle.close)
454                    .with_reason("MACD bearish crossover");
455            }
456        }
457
458        Signal::hold()
459    }
460}
461
462/// Bollinger Bands Mean Reversion Strategy
463///
464/// Goes long when price touches lower band (oversold).
465/// Exits when price reaches middle or upper band.
466/// Emits short signals when price touches upper band (execution gated by
467/// [`BacktestConfig::allow_short`](crate::backtesting::BacktestConfig)).
468///
469/// # Signal Strength
470///
471/// All entry signals emit at default strength (`1.0`). Strength is **not** scaled
472/// by how far price has penetrated through the band. This differs from
473/// [`RsiReversal`], which grades strength by RSI extremity. If you are relying
474/// on [`BacktestConfig::min_signal_strength`](crate::backtesting::BacktestConfig::min_signal_strength) to filter signals in a portfolio
475/// context, all Bollinger entries will pass the threshold equally.
476#[derive(Debug, Clone)]
477pub struct BollingerMeanReversion {
478    /// SMA period for middle band
479    pub period: usize,
480    /// Standard deviation multiplier
481    pub std_dev: f64,
482    /// Exit at middle band (true) or upper/lower band (false)
483    pub exit_at_middle: bool,
484    lower_key: String,
485    middle_key: String,
486    upper_key: String,
487    lower_slot: IndicatorSlot,
488    middle_slot: IndicatorSlot,
489    upper_slot: IndicatorSlot,
490}
491
492impl BollingerMeanReversion {
493    /// Create a new Bollinger mean reversion strategy
494    pub fn new(period: usize, std_dev: f64) -> Self {
495        Self {
496            period,
497            std_dev,
498            exit_at_middle: true,
499            lower_key: format!("bollinger_lower_{period}_{std_dev}"),
500            middle_key: format!("bollinger_middle_{period}_{std_dev}"),
501            upper_key: format!("bollinger_upper_{period}_{std_dev}"),
502            lower_slot: IndicatorSlot::default(),
503            middle_slot: IndicatorSlot::default(),
504            upper_slot: IndicatorSlot::default(),
505        }
506    }
507
508    /// Set exit target (middle band or opposite band)
509    pub fn exit_at_middle(mut self, at_middle: bool) -> Self {
510        self.exit_at_middle = at_middle;
511        self
512    }
513}
514
515impl Default for BollingerMeanReversion {
516    fn default() -> Self {
517        Self::new(20, 2.0)
518    }
519}
520
521impl Strategy for BollingerMeanReversion {
522    fn name(&self) -> &str {
523        "Bollinger Mean Reversion"
524    }
525
526    fn required_indicators(&self) -> Vec<(String, Indicator)> {
527        vec![(
528            "bollinger".to_string(),
529            Indicator::Bollinger {
530                period: self.period,
531                std_dev: self.std_dev,
532            },
533        )]
534    }
535
536    fn setup(&mut self, indicators: &HashMap<String, Vec<Option<f64>>>) {
537        if let Some(v) = indicators.get(&self.lower_key) {
538            self.lower_slot.set(v);
539        }
540        if let Some(v) = indicators.get(&self.middle_key) {
541            self.middle_slot.set(v);
542        }
543        if let Some(v) = indicators.get(&self.upper_key) {
544            self.upper_slot.set(v);
545        }
546    }
547
548    fn warmup_period(&self) -> usize {
549        self.period
550    }
551
552    fn on_candle(&self, ctx: &StrategyContext) -> Signal {
553        let candle = ctx.current_candle();
554        let close = candle.close;
555        let i = ctx.index;
556
557        // SAFETY: see SmaCrossover::on_candle.
558        let lower_vals =
559            unsafe { self.lower_slot.get() }.or_else(|| ctx.indicators.get(&self.lower_key));
560        let middle_vals =
561            unsafe { self.middle_slot.get() }.or_else(|| ctx.indicators.get(&self.middle_key));
562        let upper_vals =
563            unsafe { self.upper_slot.get() }.or_else(|| ctx.indicators.get(&self.upper_key));
564        let (Some(lower_vals), Some(middle_vals), Some(upper_vals)) =
565            (lower_vals, middle_vals, upper_vals)
566        else {
567            return Signal::hold();
568        };
569
570        let get = |vals: &Vec<Option<f64>>, idx: usize| vals.get(idx).and_then(|&v| v);
571        let (Some(lower_val), Some(middle_val), Some(upper_val)) =
572            (get(lower_vals, i), get(middle_vals, i), get(upper_vals, i))
573        else {
574            return Signal::hold();
575        };
576
577        // Long entry: price at or below lower band
578        if close <= lower_val && !ctx.has_position() {
579            return Signal::long(candle.timestamp, close)
580                .with_reason("Price at lower Bollinger Band");
581        }
582
583        // Long exit
584        if ctx.is_long() {
585            let exit_level = if self.exit_at_middle {
586                middle_val
587            } else {
588                upper_val
589            };
590            if close >= exit_level {
591                return Signal::exit(candle.timestamp, close).with_reason(format!(
592                    "Price reached {} Bollinger Band",
593                    if self.exit_at_middle {
594                        "middle"
595                    } else {
596                        "upper"
597                    }
598                ));
599            }
600        }
601
602        // Short entry: price at or above upper band
603        if close >= upper_val && !ctx.has_position() {
604            return Signal::short(candle.timestamp, close)
605                .with_reason("Price at upper Bollinger Band");
606        }
607
608        // Short exit
609        if ctx.is_short() {
610            let exit_level = if self.exit_at_middle {
611                middle_val
612            } else {
613                lower_val
614            };
615            if close <= exit_level {
616                return Signal::exit(candle.timestamp, close).with_reason(format!(
617                    "Price reached {} Bollinger Band",
618                    if self.exit_at_middle {
619                        "middle"
620                    } else {
621                        "lower"
622                    }
623                ));
624            }
625        }
626
627        Signal::hold()
628    }
629}
630
631/// SuperTrend Following Strategy
632///
633/// Goes long when SuperTrend turns bullish (uptrend).
634/// Emits short signals when SuperTrend turns bearish (execution gated by
635/// [`BacktestConfig::allow_short`](crate::backtesting::BacktestConfig)).
636#[derive(Debug, Clone)]
637pub struct SuperTrendFollow {
638    /// ATR period
639    pub period: usize,
640    /// ATR multiplier
641    pub multiplier: f64,
642    uptrend_key: String,
643    uptrend_slot: IndicatorSlot,
644}
645
646impl SuperTrendFollow {
647    /// Create a new SuperTrend following strategy
648    pub fn new(period: usize, multiplier: f64) -> Self {
649        Self {
650            period,
651            multiplier,
652            uptrend_key: format!("supertrend_uptrend_{period}_{multiplier}"),
653            uptrend_slot: IndicatorSlot::default(),
654        }
655    }
656}
657
658impl Default for SuperTrendFollow {
659    fn default() -> Self {
660        Self::new(10, 3.0)
661    }
662}
663
664impl Strategy for SuperTrendFollow {
665    fn name(&self) -> &str {
666        "SuperTrend Follow"
667    }
668
669    fn required_indicators(&self) -> Vec<(String, Indicator)> {
670        vec![(
671            "supertrend".to_string(),
672            Indicator::Supertrend {
673                period: self.period,
674                multiplier: self.multiplier,
675            },
676        )]
677    }
678
679    fn setup(&mut self, indicators: &HashMap<String, Vec<Option<f64>>>) {
680        if let Some(v) = indicators.get(&self.uptrend_key) {
681            self.uptrend_slot.set(v);
682        }
683    }
684
685    fn warmup_period(&self) -> usize {
686        self.period + 1
687    }
688
689    fn on_candle(&self, ctx: &StrategyContext) -> Signal {
690        let candle = ctx.current_candle();
691        let i = ctx.index;
692
693        // SAFETY: see SmaCrossover::on_candle.
694        let vals =
695            unsafe { self.uptrend_slot.get() }.or_else(|| ctx.indicators.get(&self.uptrend_key));
696        let Some(vals) = vals else {
697            return Signal::hold();
698        };
699        let get = |idx: usize| vals.get(idx).and_then(|&v| v);
700        let (Some(now), Some(prev)) = (get(i), if i > 0 { get(i - 1) } else { None }) else {
701            return Signal::hold();
702        };
703
704        let is_uptrend = now > 0.5;
705        let was_uptrend = prev > 0.5;
706
707        // Trend changed to bullish
708        if is_uptrend && !was_uptrend {
709            if ctx.is_short() {
710                return Signal::exit(candle.timestamp, candle.close)
711                    .with_reason("SuperTrend turned bullish - close short");
712            }
713            if !ctx.has_position() {
714                return Signal::long(candle.timestamp, candle.close)
715                    .with_reason("SuperTrend turned bullish");
716            }
717        }
718
719        // Trend changed to bearish
720        if !is_uptrend && was_uptrend {
721            if ctx.is_long() {
722                return Signal::exit(candle.timestamp, candle.close)
723                    .with_reason("SuperTrend turned bearish - close long");
724            }
725            if !ctx.has_position() {
726                return Signal::short(candle.timestamp, candle.close)
727                    .with_reason("SuperTrend turned bearish");
728            }
729        }
730
731        Signal::hold()
732    }
733}
734
735/// Donchian Channel Breakout Strategy
736///
737/// Goes long when price breaks above upper channel (new high).
738/// Exits when price breaks below lower channel (new low).
739/// Emits short signals on downward breakouts (execution gated by
740/// [`BacktestConfig::allow_short`](crate::backtesting::BacktestConfig)).
741#[derive(Debug, Clone)]
742pub struct DonchianBreakout {
743    /// Channel period
744    pub period: usize,
745    /// Use middle channel for exit (true) or opposite channel (false)
746    pub exit_at_middle: bool,
747    upper_key: String,
748    middle_key: String,
749    lower_key: String,
750    upper_slot: IndicatorSlot,
751    middle_slot: IndicatorSlot,
752    lower_slot: IndicatorSlot,
753}
754
755impl DonchianBreakout {
756    /// Create a new Donchian breakout strategy
757    pub fn new(period: usize) -> Self {
758        Self {
759            period,
760            exit_at_middle: true,
761            upper_key: format!("donchian_upper_{period}"),
762            middle_key: format!("donchian_middle_{period}"),
763            lower_key: format!("donchian_lower_{period}"),
764            upper_slot: IndicatorSlot::default(),
765            middle_slot: IndicatorSlot::default(),
766            lower_slot: IndicatorSlot::default(),
767        }
768    }
769
770    /// Set exit at middle channel
771    pub fn exit_at_middle(mut self, at_middle: bool) -> Self {
772        self.exit_at_middle = at_middle;
773        self
774    }
775}
776
777impl Default for DonchianBreakout {
778    fn default() -> Self {
779        Self::new(20)
780    }
781}
782
783impl Strategy for DonchianBreakout {
784    fn name(&self) -> &str {
785        "Donchian Breakout"
786    }
787
788    fn required_indicators(&self) -> Vec<(String, Indicator)> {
789        vec![(
790            "donchian".to_string(),
791            Indicator::DonchianChannels(self.period),
792        )]
793    }
794
795    fn setup(&mut self, indicators: &HashMap<String, Vec<Option<f64>>>) {
796        if let Some(v) = indicators.get(&self.upper_key) {
797            self.upper_slot.set(v);
798        }
799        if let Some(v) = indicators.get(&self.middle_key) {
800            self.middle_slot.set(v);
801        }
802        if let Some(v) = indicators.get(&self.lower_key) {
803            self.lower_slot.set(v);
804        }
805    }
806
807    fn warmup_period(&self) -> usize {
808        self.period
809    }
810
811    fn on_candle(&self, ctx: &StrategyContext) -> Signal {
812        let candle = ctx.current_candle();
813        let close = candle.close;
814        let i = ctx.index;
815
816        // SAFETY: see SmaCrossover::on_candle.
817        let upper_vals =
818            unsafe { self.upper_slot.get() }.or_else(|| ctx.indicators.get(&self.upper_key));
819        let middle_vals =
820            unsafe { self.middle_slot.get() }.or_else(|| ctx.indicators.get(&self.middle_key));
821        let lower_vals =
822            unsafe { self.lower_slot.get() }.or_else(|| ctx.indicators.get(&self.lower_key));
823        let (Some(upper_vals), Some(middle_vals), Some(lower_vals)) =
824            (upper_vals, middle_vals, lower_vals)
825        else {
826            return Signal::hold();
827        };
828        let get = |vals: &Vec<Option<f64>>, idx: usize| vals.get(idx).and_then(|&v| v);
829        let (Some(_upper_val), Some(middle_val), Some(_lower_val)) =
830            (get(upper_vals, i), get(middle_vals, i), get(lower_vals, i))
831        else {
832            return Signal::hold();
833        };
834        let prev_upper = if i > 0 { get(upper_vals, i - 1) } else { None };
835        let prev_lower = if i > 0 { get(lower_vals, i - 1) } else { None };
836
837        // Breakout above the *previous* bar's upper channel level → go long.
838        // Using the lagged level rather than the current bar's channel prevents
839        // look-ahead bias: the current bar's Donchian high is computed using the
840        // close of that same bar, so comparing `close > current_upper` would
841        // trivially never trigger (the close can equal but not exceed the max
842        // of the window it belongs to).  The lagged level is the natural
843        // reference point for a confirmed breakout signal.
844        if let Some(prev_up) = prev_upper
845            && close > prev_up
846        {
847            if ctx.is_short() {
848                return Signal::exit(candle.timestamp, close)
849                    .with_reason("Donchian upper channel breakout - close short");
850            }
851            if !ctx.has_position() {
852                return Signal::long(candle.timestamp, close)
853                    .with_reason("Donchian upper channel breakout");
854            }
855        }
856
857        // Breakdown below the *previous* bar's lower channel level (same
858        // lagged-reference rationale as the upper channel breakout above).
859        if let Some(prev_low) = prev_lower
860            && close < prev_low
861        {
862            if ctx.is_long() {
863                return Signal::exit(candle.timestamp, close)
864                    .with_reason("Donchian lower channel breakdown - close long");
865            }
866            if !ctx.has_position() {
867                return Signal::short(candle.timestamp, close)
868                    .with_reason("Donchian lower channel breakdown");
869            }
870        }
871
872        // Exit long at middle
873        if ctx.is_long() && self.exit_at_middle && close <= middle_val {
874            return Signal::exit(candle.timestamp, close)
875                .with_reason("Price reached Donchian middle channel");
876        }
877
878        // Exit short at middle
879        if ctx.is_short() && self.exit_at_middle && close >= middle_val {
880            return Signal::exit(candle.timestamp, close)
881                .with_reason("Price reached Donchian middle channel");
882        }
883
884        Signal::hold()
885    }
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891    use crate::backtesting::{Position, PositionSide};
892    use crate::models::chart::Candle;
893
894    fn make_candle(ts: i64, close: f64) -> Candle {
895        Candle {
896            timestamp: ts,
897            open: close,
898            high: close,
899            low: close,
900            close,
901            volume: 1000,
902            adj_close: None,
903            provider_id: None,
904        }
905    }
906
907    #[test]
908    fn test_sma_crossover_default() {
909        let s = SmaCrossover::default();
910        assert_eq!(s.fast_period, 10);
911        assert_eq!(s.slow_period, 20);
912    }
913
914    #[test]
915    fn test_sma_crossover_custom() {
916        let s = SmaCrossover::new(5, 15);
917        assert_eq!(s.fast_period, 5);
918        assert_eq!(s.slow_period, 15);
919    }
920
921    #[test]
922    fn test_rsi_default() {
923        let s = RsiReversal::default();
924        assert_eq!(s.period, 14);
925        assert!((s.oversold - 30.0).abs() < 0.01);
926        assert!((s.overbought - 70.0).abs() < 0.01);
927    }
928
929    #[test]
930    fn test_rsi_with_thresholds() {
931        let s = RsiReversal::new(10).with_thresholds(25.0, 75.0);
932        assert_eq!(s.period, 10);
933        assert!((s.oversold - 25.0).abs() < 0.01);
934        assert!((s.overbought - 75.0).abs() < 0.01);
935    }
936
937    #[test]
938    fn test_macd_default() {
939        let s = MacdSignal::default();
940        assert_eq!(s.fast, 12);
941        assert_eq!(s.slow, 26);
942        assert_eq!(s.signal, 9);
943    }
944
945    #[test]
946    fn test_bollinger_default() {
947        let s = BollingerMeanReversion::default();
948        assert_eq!(s.period, 20);
949        assert!((s.std_dev - 2.0).abs() < 0.01);
950    }
951
952    #[test]
953    fn test_supertrend_default() {
954        let s = SuperTrendFollow::default();
955        assert_eq!(s.period, 10);
956        assert!((s.multiplier - 3.0).abs() < 0.01);
957    }
958
959    #[test]
960    fn test_donchian_default() {
961        let s = DonchianBreakout::default();
962        assert_eq!(s.period, 20);
963        assert!(s.exit_at_middle);
964    }
965
966    #[test]
967    fn test_strategy_names() {
968        assert_eq!(SmaCrossover::default().name(), "SMA Crossover");
969        assert_eq!(RsiReversal::default().name(), "RSI Reversal");
970        assert_eq!(MacdSignal::default().name(), "MACD Signal");
971        assert_eq!(
972            BollingerMeanReversion::default().name(),
973            "Bollinger Mean Reversion"
974        );
975        assert_eq!(SuperTrendFollow::default().name(), "SuperTrend Follow");
976        assert_eq!(DonchianBreakout::default().name(), "Donchian Breakout");
977    }
978
979    #[test]
980    fn test_required_indicators() {
981        let sma = SmaCrossover::new(5, 10);
982        let indicators = sma.required_indicators();
983        assert_eq!(indicators.len(), 2);
984        assert_eq!(indicators[0].0, "sma_5");
985        assert_eq!(indicators[1].0, "sma_10");
986
987        let rsi = RsiReversal::new(14);
988        let indicators = rsi.required_indicators();
989        assert_eq!(indicators.len(), 1);
990        assert_eq!(indicators[0].0, "rsi_14");
991    }
992
993    #[test]
994    fn test_sma_crossover_fires_on_touch() {
995        use crate::backtesting::signal::SignalDirection;
996
997        let strategy = SmaCrossover::new(5, 10);
998        let candles = vec![make_candle(1, 100.0), make_candle(2, 101.0)];
999        let mut indicators = HashMap::new();
1000        indicators.insert("sma_5".to_string(), vec![Some(10.0), Some(11.0)]);
1001        indicators.insert("sma_10".to_string(), vec![Some(10.0), Some(10.0)]);
1002
1003        let ctx = StrategyContext {
1004            candles: &candles,
1005            index: 1,
1006            position: None,
1007            equity: 10_000.0,
1008            indicators: &indicators,
1009            extremes: None,
1010            indicator_index: None,
1011        };
1012
1013        // fast was exactly equal to slow (10 == 10), now above (11 > 10)
1014        let signal = strategy.on_candle(&ctx);
1015        assert_eq!(signal.direction, SignalDirection::Long);
1016    }
1017
1018    #[test]
1019    fn test_donchian_short_exits_on_upper_breakout_when_exit_at_middle_false() {
1020        let strategy = DonchianBreakout::new(3).exit_at_middle(false);
1021
1022        let candles = vec![make_candle(1, 100.0), make_candle(2, 115.0)];
1023        let mut indicators = HashMap::new();
1024        indicators.insert(
1025            "donchian_upper_3".to_string(),
1026            vec![Some(110.0), Some(112.0)],
1027        );
1028        indicators.insert(
1029            "donchian_middle_3".to_string(),
1030            vec![Some(100.0), Some(101.0)],
1031        );
1032        indicators.insert("donchian_lower_3".to_string(), vec![Some(90.0), Some(91.0)]);
1033
1034        let position = Position::new(
1035            PositionSide::Short,
1036            1,
1037            105.0,
1038            10.0,
1039            0.0,
1040            Signal::short(1, 105.0),
1041        );
1042
1043        let ctx = StrategyContext {
1044            candles: &candles,
1045            index: 1,
1046            position: Some(&position),
1047            equity: 10_000.0,
1048            indicators: &indicators,
1049            extremes: None,
1050            indicator_index: None,
1051        };
1052
1053        let signal = strategy.on_candle(&ctx);
1054        assert!(signal.is_exit());
1055    }
1056
1057    #[test]
1058    fn test_rsi_strength_grades_on_precross_extreme() {
1059        let strategy = RsiReversal::new(14);
1060
1061        let candles = vec![make_candle(1, 100.0), make_candle(2, 101.0)];
1062
1063        let mut deep_indicators = HashMap::new();
1064        deep_indicators.insert("rsi_14".to_string(), vec![Some(15.0), Some(31.0)]);
1065        let deep_ctx = StrategyContext {
1066            candles: &candles,
1067            index: 1,
1068            position: None,
1069            equity: 10_000.0,
1070            indicators: &deep_indicators,
1071            extremes: None,
1072            indicator_index: None,
1073        };
1074        let deep_signal = strategy.on_candle(&deep_ctx);
1075        assert_eq!(deep_signal.strength, SignalStrength::strong());
1076
1077        let mut shallow_indicators = HashMap::new();
1078        shallow_indicators.insert("rsi_14".to_string(), vec![Some(28.0), Some(31.0)]);
1079        let shallow_ctx = StrategyContext {
1080            candles: &candles,
1081            index: 1,
1082            position: None,
1083            equity: 10_000.0,
1084            indicators: &shallow_indicators,
1085            extremes: None,
1086            indicator_index: None,
1087        };
1088        let shallow_signal = strategy.on_candle(&shallow_ctx);
1089        assert_eq!(shallow_signal.strength, SignalStrength::weak());
1090    }
1091}