Skip to main content

wickra_core/indicators/
naked_poc.rs

1//! Naked POC โ€” the nearest prior-session point of control price has not yet revisited.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Naked (Virgin) POC โ€” the nearest **untested** point of control from a prior
10/// session: a heavily-traded price the market has not traded back through since.
11///
12/// ```text
13/// every `session_len` candles forms a session; its POC (heaviest-volume price) is
14///   recorded as "naked"
15/// a naked POC becomes "tested" once a later candle's high-low range covers it
16/// output = the nearest still-naked POC to the current close (or the close itself
17///   if every prior POC has been revisited)
18/// ```
19///
20/// A point of control is a magnet โ€” price tends to return to fair value. A *naked*
21/// (or virgin) POC is one that has not yet been revisited, so it carries an
22/// outstanding "pull": untested POCs are high-probability targets and
23/// support/resistance on the approach. This indicator records each completed
24/// session's POC, marks them tested as price trades through them, and reports the
25/// closest one still outstanding.
26///
27/// The first value lands after `session_len` candles (the first session's POC).
28/// Each `update` is O(`session_len ยท bins` + naked-count).
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Candle, Indicator, NakedPoc};
34///
35/// let mut indicator = NakedPoc::new(20, 24).unwrap();
36/// let mut last = None;
37/// for i in 0..60 {
38///     let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
39///     let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
40///     last = indicator.update(c);
41/// }
42/// assert!(last.is_some());
43/// ```
44#[derive(Debug, Clone)]
45pub struct NakedPoc {
46    session_len: usize,
47    bins: usize,
48    session: VecDeque<Candle>,
49    naked: Vec<f64>,
50    last_close: f64,
51    ready: bool,
52    last: Option<f64>,
53}
54
55impl NakedPoc {
56    /// Construct a Naked POC tracker with the given `session_len` and profile
57    /// `bins`.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`Error::PeriodZero`] if `session_len` or `bins` is zero.
62    pub fn new(session_len: usize, bins: usize) -> Result<Self> {
63        if session_len == 0 || bins == 0 {
64            return Err(Error::PeriodZero);
65        }
66        Ok(Self {
67            session_len,
68            bins,
69            session: VecDeque::with_capacity(session_len),
70            naked: Vec::new(),
71            last_close: 0.0,
72            ready: false,
73            last: None,
74        })
75    }
76
77    /// Configured `(session_len, bins)`.
78    pub const fn params(&self) -> (usize, usize) {
79        (self.session_len, self.bins)
80    }
81
82    /// Number of currently-naked POCs.
83    pub fn naked_count(&self) -> usize {
84        self.naked.len()
85    }
86
87    /// Current value if available.
88    pub const fn value(&self) -> Option<f64> {
89        self.last
90    }
91
92    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
93    fn session_poc(&self) -> f64 {
94        let mut low = f64::INFINITY;
95        let mut high = f64::NEG_INFINITY;
96        for c in &self.session {
97            low = low.min(c.low);
98            high = high.max(c.high);
99        }
100        let span = high - low;
101        if span <= 0.0 {
102            return low;
103        }
104        let width = span / self.bins as f64;
105        let mut hist = vec![0.0; self.bins];
106        for c in &self.session {
107            if c.volume == 0.0 {
108                continue;
109            }
110            let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
111            let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
112            let share = c.volume / (hi_idx - lo_idx + 1) as f64;
113            for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
114                *bin += share;
115            }
116        }
117        let mut poc = 0;
118        let mut poc_vol = f64::NEG_INFINITY;
119        for (idx, &vol) in hist.iter().enumerate() {
120            if vol > poc_vol {
121                poc_vol = vol;
122                poc = idx;
123            }
124        }
125        low + (poc as f64 + 0.5) * width
126    }
127}
128
129impl Indicator for NakedPoc {
130    type Input = Candle;
131    type Output = f64;
132
133    #[inline]
134    fn update(&mut self, candle: Candle) -> Option<f64> {
135        // Test outstanding naked POCs against this candle's range.
136        self.naked
137            .retain(|&poc| !(candle.low <= poc && poc <= candle.high));
138        self.last_close = candle.close;
139
140        // Accumulate the session; finalize a POC at the boundary.
141        self.session.push_back(candle);
142        if self.session.len() == self.session_len {
143            let poc = self.session_poc();
144            self.naked.push(poc);
145            self.session.clear();
146            self.ready = true;
147        }
148
149        if !self.ready {
150            return None;
151        }
152        let nearest = self
153            .naked
154            .iter()
155            .copied()
156            .min_by(|a, b| {
157                (a - self.last_close)
158                    .abs()
159                    .total_cmp(&(b - self.last_close).abs())
160            })
161            .unwrap_or(self.last_close);
162        self.last = Some(nearest);
163        Some(nearest)
164    }
165
166    fn reset(&mut self) {
167        self.session.clear();
168        self.naked.clear();
169        self.last_close = 0.0;
170        self.ready = false;
171        self.last = None;
172    }
173
174    #[inline]
175    fn warmup_period(&self) -> usize {
176        self.session_len
177    }
178
179    #[inline]
180    fn is_ready(&self) -> bool {
181        self.last.is_some()
182    }
183
184    #[inline]
185    fn name(&self) -> &'static str {
186        "NakedPoc"
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::traits::BatchExt;
194
195    fn c(high: f64, low: f64, close: f64, volume: f64) -> Candle {
196        Candle::new_unchecked(f64::midpoint(high, low), high, low, close, volume, 0)
197    }
198
199    #[test]
200    fn rejects_zero_params() {
201        assert!(matches!(NakedPoc::new(0, 24), Err(Error::PeriodZero)));
202        assert!(matches!(NakedPoc::new(20, 0), Err(Error::PeriodZero)));
203    }
204
205    #[test]
206    fn accessors_and_metadata() {
207        let n = NakedPoc::new(20, 24).unwrap();
208        assert_eq!(n.params(), (20, 24));
209        assert_eq!(n.naked_count(), 0);
210        assert_eq!(n.warmup_period(), 20);
211        assert_eq!(n.name(), "NakedPoc");
212        assert!(!n.is_ready());
213        assert_eq!(n.value(), None);
214    }
215
216    #[test]
217    fn first_emission_at_session_end() {
218        let mut n = NakedPoc::new(4, 8).unwrap();
219        let candles: Vec<Candle> = (0..6).map(|_| c(101.0, 99.0, 100.0, 1_000.0)).collect();
220        let out = n.batch(&candles);
221        for v in out.iter().take(3) {
222            assert!(v.is_none());
223        }
224        assert!(out[3].is_some());
225    }
226
227    #[test]
228    fn records_session_poc() {
229        let mut n = NakedPoc::new(4, 16).unwrap();
230        // A session clustered around 100 -> POC near 100.
231        n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
232        assert_eq!(n.naked_count(), 1);
233        let poc = n.value().unwrap();
234        assert!(
235            (poc - 100.0).abs() < 2.0,
236            "POC should be near 100, got {poc}"
237        );
238    }
239
240    #[test]
241    fn revisit_marks_poc_tested() {
242        let mut n = NakedPoc::new(4, 16).unwrap();
243        // Session 1 around 100 -> naked POC ~100.
244        n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
245        assert_eq!(n.naked_count(), 1);
246        // Trade away at 120 (does not cover 100) -> still naked.
247        n.update(c(121.0, 119.0, 120.0, 1_000.0));
248        assert_eq!(n.naked_count(), 1);
249        // A candle whose range covers 100 -> POC tested -> removed.
250        n.update(c(121.0, 95.0, 100.0, 1_000.0));
251        assert_eq!(n.naked_count(), 0);
252    }
253
254    #[test]
255    fn empty_naked_reports_close() {
256        let mut n = NakedPoc::new(4, 16).unwrap();
257        n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
258        // Wipe the naked POC with a covering candle.
259        let out = n.update(c(121.0, 95.0, 117.0, 1_000.0)).unwrap();
260        assert_eq!(n.naked_count(), 0);
261        assert!(
262            (out - 117.0).abs() < 1e-9,
263            "with no naked POC, output is the close"
264        );
265    }
266
267    #[test]
268    fn reset_clears_state() {
269        let mut n = NakedPoc::new(4, 8).unwrap();
270        n.batch(&[c(101.0, 99.0, 100.0, 1_000.0); 6]);
271        assert!(n.is_ready());
272        n.reset();
273        assert!(!n.is_ready());
274        assert_eq!(n.value(), None);
275        assert_eq!(n.naked_count(), 0);
276    }
277
278    #[test]
279    fn batch_equals_streaming() {
280        let candles: Vec<Candle> = (0..80)
281            .map(|i| {
282                let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
283                c(b + 1.0, b - 1.0, b, 1_000.0 + f64::from(i))
284            })
285            .collect();
286        let batch = NakedPoc::new(20, 24).unwrap().batch(&candles);
287        let mut b = NakedPoc::new(20, 24).unwrap();
288        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
289        assert_eq!(batch, streamed);
290    }
291
292    #[test]
293    fn flat_session_reports_price() {
294        // A session with zero high-low span returns the session price directly.
295        let mut n = NakedPoc::new(2, 4).unwrap();
296        n.update(c(50.0, 50.0, 50.0, 10.0));
297        assert_eq!(n.update(c(50.0, 50.0, 50.0, 10.0)), Some(50.0));
298    }
299
300    #[test]
301    fn zero_volume_session_is_handled() {
302        // Zero-volume candles are skipped in the histogram; a POC still emits.
303        let mut n = NakedPoc::new(2, 4).unwrap();
304        n.update(c(60.0, 40.0, 50.0, 0.0));
305        assert!(n.update(c(60.0, 40.0, 50.0, 0.0)).is_some());
306    }
307
308    #[test]
309    fn nearest_of_two_naked_pocs() {
310        // Two untouched POCs at distant prices accumulate; the one nearest the
311        // last close is reported (exercises the min-by comparison).
312        let mut n = NakedPoc::new(2, 4).unwrap();
313        n.update(c(11.0, 9.0, 10.0, 100.0));
314        n.update(c(11.0, 9.0, 10.0, 100.0)); // POC near 10
315        n.update(c(101.0, 99.0, 100.0, 100.0));
316        let v = n.update(c(101.0, 99.0, 100.0, 100.0)).unwrap(); // POC near 100
317        assert!(
318            v > 50.0,
319            "nearest to close 100 should be the upper POC, got {v}"
320        );
321    }
322}