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    #[inline]
99    fn update(&mut self, candle: Candle) -> Option<NrtrOutput> {
100        let close = candle.close;
101        let down = self.pct / 100.0;
102        let up = self.pct / 100.0;
103
104        if self.direction == 0.0 {
105            self.direction = 1.0;
106            self.water = close;
107        } else if self.direction > 0.0 {
108            self.water = self.water.max(close);
109            let line = self.water * (1.0 - down);
110            if close < line {
111                self.direction = -1.0;
112                self.water = close;
113            }
114        } else {
115            self.water = self.water.min(close);
116            let line = self.water * (1.0 + up);
117            if close > line {
118                self.direction = 1.0;
119                self.water = close;
120            }
121        }
122
123        let line = if self.direction > 0.0 {
124            self.water * (1.0 - down)
125        } else {
126            self.water * (1.0 + up)
127        };
128        let out = NrtrOutput {
129            value: line,
130            direction: self.direction,
131        };
132        self.last = Some(out);
133        Some(out)
134    }
135
136    fn reset(&mut self) {
137        self.direction = 0.0;
138        self.water = 0.0;
139        self.last = None;
140    }
141
142    #[inline]
143    fn warmup_period(&self) -> usize {
144        1
145    }
146
147    #[inline]
148    fn is_ready(&self) -> bool {
149        self.last.is_some()
150    }
151
152    #[inline]
153    fn name(&self) -> &'static str {
154        "Nrtr"
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::traits::BatchExt;
162
163    fn c(close: f64) -> Candle {
164        Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
165    }
166
167    #[test]
168    fn rejects_invalid_pct() {
169        assert!(matches!(
170            Nrtr::new(0.0),
171            Err(Error::InvalidParameter { .. })
172        ));
173        assert!(matches!(
174            Nrtr::new(100.0),
175            Err(Error::InvalidParameter { .. })
176        ));
177        assert!(matches!(
178            Nrtr::new(f64::NAN),
179            Err(Error::InvalidParameter { .. })
180        ));
181        assert!(Nrtr::new(2.0).is_ok());
182    }
183
184    #[test]
185    fn accessors_and_metadata() {
186        let n = Nrtr::new(2.0).unwrap();
187        assert_eq!(n.pct(), 2.0);
188        assert_eq!(n.warmup_period(), 1);
189        assert_eq!(n.name(), "Nrtr");
190        assert!(!n.is_ready());
191        assert_eq!(n.value(), None);
192    }
193
194    #[test]
195    fn first_bar_emits_up_line() {
196        let mut n = Nrtr::new(10.0).unwrap();
197        let o = n.update(c(100.0)).unwrap();
198        assert_eq!(o.direction, 1.0);
199        // line = 100 * (1 - 0.10) = 90.
200        assert!((o.value - 90.0).abs() < 1e-9);
201    }
202
203    #[test]
204    fn uptrend_keeps_line_below_price() {
205        let mut n = Nrtr::new(5.0).unwrap();
206        let candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
207        for (o, candle) in n.batch(&candles).into_iter().zip(candles.iter()) {
208            let o = o.unwrap();
209            assert_eq!(o.direction, 1.0);
210            assert!(o.value < candle.close);
211        }
212    }
213
214    #[test]
215    fn reverses_on_retracement() {
216        let mut n = Nrtr::new(5.0).unwrap();
217        // Rise to 120, then drop sharply -> a >5% retracement reverses the trend.
218        let mut candles: Vec<Candle> = (0..20).map(|i| c(100.0 + f64::from(i))).collect();
219        candles.extend((0..10).map(|i| c(119.0 - 3.0 * f64::from(i))));
220        let dirs: Vec<f64> = n
221            .batch(&candles)
222            .into_iter()
223            .flatten()
224            .map(|o| o.direction)
225            .collect();
226        assert!(dirs.iter().any(|&d| d > 0.0));
227        assert!(dirs.iter().any(|&d| d < 0.0));
228    }
229
230    #[test]
231    fn downtrend_keeps_line_above_price() {
232        let mut n = Nrtr::new(5.0).unwrap();
233        // Establish a downtrend after an initial bar.
234        let mut candles = vec![c(100.0)];
235        candles.extend((0..30).map(|i| c(80.0 - f64::from(i))));
236        let out = n.batch(&candles);
237        let o = out.last().unwrap().unwrap();
238        let candle = candles.last().unwrap();
239        assert_eq!(o.direction, -1.0);
240        assert!(o.value > candle.close);
241    }
242
243    #[test]
244    fn reset_clears_state() {
245        let mut n = Nrtr::new(2.0).unwrap();
246        n.batch(&(0..20).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
247        assert!(n.is_ready());
248        n.reset();
249        assert!(!n.is_ready());
250        assert_eq!(n.value(), None);
251    }
252
253    #[test]
254    fn batch_equals_streaming() {
255        let candles: Vec<Candle> = (0..120)
256            .map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 15.0))
257            .collect();
258        let batch = Nrtr::new(3.0).unwrap().batch(&candles);
259        let mut b = Nrtr::new(3.0).unwrap();
260        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
261        assert_eq!(batch, streamed);
262    }
263}