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    fn update(&mut self, candle: Candle) -> Option<f64> {
110        if self.window.len() == self.period {
111            self.window.pop_front();
112        }
113        self.window.push_back(candle);
114        if self.window.len() < self.period {
115            return None;
116        }
117        let count = self.count_single_prints() as f64;
118        self.last = Some(count);
119        Some(count)
120    }
121
122    fn reset(&mut self) {
123        self.window.clear();
124        self.last = None;
125    }
126
127    fn warmup_period(&self) -> usize {
128        self.period
129    }
130
131    fn is_ready(&self) -> bool {
132        self.last.is_some()
133    }
134
135    fn name(&self) -> &'static str {
136        "SinglePrints"
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::traits::BatchExt;
144
145    fn c(high: f64, low: f64) -> Candle {
146        Candle::new_unchecked(
147            f64::midpoint(high, low),
148            high,
149            low,
150            f64::midpoint(high, low),
151            1_000.0,
152            0,
153        )
154    }
155
156    #[test]
157    fn rejects_zero_params() {
158        assert!(matches!(SinglePrints::new(0, 24), Err(Error::PeriodZero)));
159        assert!(matches!(SinglePrints::new(20, 0), Err(Error::PeriodZero)));
160    }
161
162    #[test]
163    fn accessors_and_metadata() {
164        let s = SinglePrints::new(20, 24).unwrap();
165        assert_eq!(s.params(), (20, 24));
166        assert_eq!(s.warmup_period(), 20);
167        assert_eq!(s.name(), "SinglePrints");
168        assert!(!s.is_ready());
169        assert_eq!(s.value(), None);
170    }
171
172    #[test]
173    fn first_emission_at_warmup_period() {
174        let mut s = SinglePrints::new(4, 8).unwrap();
175        let candles: Vec<Candle> = (0..6)
176            .map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
177            .collect();
178        let out = s.batch(&candles);
179        for v in out.iter().take(3) {
180            assert!(v.is_none());
181        }
182        assert!(out[3].is_some());
183    }
184
185    #[test]
186    fn flat_range_has_no_single_prints() {
187        // Every bar covers the same single price -> zero span -> 0.
188        let mut s = SinglePrints::new(4, 8).unwrap();
189        let last = s
190            .batch(&[c(100.0, 100.0); 6])
191            .into_iter()
192            .flatten()
193            .last()
194            .unwrap();
195        assert_eq!(last, 0.0);
196    }
197
198    #[test]
199    fn ramp_has_many_single_prints() {
200        // A one-directional ramp visits most levels exactly once.
201        let mut s = SinglePrints::new(10, 24).unwrap();
202        let candles: Vec<Candle> = (0..10)
203            .map(|i| c(100.5 + f64::from(i), 99.5 + f64::from(i)))
204            .collect();
205        let last = s.batch(&candles).into_iter().flatten().last().unwrap();
206        assert!(
207            last > 0.0,
208            "a ramp should produce single prints, got {last}"
209        );
210    }
211
212    #[test]
213    fn output_non_negative() {
214        let mut s = SinglePrints::new(14, 24).unwrap();
215        for v in s
216            .batch(
217                &(0..60)
218                    .map(|i| c(110.0 + (f64::from(i) * 0.3).sin() * 8.0, 90.0))
219                    .collect::<Vec<_>>(),
220            )
221            .into_iter()
222            .flatten()
223        {
224            assert!(v >= 0.0);
225        }
226    }
227
228    #[test]
229    fn reset_clears_state() {
230        let mut s = SinglePrints::new(4, 8).unwrap();
231        s.batch(
232            &(0..6)
233                .map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
234                .collect::<Vec<_>>(),
235        );
236        assert!(s.is_ready());
237        s.reset();
238        assert!(!s.is_ready());
239        assert_eq!(s.value(), None);
240        assert_eq!(s.update(c(101.0, 99.0)), None);
241    }
242
243    #[test]
244    fn batch_equals_streaming() {
245        let candles: Vec<Candle> = (0..80)
246            .map(|i| c(110.0 + (f64::from(i) * 0.25).sin() * 9.0, 90.0))
247            .collect();
248        let batch = SinglePrints::new(20, 24).unwrap().batch(&candles);
249        let mut b = SinglePrints::new(20, 24).unwrap();
250        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
251        assert_eq!(batch, streamed);
252    }
253}