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    fn update(&mut self, candle: Candle) -> Option<f64> {
134        // Test outstanding naked POCs against this candle's range.
135        self.naked
136            .retain(|&poc| !(candle.low <= poc && poc <= candle.high));
137        self.last_close = candle.close;
138
139        // Accumulate the session; finalize a POC at the boundary.
140        self.session.push_back(candle);
141        if self.session.len() == self.session_len {
142            let poc = self.session_poc();
143            self.naked.push(poc);
144            self.session.clear();
145            self.ready = true;
146        }
147
148        if !self.ready {
149            return None;
150        }
151        let nearest = self
152            .naked
153            .iter()
154            .copied()
155            .min_by(|a, b| {
156                (a - self.last_close)
157                    .abs()
158                    .total_cmp(&(b - self.last_close).abs())
159            })
160            .unwrap_or(self.last_close);
161        self.last = Some(nearest);
162        Some(nearest)
163    }
164
165    fn reset(&mut self) {
166        self.session.clear();
167        self.naked.clear();
168        self.last_close = 0.0;
169        self.ready = false;
170        self.last = None;
171    }
172
173    fn warmup_period(&self) -> usize {
174        self.session_len
175    }
176
177    fn is_ready(&self) -> bool {
178        self.last.is_some()
179    }
180
181    fn name(&self) -> &'static str {
182        "NakedPoc"
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::traits::BatchExt;
190
191    fn c(high: f64, low: f64, close: f64, volume: f64) -> Candle {
192        Candle::new_unchecked(f64::midpoint(high, low), high, low, close, volume, 0)
193    }
194
195    #[test]
196    fn rejects_zero_params() {
197        assert!(matches!(NakedPoc::new(0, 24), Err(Error::PeriodZero)));
198        assert!(matches!(NakedPoc::new(20, 0), Err(Error::PeriodZero)));
199    }
200
201    #[test]
202    fn accessors_and_metadata() {
203        let n = NakedPoc::new(20, 24).unwrap();
204        assert_eq!(n.params(), (20, 24));
205        assert_eq!(n.naked_count(), 0);
206        assert_eq!(n.warmup_period(), 20);
207        assert_eq!(n.name(), "NakedPoc");
208        assert!(!n.is_ready());
209        assert_eq!(n.value(), None);
210    }
211
212    #[test]
213    fn first_emission_at_session_end() {
214        let mut n = NakedPoc::new(4, 8).unwrap();
215        let candles: Vec<Candle> = (0..6).map(|_| c(101.0, 99.0, 100.0, 1_000.0)).collect();
216        let out = n.batch(&candles);
217        for v in out.iter().take(3) {
218            assert!(v.is_none());
219        }
220        assert!(out[3].is_some());
221    }
222
223    #[test]
224    fn records_session_poc() {
225        let mut n = NakedPoc::new(4, 16).unwrap();
226        // A session clustered around 100 -> POC near 100.
227        n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
228        assert_eq!(n.naked_count(), 1);
229        let poc = n.value().unwrap();
230        assert!(
231            (poc - 100.0).abs() < 2.0,
232            "POC should be near 100, got {poc}"
233        );
234    }
235
236    #[test]
237    fn revisit_marks_poc_tested() {
238        let mut n = NakedPoc::new(4, 16).unwrap();
239        // Session 1 around 100 -> naked POC ~100.
240        n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
241        assert_eq!(n.naked_count(), 1);
242        // Trade away at 120 (does not cover 100) -> still naked.
243        n.update(c(121.0, 119.0, 120.0, 1_000.0));
244        assert_eq!(n.naked_count(), 1);
245        // A candle whose range covers 100 -> POC tested -> removed.
246        n.update(c(121.0, 95.0, 100.0, 1_000.0));
247        assert_eq!(n.naked_count(), 0);
248    }
249
250    #[test]
251    fn empty_naked_reports_close() {
252        let mut n = NakedPoc::new(4, 16).unwrap();
253        n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
254        // Wipe the naked POC with a covering candle.
255        let out = n.update(c(121.0, 95.0, 117.0, 1_000.0)).unwrap();
256        assert_eq!(n.naked_count(), 0);
257        assert!(
258            (out - 117.0).abs() < 1e-9,
259            "with no naked POC, output is the close"
260        );
261    }
262
263    #[test]
264    fn reset_clears_state() {
265        let mut n = NakedPoc::new(4, 8).unwrap();
266        n.batch(&[c(101.0, 99.0, 100.0, 1_000.0); 6]);
267        assert!(n.is_ready());
268        n.reset();
269        assert!(!n.is_ready());
270        assert_eq!(n.value(), None);
271        assert_eq!(n.naked_count(), 0);
272    }
273
274    #[test]
275    fn batch_equals_streaming() {
276        let candles: Vec<Candle> = (0..80)
277            .map(|i| {
278                let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
279                c(b + 1.0, b - 1.0, b, 1_000.0 + f64::from(i))
280            })
281            .collect();
282        let batch = NakedPoc::new(20, 24).unwrap().batch(&candles);
283        let mut b = NakedPoc::new(20, 24).unwrap();
284        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
285        assert_eq!(batch, streamed);
286    }
287
288    #[test]
289    fn flat_session_reports_price() {
290        // A session with zero high-low span returns the session price directly.
291        let mut n = NakedPoc::new(2, 4).unwrap();
292        n.update(c(50.0, 50.0, 50.0, 10.0));
293        assert_eq!(n.update(c(50.0, 50.0, 50.0, 10.0)), Some(50.0));
294    }
295
296    #[test]
297    fn zero_volume_session_is_handled() {
298        // Zero-volume candles are skipped in the histogram; a POC still emits.
299        let mut n = NakedPoc::new(2, 4).unwrap();
300        n.update(c(60.0, 40.0, 50.0, 0.0));
301        assert!(n.update(c(60.0, 40.0, 50.0, 0.0)).is_some());
302    }
303
304    #[test]
305    fn nearest_of_two_naked_pocs() {
306        // Two untouched POCs at distant prices accumulate; the one nearest the
307        // last close is reported (exercises the min-by comparison).
308        let mut n = NakedPoc::new(2, 4).unwrap();
309        n.update(c(11.0, 9.0, 10.0, 100.0));
310        n.update(c(11.0, 9.0, 10.0, 100.0)); // POC near 10
311        n.update(c(101.0, 99.0, 100.0, 100.0));
312        let v = n.update(c(101.0, 99.0, 100.0, 100.0)).unwrap(); // POC near 100
313        assert!(
314            v > 50.0,
315            "nearest to close 100 should be the upper POC, got {v}"
316        );
317    }
318}