Skip to main content

wickra_core/indicators/
new_price_lines.rs

1//! New Price Lines — the "eight/ten new price lines" exhaustion count.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// New Price Lines — the Japanese "shinne" (new-price) exhaustion count: when the
8/// close has made `count` consecutive new highs (or lows), the trend is considered
9/// stretched and ripe for a pause or reversal.
10///
11/// ```text
12/// consecutive higher closes form "new price lines" up
13/// consecutive lower  closes form "new price lines" down
14/// signal = −1 once `count` consecutive higher closes (overbought / sell warning)
15/// signal = +1 once `count` consecutive lower  closes (oversold / buy warning)
16/// signal =  0 otherwise
17/// ```
18///
19/// Traditional Japanese practice flags **eight** new price lines (and a stronger
20/// **ten** or twelve) as the point where a directional run becomes exhausted —
21/// the market has gone up (or down) so many bars in a row that a corrective pause
22/// is statistically due. The signal stays active for every bar the streak remains
23/// at or above `count`, and clears the moment a close breaks the streak.
24///
25/// The first value lands on the second bar (one prior close is needed). The
26/// output is `+1` / `0` / `−1`. Each `update` is O(1).
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Candle, Indicator, NewPriceLines};
32///
33/// let mut indicator = NewPriceLines::new(8).unwrap();
34/// let mut last = None;
35/// for i in 0..12 {
36///     let close = 100.0 + f64::from(i); // 11 consecutive higher closes
37///     let c = Candle::new(close, close, close, close, 1_000.0, 0).unwrap();
38///     last = indicator.update(c);
39/// }
40/// assert_eq!(last, Some(-1.0));
41/// ```
42#[derive(Debug, Clone)]
43pub struct NewPriceLines {
44    count: usize,
45    prev_close: Option<f64>,
46    consec_up: usize,
47    consec_down: usize,
48    last: Option<f64>,
49}
50
51impl NewPriceLines {
52    /// Construct a New Price Lines counter that fires at `count` consecutive new
53    /// closes (classic `8`, stronger `10`/`12`).
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::InvalidPeriod`] if `count < 2`.
58    pub fn new(count: usize) -> Result<Self> {
59        if count < 2 {
60            return Err(Error::InvalidPeriod {
61                message: "new price lines count must be >= 2",
62            });
63        }
64        if count > crate::error::MAX_PERIOD {
65            return Err(Error::InvalidPeriod {
66                message: crate::error::PERIOD_ABOVE_MAX,
67            });
68        }
69        Ok(Self {
70            count,
71            prev_close: None,
72            consec_up: 0,
73            consec_down: 0,
74            last: None,
75        })
76    }
77
78    /// Configured count threshold.
79    pub const fn count(&self) -> usize {
80        self.count
81    }
82
83    /// Current consecutive streak `(up, down)`.
84    pub const fn streak(&self) -> (usize, usize) {
85        (self.consec_up, self.consec_down)
86    }
87
88    /// Current value if available.
89    pub const fn value(&self) -> Option<f64> {
90        self.last
91    }
92}
93
94impl Indicator for NewPriceLines {
95    type Input = Candle;
96    type Output = f64;
97
98    #[inline]
99    fn update(&mut self, candle: Candle) -> Option<f64> {
100        let close = candle.close;
101        let Some(prev) = self.prev_close else {
102            self.prev_close = Some(close);
103            return None;
104        };
105        if close > prev {
106            self.consec_up += 1;
107            self.consec_down = 0;
108        } else if close < prev {
109            self.consec_down += 1;
110            self.consec_up = 0;
111        } else {
112            self.consec_up = 0;
113            self.consec_down = 0;
114        }
115        self.prev_close = Some(close);
116
117        let v = if self.consec_up >= self.count {
118            -1.0
119        } else if self.consec_down >= self.count {
120            1.0
121        } else {
122            0.0
123        };
124        self.last = Some(v);
125        Some(v)
126    }
127
128    fn reset(&mut self) {
129        self.prev_close = None;
130        self.consec_up = 0;
131        self.consec_down = 0;
132        self.last = None;
133    }
134
135    #[inline]
136    fn warmup_period(&self) -> usize {
137        2
138    }
139
140    #[inline]
141    fn is_ready(&self) -> bool {
142        self.last.is_some()
143    }
144
145    #[inline]
146    fn name(&self) -> &'static str {
147        "NewPriceLines"
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::traits::BatchExt;
155
156    fn c(close: f64) -> Candle {
157        Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
158    }
159
160    #[test]
161    fn rejects_small_count() {
162        assert!(matches!(
163            NewPriceLines::new(1),
164            Err(Error::InvalidPeriod { .. })
165        ));
166        assert!(NewPriceLines::new(2).is_ok());
167    }
168
169    #[test]
170    fn accessors_and_metadata() {
171        let n = NewPriceLines::new(8).unwrap();
172        assert_eq!(n.count(), 8);
173        assert_eq!(n.streak(), (0, 0));
174        assert_eq!(n.warmup_period(), 2);
175        assert_eq!(n.name(), "NewPriceLines");
176        assert!(!n.is_ready());
177        assert_eq!(n.value(), None);
178    }
179
180    #[test]
181    fn first_bar_seeds_without_signal() {
182        let mut n = NewPriceLines::new(3).unwrap();
183        assert_eq!(n.update(c(100.0)), None);
184        assert!(n.update(c(101.0)).is_some());
185    }
186
187    #[test]
188    fn eight_higher_closes_signal_sell() {
189        let mut n = NewPriceLines::new(8).unwrap();
190        // 11 consecutive higher closes -> by the 9th the count reaches 8 -> -1.
191        let candles: Vec<Candle> = (0..12).map(|i| c(100.0 + f64::from(i))).collect();
192        let last = n.batch(&candles).into_iter().flatten().last().unwrap();
193        assert_eq!(last, -1.0);
194    }
195
196    #[test]
197    fn eight_lower_closes_signal_buy() {
198        let mut n = NewPriceLines::new(8).unwrap();
199        let candles: Vec<Candle> = (0..12).map(|i| c(200.0 - f64::from(i))).collect();
200        let last = n.batch(&candles).into_iter().flatten().last().unwrap();
201        assert_eq!(last, 1.0);
202    }
203
204    #[test]
205    fn break_in_streak_clears_signal() {
206        let mut n = NewPriceLines::new(3).unwrap();
207        n.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // streak 3 -> -1
208        assert_eq!(n.value(), Some(-1.0));
209        // A lower close breaks the up streak.
210        assert_eq!(n.update(c(102.0)), Some(0.0));
211        assert_eq!(n.streak(), (0, 1));
212    }
213
214    #[test]
215    fn unchanged_close_resets_streak() {
216        let mut n = NewPriceLines::new(3).unwrap();
217        n.batch(&[c(100.0), c(101.0), c(102.0)]);
218        assert_eq!(n.update(c(102.0)), Some(0.0)); // equal -> reset
219        assert_eq!(n.streak(), (0, 0));
220    }
221
222    #[test]
223    fn reset_clears_state() {
224        let mut n = NewPriceLines::new(3).unwrap();
225        n.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]);
226        assert!(n.is_ready());
227        n.reset();
228        assert!(!n.is_ready());
229        assert_eq!(n.value(), None);
230        assert_eq!(n.streak(), (0, 0));
231    }
232
233    #[test]
234    fn batch_equals_streaming() {
235        let candles: Vec<Candle> = (0..80)
236            .map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
237            .collect();
238        let batch = NewPriceLines::new(8).unwrap().batch(&candles);
239        let mut b = NewPriceLines::new(8).unwrap();
240        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
241        assert_eq!(batch, streamed);
242    }
243}