Skip to main content

wickra_core/indicators/
nrtr.rs

1//! NRTR — Nick Rypock Trailing Reverse, a percentage trailing-reverse stop.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Output of [`Nrtr`]: the trailing-reverse line and the trend direction.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct NrtrOutput {
10    /// The NRTR line — below price in an uptrend, above price in a downtrend.
11    pub value: f64,
12    /// Trend direction: `+1.0` up (line below price), `-1.0` down.
13    pub direction: f64,
14}
15
16/// NRTR (Nick Rypock Trailing Reverse) — a **percentage** trailing-reverse stop
17/// that follows the trend extreme and flips when price retraces by a fixed
18/// percentage.
19///
20/// ```text
21/// uptrend:   high_water = max(high_water, close)
22///            line       = high_water · (1 − pct/100)
23///            flip down when close < line  (reseed low_water = close)
24/// downtrend: low_water  = min(low_water, close)
25///            line       = low_water · (1 + pct/100)
26///            flip up   when close > line  (reseed high_water = close)
27/// ```
28///
29/// Unlike volatility stops (ATR, σ-of-range), NRTR uses a pure **percentage**
30/// retracement: the line trails the highest close reached in the up-leg at a
31/// fixed `pct` below it, and a close that gives back that percentage reverses the
32/// trend, handing the line to the opposite extreme. This makes it scale-free and
33/// trivially tunable — one number sets how much retracement you tolerate. It
34/// differs from a fixed percentage *stop-loss* in that it **reverses** (tracks
35/// both directions) rather than just exiting.
36///
37/// The first bar seeds the up-trend and emits a line immediately. Each `update` is
38/// O(1).
39///
40/// # Example
41///
42/// ```
43/// use wickra_core::{Candle, Indicator, Nrtr};
44///
45/// let mut indicator = Nrtr::new(2.0).unwrap();
46/// let mut last = None;
47/// for i in 0..40 {
48///     let close = 100.0 + f64::from(i);
49///     let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap();
50///     last = indicator.update(c);
51/// }
52/// assert!(last.is_some());
53/// ```
54#[derive(Debug, Clone)]
55pub struct Nrtr {
56    pct: f64,
57    direction: f64,
58    water: f64,
59    last: Option<NrtrOutput>,
60}
61
62impl Nrtr {
63    /// Construct an NRTR with the given trailing percentage (e.g. `2.0` for 2%).
64    ///
65    /// # Errors
66    ///
67    /// Returns [`Error::InvalidParameter`] if `pct` is not finite or is outside
68    /// `(0, 100)`.
69    pub fn new(pct: f64) -> Result<Self> {
70        if !pct.is_finite() || pct <= 0.0 || pct >= 100.0 {
71            return Err(Error::InvalidParameter {
72                message: "NRTR percentage must be in (0, 100)",
73            });
74        }
75        Ok(Self {
76            pct,
77            direction: 0.0,
78            water: 0.0,
79            last: None,
80        })
81    }
82
83    /// Configured trailing percentage.
84    pub const fn pct(&self) -> f64 {
85        self.pct
86    }
87
88    /// Current value if available.
89    pub const fn value(&self) -> Option<NrtrOutput> {
90        self.last
91    }
92}
93
94impl Indicator for Nrtr {
95    type Input = Candle;
96    type Output = NrtrOutput;
97
98    fn update(&mut self, candle: Candle) -> Option<NrtrOutput> {
99        let close = candle.close;
100        let down = self.pct / 100.0;
101        let up = self.pct / 100.0;
102
103        if self.direction == 0.0 {
104            self.direction = 1.0;
105            self.water = close;
106        } else if self.direction > 0.0 {
107            self.water = self.water.max(close);
108            let line = self.water * (1.0 - down);
109            if close < line {
110                self.direction = -1.0;
111                self.water = close;
112            }
113        } else {
114            self.water = self.water.min(close);
115            let line = self.water * (1.0 + up);
116            if close > line {
117                self.direction = 1.0;
118                self.water = close;
119            }
120        }
121
122        let line = if self.direction > 0.0 {
123            self.water * (1.0 - down)
124        } else {
125            self.water * (1.0 + up)
126        };
127        let out = NrtrOutput {
128            value: line,
129            direction: self.direction,
130        };
131        self.last = Some(out);
132        Some(out)
133    }
134
135    fn reset(&mut self) {
136        self.direction = 0.0;
137        self.water = 0.0;
138        self.last = None;
139    }
140
141    fn warmup_period(&self) -> usize {
142        1
143    }
144
145    fn is_ready(&self) -> bool {
146        self.last.is_some()
147    }
148
149    fn name(&self) -> &'static str {
150        "Nrtr"
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::traits::BatchExt;
158
159    fn c(close: f64) -> Candle {
160        Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
161    }
162
163    #[test]
164    fn rejects_invalid_pct() {
165        assert!(matches!(
166            Nrtr::new(0.0),
167            Err(Error::InvalidParameter { .. })
168        ));
169        assert!(matches!(
170            Nrtr::new(100.0),
171            Err(Error::InvalidParameter { .. })
172        ));
173        assert!(matches!(
174            Nrtr::new(f64::NAN),
175            Err(Error::InvalidParameter { .. })
176        ));
177        assert!(Nrtr::new(2.0).is_ok());
178    }
179
180    #[test]
181    fn accessors_and_metadata() {
182        let n = Nrtr::new(2.0).unwrap();
183        assert_eq!(n.pct(), 2.0);
184        assert_eq!(n.warmup_period(), 1);
185        assert_eq!(n.name(), "Nrtr");
186        assert!(!n.is_ready());
187        assert_eq!(n.value(), None);
188    }
189
190    #[test]
191    fn first_bar_emits_up_line() {
192        let mut n = Nrtr::new(10.0).unwrap();
193        let o = n.update(c(100.0)).unwrap();
194        assert_eq!(o.direction, 1.0);
195        // line = 100 * (1 - 0.10) = 90.
196        assert!((o.value - 90.0).abs() < 1e-9);
197    }
198
199    #[test]
200    fn uptrend_keeps_line_below_price() {
201        let mut n = Nrtr::new(5.0).unwrap();
202        let candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
203        for (o, candle) in n.batch(&candles).into_iter().zip(candles.iter()) {
204            let o = o.unwrap();
205            assert_eq!(o.direction, 1.0);
206            assert!(o.value < candle.close);
207        }
208    }
209
210    #[test]
211    fn reverses_on_retracement() {
212        let mut n = Nrtr::new(5.0).unwrap();
213        // Rise to 120, then drop sharply -> a >5% retracement reverses the trend.
214        let mut candles: Vec<Candle> = (0..20).map(|i| c(100.0 + f64::from(i))).collect();
215        candles.extend((0..10).map(|i| c(119.0 - 3.0 * f64::from(i))));
216        let dirs: Vec<f64> = n
217            .batch(&candles)
218            .into_iter()
219            .flatten()
220            .map(|o| o.direction)
221            .collect();
222        assert!(dirs.iter().any(|&d| d > 0.0));
223        assert!(dirs.iter().any(|&d| d < 0.0));
224    }
225
226    #[test]
227    fn downtrend_keeps_line_above_price() {
228        let mut n = Nrtr::new(5.0).unwrap();
229        // Establish a downtrend after an initial bar.
230        let mut candles = vec![c(100.0)];
231        candles.extend((0..30).map(|i| c(80.0 - f64::from(i))));
232        let out = n.batch(&candles);
233        let o = out.last().unwrap().unwrap();
234        let candle = candles.last().unwrap();
235        assert_eq!(o.direction, -1.0);
236        assert!(o.value > candle.close);
237    }
238
239    #[test]
240    fn reset_clears_state() {
241        let mut n = Nrtr::new(2.0).unwrap();
242        n.batch(&(0..20).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
243        assert!(n.is_ready());
244        n.reset();
245        assert!(!n.is_ready());
246        assert_eq!(n.value(), None);
247    }
248
249    #[test]
250    fn batch_equals_streaming() {
251        let candles: Vec<Candle> = (0..120)
252            .map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 15.0))
253            .collect();
254        let batch = Nrtr::new(3.0).unwrap().batch(&candles);
255        let mut b = Nrtr::new(3.0).unwrap();
256        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
257        assert_eq!(batch, streamed);
258    }
259}