Skip to main content

quantwave_core/indicators/
supertrend.rs

1use crate::indicators::incremental::ta_atr::TaATR;
2use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
3use crate::indicators::volatility::ATR;
4use crate::traits::Next;
5use serde::{Deserialize, Serialize};
6
7/// Initial directional bias for SuperTrend before trend confirmation.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
9pub enum SuperTrendInitialDirection {
10    /// Assumes an uptrend from bar 0 (direction = 1, quantwave legacy default).
11    #[default]
12    Up,
13    /// Assumes a downtrend initially (direction = -1, TradingView convention).
14    /// The first close above the upper band represents an explicit flip to uptrend (e.g. IPO breakout).
15    Down,
16    /// Sets the initial direction from close vs bar midpoint `(high + low) / 2` on the first valid bar.
17    FromClose,
18}
19
20impl std::str::FromStr for SuperTrendInitialDirection {
21    type Err = String;
22    fn from_str(s: &str) -> Result<Self, Self::Err> {
23        match s.to_ascii_lowercase().as_str() {
24            "up" => Ok(Self::Up),
25            "down" => Ok(Self::Down),
26            "from_close" | "fromclose" => Ok(Self::FromClose),
27            other => Err(format!(
28                "unknown initial_direction: '{other}' (expected 'up', 'down', or 'from_close')"
29            )),
30        }
31    }
32}
33
34/// ATR smoothing algorithm used for SuperTrend volatility bands.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
36pub enum SuperTrendAtrSmoothing {
37    /// Standard EMA smoothing (alpha = 2/(period+1), seeded from bar 0, quantwave legacy default).
38    #[default]
39    Ema,
40    /// Wilder's RMA smoothing (alpha = 1/period, seeded with SMA of first `period` true ranges,
41    /// emits NaN during warm-up, TA-Lib and TradingView Pine `ta.atr` convention).
42    Wilder,
43}
44
45impl std::str::FromStr for SuperTrendAtrSmoothing {
46    type Err = String;
47    fn from_str(s: &str) -> Result<Self, Self::Err> {
48        match s.to_ascii_lowercase().as_str() {
49            "ema" => Ok(Self::Ema),
50            "wilder" | "rma" => Ok(Self::Wilder),
51            other => Err(format!(
52                "unknown smoothing: '{other}' (expected 'ema' or 'wilder')"
53            )),
54        }
55    }
56}
57
58#[derive(Debug, Clone)]
59enum AtrBackend {
60    Ema(ATR),
61    Wilder(TaATR),
62}
63
64impl AtrBackend {
65    fn next(&mut self, hlc: (f64, f64, f64)) -> f64 {
66        match self {
67            Self::Ema(atr) => atr.next(hlc),
68            Self::Wilder(ta_atr) => ta_atr.next(hlc),
69        }
70    }
71}
72
73pub const SUPERTREND_METADATA: IndicatorMetadata = IndicatorMetadata {
74    name: "SuperTrend",
75    description: "Trend-following indicator that combines ATR for volatility bands to identify the primary market direction.",
76    usage: "Use as a primary trend-following indicator and dynamic stop-loss. A SuperTrend flip from bearish to bullish (or vice versa) provides a clear, rule-based entry and exit signal.",
77    keywords: &["trend", "atr", "stop-loss", "classic", "breakout"],
78    ehlers_summary: "SuperTrend computes upper and lower ATR-based bands around the midpoint of each bar. The active line flips from upper to lower (and vice versa) only when price closes beyond the band, providing a clean directional bias and a trailing stop level in one indicator. — TradingView Community",
79    params: &[
80        ParamDef {
81            name: "period",
82            default: "10",
83            description: "ATR length",
84        },
85        ParamDef {
86            name: "multiplier",
87            default: "3.0",
88            description: "ATR multiplier",
89        },
90        ParamDef {
91            name: "initial_direction",
92            default: "up",
93            description: "Initial trend direction before reversal ('up', 'down', or 'from_close')",
94        },
95        ParamDef {
96            name: "smoothing",
97            default: "ema",
98            description: "ATR smoothing algorithm ('ema' default, or 'wilder'). Note: SuperTrend defaults to EMA, whereas standalone atr() defaults to Wilder.",
99        },
100    ],
101    formula_source: "https://www.tradingview.com/script/7zF0a4f8-SuperTrend-by-Mobius/",
102    formula_latex: r#"
103\[
104\text{SuperTrend} = \begin{cases}
105\text{LowerBand} & \text{if trend is up} \\
106\text{UpperBand} & \text{if trend is down}
107\end{cases}
108\]
109"#,
110    gold_standard_file: "supertrend_10_3.json",
111    category: "Classic",
112};
113
114/// SuperTrend Indicator with configurable initial direction and ATR smoothing.
115///
116/// Note on smoothing:
117///   SuperTrend's internal ATR defaults to EMA smoothing (`SuperTrendAtrSmoothing::Ema`).
118///   In contrast, standalone `ATR` / `TaATR` defaults to Wilder's RMA.
119///   Use `with_options(..., SuperTrendAtrSmoothing::Wilder)` if Wilder RMA smoothing is desired.
120#[derive(Debug, Clone)]
121pub struct SuperTrend {
122    atr: AtrBackend,
123    multiplier: f64,
124    prev_close: Option<f64>,
125    prev_upper_band: Option<f64>,
126    prev_lower_band: Option<f64>,
127    direction: i8, // 1 for up, -1 for down
128    initial_direction: SuperTrendInitialDirection,
129    initialized_direction: bool,
130}
131
132impl SuperTrend {
133    pub fn new(period: usize, multiplier: f64) -> Self {
134        Self::with_options(
135            period,
136            multiplier,
137            SuperTrendInitialDirection::Up,
138            SuperTrendAtrSmoothing::Ema,
139        )
140    }
141
142    pub fn with_options(
143        period: usize,
144        multiplier: f64,
145        initial_direction: SuperTrendInitialDirection,
146        atr_smoothing: SuperTrendAtrSmoothing,
147    ) -> Self {
148        let initial_dir_val = match initial_direction {
149            SuperTrendInitialDirection::Up => 1,
150            SuperTrendInitialDirection::Down => -1,
151            SuperTrendInitialDirection::FromClose => 1,
152        };
153        let atr = match atr_smoothing {
154            SuperTrendAtrSmoothing::Ema => AtrBackend::Ema(ATR::new(period)),
155            SuperTrendAtrSmoothing::Wilder => AtrBackend::Wilder(TaATR::new(period)),
156        };
157        Self {
158            atr,
159            multiplier,
160            prev_close: None,
161            prev_upper_band: None,
162            prev_lower_band: None,
163            direction: initial_dir_val,
164            initial_direction,
165            initialized_direction: false,
166        }
167    }
168}
169
170impl Next<(f64, f64, f64)> for SuperTrend {
171    type Output = (f64, i8);
172
173    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
174        if high.is_nan() || low.is_nan() || close.is_nan() {
175            return (f64::NAN, self.direction);
176        }
177
178        let atr = self.atr.next((high, low, close));
179        if atr.is_nan() {
180            return (f64::NAN, self.direction);
181        }
182
183        let mid = (high + low) / 2.0;
184
185        if !self.initialized_direction {
186            self.direction = match self.initial_direction {
187                SuperTrendInitialDirection::Up => 1,
188                SuperTrendInitialDirection::Down => -1,
189                SuperTrendInitialDirection::FromClose => {
190                    if close >= mid {
191                        1
192                    } else {
193                        -1
194                    }
195                }
196            };
197            self.initialized_direction = true;
198        }
199
200        let basic_upper = mid + self.multiplier * atr;
201        let basic_lower = mid - self.multiplier * atr;
202
203        let upper_band = match self.prev_upper_band {
204            Some(prev_upper) => {
205                if basic_upper < prev_upper || self.prev_close.unwrap_or(0.0) > prev_upper {
206                    basic_upper
207                } else {
208                    prev_upper
209                }
210            }
211            None => basic_upper,
212        };
213
214        let lower_band = match self.prev_lower_band {
215            Some(prev_lower) => {
216                if basic_lower > prev_lower || self.prev_close.unwrap_or(0.0) < prev_lower {
217                    basic_lower
218                } else {
219                    prev_lower
220                }
221            }
222            None => basic_lower,
223        };
224
225        if self.direction == -1 && close > upper_band {
226            self.direction = 1;
227        } else if self.direction == 1 && close < lower_band {
228            self.direction = -1;
229        }
230
231        let supertrend = if self.direction == 1 {
232            lower_band
233        } else {
234            upper_band
235        };
236
237        self.prev_close = Some(close);
238        self.prev_upper_band = Some(upper_band);
239        self.prev_lower_band = Some(lower_band);
240
241        (supertrend, self.direction)
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use proptest::prelude::*;
249    use serde::Deserialize;
250    use std::fs;
251    use std::path::Path;
252
253    #[derive(Debug, Deserialize)]
254    struct SuperTrendCase {
255        high: Vec<f64>,
256        low: Vec<f64>,
257        close: Vec<f64>,
258        expected_st: Vec<f64>,
259        expected_dir: Vec<i8>,
260    }
261
262    #[test]
263    fn test_supertrend_gold_standard() {
264        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
265        let manifest_path = Path::new(&manifest_dir);
266        let path = manifest_path.join("tests/gold_standard/supertrend_10_3.json");
267        let path = if path.exists() {
268            path
269        } else {
270            manifest_path
271                .parent()
272                .unwrap()
273                .join("tests/gold_standard/supertrend_10_3.json")
274        };
275        let content = fs::read_to_string(path).unwrap();
276        let case: SuperTrendCase = serde_json::from_str(&content).unwrap();
277
278        let mut st = SuperTrend::new(10, 3.0);
279        for i in 0..case.high.len() {
280            let (val, dir) = st.next((case.high[i], case.low[i], case.close[i]));
281            approx::assert_relative_eq!(val, case.expected_st[i], epsilon = 1e-6);
282            assert_eq!(dir, case.expected_dir[i]);
283        }
284    }
285
286    #[test]
287    fn test_supertrend_nan_inputs_emit_nan() {
288        let mut st = SuperTrend::new(3, 2.0);
289        let c = [1.0, 2.0, 3.0, f64::NAN, 5.0, 6.0, 7.0];
290        let mut results = Vec::new();
291        for &val in &c {
292            results.push(st.next((val + 1.0, val - 1.0, val)));
293        }
294        // At index 3 (NaN), result must be NaN, NOT stale finite number!
295        assert!(
296            results[3].0.is_nan(),
297            "SuperTrend should emit NaN on NaN input, got {}",
298            results[3].0
299        );
300    }
301
302    #[test]
303    fn test_supertrend_wilder_down_startup_and_breakout() {
304        // Test IPO-base stock: listings starting in downtrend (Down) with Wilder ATR
305        let period = 3;
306        let mult = 2.0;
307        let mut st = SuperTrend::with_options(
308            period,
309            mult,
310            SuperTrendInitialDirection::Down,
311            SuperTrendAtrSmoothing::Wilder,
312        );
313
314        // Bars 0..period (indices 0, 1, 2): Wilder ATR needs period (3) true ranges.
315        // Bar 0 sets prev_close; bars 1, 2 collect 2 true ranges (< 3) -> emits NaN.
316        let r0 = st.next((10.0, 8.0, 9.0));
317        assert!(r0.0.is_nan());
318        let r1 = st.next((11.0, 9.0, 10.0));
319        assert!(r1.0.is_nan());
320        let r2 = st.next((11.0, 9.0, 10.0));
321        assert!(r2.0.is_nan());
322
323        // Bar 3: 3rd true range collected -> Wilder ATR emits SMA of the 3 true ranges.
324        // TRs:
325        // bar1: max(2, |11-9|, |9-9|) = 2.0
326        // bar2: max(2, |11-10|, |9-10|) = 2.0
327        // bar3: max(2, |10-10|, |8-10|) = 2.0
328        // ATR = (2 + 2 + 2)/3 = 2.0.
329        // Mid = (10 + 8)/2 = 9.0. basic_upper = 9 + 2*2 = 13.0.
330        // Close = 9.5 < 13.0 -> remains Down (-1), supertrend line = 13.0.
331        let r3 = st.next((10.0, 8.0, 9.5));
332        assert!(!r3.0.is_nan());
333        approx::assert_relative_eq!(r3.0, 13.0);
334        assert_eq!(r3.1, -1);
335
336        // Bar 4: Price breaks out above upper band (IPO-base breakout)!
337        // Close = 15.0 > 13.0 -> flips to Up (1)!
338        let r4 = st.next((16.0, 14.0, 15.0));
339        assert_eq!(
340            r4.1, 1,
341            "Should flip to uptrend upon breakout above upper band"
342        );
343    }
344
345    fn supertrend_batch(
346        data: Vec<(f64, f64, f64)>,
347        period: usize,
348        multiplier: f64,
349    ) -> Vec<(f64, i8)> {
350        let mut st = SuperTrend::new(period, multiplier);
351        data.into_iter().map(|x| st.next(x)).collect()
352    }
353
354    proptest! {
355        #[test]
356        fn test_supertrend_parity(input in prop::collection::vec((0.0..100.0, 0.0..100.0, 0.0..100.0), 1..100)) {
357            let mut adj_input = Vec::with_capacity(input.len());
358            for (h, l, c) in input {
359                let h_f: f64 = h;
360                let l_f: f64 = l;
361                let c_f: f64 = c;
362                let high = h_f.max(l_f).max(c_f);
363                let low = l_f.min(h_f).min(c_f);
364                adj_input.push((high, low, c_f));
365            }
366
367            let period = 10;
368            let multiplier = 3.0;
369            let mut st = SuperTrend::new(period, multiplier);
370            let mut streaming_results = Vec::with_capacity(adj_input.len());
371            for &val in &adj_input {
372                streaming_results.push(st.next(val));
373            }
374
375            let batch_results = supertrend_batch(adj_input, period, multiplier);
376
377            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
378                approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-6);
379                assert_eq!(s.1, b.1);
380            }
381        }
382    }
383}