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        Ok(Self {
65            count,
66            prev_close: None,
67            consec_up: 0,
68            consec_down: 0,
69            last: None,
70        })
71    }
72
73    /// Configured count threshold.
74    pub const fn count(&self) -> usize {
75        self.count
76    }
77
78    /// Current consecutive streak `(up, down)`.
79    pub const fn streak(&self) -> (usize, usize) {
80        (self.consec_up, self.consec_down)
81    }
82
83    /// Current value if available.
84    pub const fn value(&self) -> Option<f64> {
85        self.last
86    }
87}
88
89impl Indicator for NewPriceLines {
90    type Input = Candle;
91    type Output = f64;
92
93    fn update(&mut self, candle: Candle) -> Option<f64> {
94        let close = candle.close;
95        let Some(prev) = self.prev_close else {
96            self.prev_close = Some(close);
97            return None;
98        };
99        if close > prev {
100            self.consec_up += 1;
101            self.consec_down = 0;
102        } else if close < prev {
103            self.consec_down += 1;
104            self.consec_up = 0;
105        } else {
106            self.consec_up = 0;
107            self.consec_down = 0;
108        }
109        self.prev_close = Some(close);
110
111        let v = if self.consec_up >= self.count {
112            -1.0
113        } else if self.consec_down >= self.count {
114            1.0
115        } else {
116            0.0
117        };
118        self.last = Some(v);
119        Some(v)
120    }
121
122    fn reset(&mut self) {
123        self.prev_close = None;
124        self.consec_up = 0;
125        self.consec_down = 0;
126        self.last = None;
127    }
128
129    fn warmup_period(&self) -> usize {
130        2
131    }
132
133    fn is_ready(&self) -> bool {
134        self.last.is_some()
135    }
136
137    fn name(&self) -> &'static str {
138        "NewPriceLines"
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::traits::BatchExt;
146
147    fn c(close: f64) -> Candle {
148        Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
149    }
150
151    #[test]
152    fn rejects_small_count() {
153        assert!(matches!(
154            NewPriceLines::new(1),
155            Err(Error::InvalidPeriod { .. })
156        ));
157        assert!(NewPriceLines::new(2).is_ok());
158    }
159
160    #[test]
161    fn accessors_and_metadata() {
162        let n = NewPriceLines::new(8).unwrap();
163        assert_eq!(n.count(), 8);
164        assert_eq!(n.streak(), (0, 0));
165        assert_eq!(n.warmup_period(), 2);
166        assert_eq!(n.name(), "NewPriceLines");
167        assert!(!n.is_ready());
168        assert_eq!(n.value(), None);
169    }
170
171    #[test]
172    fn first_bar_seeds_without_signal() {
173        let mut n = NewPriceLines::new(3).unwrap();
174        assert_eq!(n.update(c(100.0)), None);
175        assert!(n.update(c(101.0)).is_some());
176    }
177
178    #[test]
179    fn eight_higher_closes_signal_sell() {
180        let mut n = NewPriceLines::new(8).unwrap();
181        // 11 consecutive higher closes -> by the 9th the count reaches 8 -> -1.
182        let candles: Vec<Candle> = (0..12).map(|i| c(100.0 + f64::from(i))).collect();
183        let last = n.batch(&candles).into_iter().flatten().last().unwrap();
184        assert_eq!(last, -1.0);
185    }
186
187    #[test]
188    fn eight_lower_closes_signal_buy() {
189        let mut n = NewPriceLines::new(8).unwrap();
190        let candles: Vec<Candle> = (0..12).map(|i| c(200.0 - f64::from(i))).collect();
191        let last = n.batch(&candles).into_iter().flatten().last().unwrap();
192        assert_eq!(last, 1.0);
193    }
194
195    #[test]
196    fn break_in_streak_clears_signal() {
197        let mut n = NewPriceLines::new(3).unwrap();
198        n.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // streak 3 -> -1
199        assert_eq!(n.value(), Some(-1.0));
200        // A lower close breaks the up streak.
201        assert_eq!(n.update(c(102.0)), Some(0.0));
202        assert_eq!(n.streak(), (0, 1));
203    }
204
205    #[test]
206    fn unchanged_close_resets_streak() {
207        let mut n = NewPriceLines::new(3).unwrap();
208        n.batch(&[c(100.0), c(101.0), c(102.0)]);
209        assert_eq!(n.update(c(102.0)), Some(0.0)); // equal -> reset
210        assert_eq!(n.streak(), (0, 0));
211    }
212
213    #[test]
214    fn reset_clears_state() {
215        let mut n = NewPriceLines::new(3).unwrap();
216        n.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]);
217        assert!(n.is_ready());
218        n.reset();
219        assert!(!n.is_ready());
220        assert_eq!(n.value(), None);
221        assert_eq!(n.streak(), (0, 0));
222    }
223
224    #[test]
225    fn batch_equals_streaming() {
226        let candles: Vec<Candle> = (0..80)
227            .map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
228            .collect();
229        let batch = NewPriceLines::new(8).unwrap().batch(&candles);
230        let mut b = NewPriceLines::new(8).unwrap();
231        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
232        assert_eq!(batch, streamed);
233    }
234}