Skip to main content

wickra_core/indicators/
single_prints.rs

1//! Single Prints โ€” count of price levels touched by exactly one bar (low acceptance).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Single Prints โ€” the number of price levels (bins) in the rolling profile that
10/// were touched by **exactly one** bar, marking zones of low acceptance / fast
11/// movement.
12///
13/// ```text
14/// for each of `bins` price levels over the last `period` candles:
15///   touches = number of bars whose high-low range covers that level
16/// SinglePrints = count of levels with touches == 1
17/// ```
18///
19/// In Market Profile a "single print" is a price the market traded through so
20/// quickly that only one time-period printed there โ€” a footprint of an aggressive,
21/// one-sided move with little two-way trade. Single prints often act as support or
22/// resistance on a retest (the imbalance gets "repaired") and mark the edges of
23/// rapid moves. Counting them per profile gives a streaming gauge of how much of
24/// the recent range was traversed without acceptance: a high count means a fast,
25/// trending, low-rotation market; a low count means a balanced, well-traded range.
26///
27/// The output is a non-negative count. The first value lands after `period`
28/// candles; each `update` rebuilds the touch histogram in O(`period ยท bins`).
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Candle, Indicator, SinglePrints};
34///
35/// let mut indicator = SinglePrints::new(20, 24).unwrap();
36/// let mut last = None;
37/// for i in 0..40 {
38///     let base = 100.0 + f64::from(i); // a one-directional ramp -> many single prints
39///     let c = Candle::new(base, base + 0.5, base - 0.5, base, 1_000.0, 0).unwrap();
40///     last = indicator.update(c);
41/// }
42/// assert!(last.is_some());
43/// ```
44#[derive(Debug, Clone)]
45pub struct SinglePrints {
46    period: usize,
47    bins: usize,
48    window: VecDeque<Candle>,
49    last: Option<f64>,
50}
51
52impl SinglePrints {
53    /// Construct a Single Prints counter.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::PeriodZero`] if `period` or `bins` is zero.
58    pub fn new(period: usize, bins: usize) -> Result<Self> {
59        if period == 0 || bins == 0 {
60            return Err(Error::PeriodZero);
61        }
62        Ok(Self {
63            period,
64            bins,
65            window: VecDeque::with_capacity(period),
66            last: None,
67        })
68    }
69
70    /// Configured `(period, bins)`.
71    pub const fn params(&self) -> (usize, usize) {
72        (self.period, self.bins)
73    }
74
75    /// Current value if available.
76    pub const fn value(&self) -> Option<f64> {
77        self.last
78    }
79
80    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
81    fn count_single_prints(&self) -> usize {
82        let mut low = f64::INFINITY;
83        let mut high = f64::NEG_INFINITY;
84        for c in &self.window {
85            low = low.min(c.low);
86            high = high.max(c.high);
87        }
88        let span = high - low;
89        if span <= 0.0 {
90            return 0;
91        }
92        let width = span / self.bins as f64;
93        let mut touches = vec![0u32; self.bins];
94        for c in &self.window {
95            let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
96            let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
97            for t in touches.iter_mut().take(hi_idx + 1).skip(lo_idx) {
98                *t += 1;
99            }
100        }
101        touches.iter().filter(|&&t| t == 1).count()
102    }
103}
104
105impl Indicator for SinglePrints {
106    type Input = Candle;
107    type Output = f64;
108
109    #[inline]
110    fn update(&mut self, candle: Candle) -> Option<f64> {
111        if self.window.len() == self.period {
112            self.window.pop_front();
113        }
114        self.window.push_back(candle);
115        if self.window.len() < self.period {
116            return None;
117        }
118        let count = self.count_single_prints() as f64;
119        self.last = Some(count);
120        Some(count)
121    }
122
123    fn reset(&mut self) {
124        self.window.clear();
125        self.last = None;
126    }
127
128    #[inline]
129    fn warmup_period(&self) -> usize {
130        self.period
131    }
132
133    #[inline]
134    fn is_ready(&self) -> bool {
135        self.last.is_some()
136    }
137
138    #[inline]
139    fn name(&self) -> &'static str {
140        "SinglePrints"
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::traits::BatchExt;
148
149    fn c(high: f64, low: f64) -> Candle {
150        Candle::new_unchecked(
151            f64::midpoint(high, low),
152            high,
153            low,
154            f64::midpoint(high, low),
155            1_000.0,
156            0,
157        )
158    }
159
160    #[test]
161    fn rejects_zero_params() {
162        assert!(matches!(SinglePrints::new(0, 24), Err(Error::PeriodZero)));
163        assert!(matches!(SinglePrints::new(20, 0), Err(Error::PeriodZero)));
164    }
165
166    #[test]
167    fn accessors_and_metadata() {
168        let s = SinglePrints::new(20, 24).unwrap();
169        assert_eq!(s.params(), (20, 24));
170        assert_eq!(s.warmup_period(), 20);
171        assert_eq!(s.name(), "SinglePrints");
172        assert!(!s.is_ready());
173        assert_eq!(s.value(), None);
174    }
175
176    #[test]
177    fn first_emission_at_warmup_period() {
178        let mut s = SinglePrints::new(4, 8).unwrap();
179        let candles: Vec<Candle> = (0..6)
180            .map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
181            .collect();
182        let out = s.batch(&candles);
183        for v in out.iter().take(3) {
184            assert!(v.is_none());
185        }
186        assert!(out[3].is_some());
187    }
188
189    #[test]
190    fn flat_range_has_no_single_prints() {
191        // Every bar covers the same single price -> zero span -> 0.
192        let mut s = SinglePrints::new(4, 8).unwrap();
193        let last = s
194            .batch(&[c(100.0, 100.0); 6])
195            .into_iter()
196            .flatten()
197            .last()
198            .unwrap();
199        assert_eq!(last, 0.0);
200    }
201
202    #[test]
203    fn ramp_has_many_single_prints() {
204        // A one-directional ramp visits most levels exactly once.
205        let mut s = SinglePrints::new(10, 24).unwrap();
206        let candles: Vec<Candle> = (0..10)
207            .map(|i| c(100.5 + f64::from(i), 99.5 + f64::from(i)))
208            .collect();
209        let last = s.batch(&candles).into_iter().flatten().last().unwrap();
210        assert!(
211            last > 0.0,
212            "a ramp should produce single prints, got {last}"
213        );
214    }
215
216    #[test]
217    fn output_non_negative() {
218        let mut s = SinglePrints::new(14, 24).unwrap();
219        for v in s
220            .batch(
221                &(0..60)
222                    .map(|i| c(110.0 + (f64::from(i) * 0.3).sin() * 8.0, 90.0))
223                    .collect::<Vec<_>>(),
224            )
225            .into_iter()
226            .flatten()
227        {
228            assert!(v >= 0.0);
229        }
230    }
231
232    #[test]
233    fn reset_clears_state() {
234        let mut s = SinglePrints::new(4, 8).unwrap();
235        s.batch(
236            &(0..6)
237                .map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
238                .collect::<Vec<_>>(),
239        );
240        assert!(s.is_ready());
241        s.reset();
242        assert!(!s.is_ready());
243        assert_eq!(s.value(), None);
244        assert_eq!(s.update(c(101.0, 99.0)), None);
245    }
246
247    #[test]
248    fn batch_equals_streaming() {
249        let candles: Vec<Candle> = (0..80)
250            .map(|i| c(110.0 + (f64::from(i) * 0.25).sin() * 9.0, 90.0))
251            .collect();
252        let batch = SinglePrints::new(20, 24).unwrap().batch(&candles);
253        let mut b = SinglePrints::new(20, 24).unwrap();
254        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
255        assert_eq!(batch, streamed);
256    }
257}