Skip to main content

wickra_core/indicators/
profile_shape.rs

1//! Profile Shape — classifies the volume profile as b-shape, P-shape, or D/normal.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Profile Shape — classifies a rolling volume profile by where its point of
10/// control (POC) sits within the range: `b`, `P`, or `D` (normal).
11///
12/// ```text
13/// build a `bins`-bucket volume profile over the last `period` candles
14/// poc_idx = bin with the most volume
15/// +1  P-shape : POC in the upper third  (heavy top, thin tail down) — short-covering / accumulation
16/// −1  b-shape : POC in the lower third  (heavy bottom, thin tail up) — long-liquidation / distribution
17///  0  D/normal: POC in the middle third (balanced bell)
18/// ```
19///
20/// Market Profile readers classify the day's shape by the location of the heaviest
21/// trading. A **P-shape** (control high, a thin tail beneath) typically marks
22/// short-covering or the start of accumulation; a **b-shape** (control low, thin
23/// tail above) marks long liquidation or distribution; a **D-shape** is a balanced,
24/// two-sided day. Reducing the profile to this three-way code gives a compact,
25/// streaming read of market posture.
26///
27/// The output is `+1` / `0` / `−1`. The first value lands after `period` candles;
28/// each `update` rebuilds the profile in O(`period · bins`).
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Candle, Indicator, ProfileShape};
34///
35/// let mut indicator = ProfileShape::new(20, 24).unwrap();
36/// let mut last = None;
37/// for i in 0..40 {
38///     let base = 100.0 + (f64::from(i) * 0.3).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 ProfileShape {
46    period: usize,
47    bins: usize,
48    window: VecDeque<Candle>,
49    last: Option<f64>,
50}
51
52impl ProfileShape {
53    /// Construct a Profile Shape classifier.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::PeriodZero`] if `period` is zero, or
58    /// [`Error::InvalidPeriod`] if `bins < 3` (the three-way split needs three
59    /// zones).
60    pub fn new(period: usize, bins: usize) -> Result<Self> {
61        if period == 0 {
62            return Err(Error::PeriodZero);
63        }
64        if period > crate::error::MAX_PERIOD {
65            return Err(Error::InvalidPeriod {
66                message: crate::error::PERIOD_ABOVE_MAX,
67            });
68        }
69        if bins < 3 {
70            return Err(Error::InvalidPeriod {
71                message: "profile shape needs bins >= 3",
72            });
73        }
74        if bins > crate::error::MAX_PERIOD {
75            return Err(Error::InvalidPeriod {
76                message: crate::error::PERIOD_ABOVE_MAX,
77            });
78        }
79        Ok(Self {
80            period,
81            bins,
82            window: VecDeque::with_capacity(period),
83            last: None,
84        })
85    }
86
87    /// Configured `(period, bins)`.
88    pub const fn params(&self) -> (usize, usize) {
89        (self.period, self.bins)
90    }
91
92    /// Current value if available.
93    pub const fn value(&self) -> Option<f64> {
94        self.last
95    }
96
97    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
98    fn poc_index(&self) -> usize {
99        let mut low = f64::INFINITY;
100        let mut high = f64::NEG_INFINITY;
101        for c in &self.window {
102            low = low.min(c.low);
103            high = high.max(c.high);
104        }
105        let mut hist = vec![0.0; self.bins];
106        let span = high - low;
107        if span > 0.0 {
108            let width = span / self.bins as f64;
109            for c in &self.window {
110                if c.volume == 0.0 {
111                    continue;
112                }
113                let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
114                let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
115                let share = c.volume / (hi_idx - lo_idx + 1) as f64;
116                for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
117                    *bin += share;
118                }
119            }
120        }
121        let mut poc_idx = 0;
122        let mut poc_vol = f64::NEG_INFINITY;
123        for (idx, &vol) in hist.iter().enumerate() {
124            if vol > poc_vol {
125                poc_vol = vol;
126                poc_idx = idx;
127            }
128        }
129        poc_idx
130    }
131}
132
133impl Indicator for ProfileShape {
134    type Input = Candle;
135    type Output = f64;
136
137    #[inline]
138    fn update(&mut self, candle: Candle) -> Option<f64> {
139        if self.window.len() == self.period {
140            self.window.pop_front();
141        }
142        self.window.push_back(candle);
143        if self.window.len() < self.period {
144            return None;
145        }
146        let poc = self.poc_index();
147        let lower = self.bins / 3;
148        let upper = self.bins - self.bins / 3;
149        let shape = if poc >= upper {
150            1.0
151        } else if poc < lower {
152            -1.0
153        } else {
154            0.0
155        };
156        self.last = Some(shape);
157        Some(shape)
158    }
159
160    fn reset(&mut self) {
161        self.window.clear();
162        self.last = None;
163    }
164
165    #[inline]
166    fn warmup_period(&self) -> usize {
167        self.period
168    }
169
170    #[inline]
171    fn is_ready(&self) -> bool {
172        self.last.is_some()
173    }
174
175    #[inline]
176    fn name(&self) -> &'static str {
177        "ProfileShape"
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::traits::BatchExt;
185
186    fn c(high: f64, low: f64, volume: f64) -> Candle {
187        Candle::new_unchecked(
188            f64::midpoint(high, low),
189            high,
190            low,
191            f64::midpoint(high, low),
192            volume,
193            0,
194        )
195    }
196
197    #[test]
198    fn rejects_invalid_params() {
199        assert!(matches!(ProfileShape::new(0, 24), Err(Error::PeriodZero)));
200        assert!(matches!(
201            ProfileShape::new(20, 2),
202            Err(Error::InvalidPeriod { .. })
203        ));
204    }
205
206    #[test]
207    fn accessors_and_metadata() {
208        let p = ProfileShape::new(20, 24).unwrap();
209        assert_eq!(p.params(), (20, 24));
210        assert_eq!(p.warmup_period(), 20);
211        assert_eq!(p.name(), "ProfileShape");
212        assert!(!p.is_ready());
213        assert_eq!(p.value(), None);
214    }
215
216    #[test]
217    fn first_emission_at_warmup_period() {
218        let mut p = ProfileShape::new(4, 9).unwrap();
219        let candles: Vec<Candle> = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
220        let out = p.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 heavy_top_is_p_shape() {
229        // Volume concentrated near the top of the range -> P-shape -> +1.
230        let mut p = ProfileShape::new(6, 9).unwrap();
231        let mut candles: Vec<Candle> = (0..5).map(|_| c(119.0, 117.0, 5_000.0)).collect();
232        candles.push(c(119.0, 80.0, 50.0)); // a thin tail down to 80
233        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
234        assert_eq!(last, 1.0);
235    }
236
237    #[test]
238    fn heavy_bottom_is_b_shape() {
239        let mut p = ProfileShape::new(6, 9).unwrap();
240        let mut candles: Vec<Candle> = (0..5).map(|_| c(83.0, 81.0, 5_000.0)).collect();
241        candles.push(c(120.0, 81.0, 50.0)); // a thin tail up to 120
242        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
243        assert_eq!(last, -1.0);
244    }
245
246    #[test]
247    fn balanced_is_d_shape() {
248        // Volume concentrated in the middle -> D/normal -> 0.
249        let mut p = ProfileShape::new(6, 9).unwrap();
250        let mut candles: Vec<Candle> = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
251        candles.push(c(120.0, 80.0, 50.0)); // thin tails both ways, POC in the middle
252        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
253        assert_eq!(last, 0.0);
254    }
255
256    #[test]
257    fn reset_clears_state() {
258        let mut p = ProfileShape::new(4, 9).unwrap();
259        p.batch(&[c(110.0, 90.0, 1_000.0); 6]);
260        assert!(p.is_ready());
261        p.reset();
262        assert!(!p.is_ready());
263        assert_eq!(p.value(), None);
264        assert_eq!(p.update(c(110.0, 90.0, 1_000.0)), None);
265    }
266
267    #[test]
268    fn batch_equals_streaming() {
269        let candles: Vec<Candle> = (0..80)
270            .map(|i| {
271                c(
272                    110.0 + (f64::from(i) * 0.25).sin() * 9.0,
273                    90.0,
274                    1_000.0 + f64::from(i),
275                )
276            })
277            .collect();
278        let batch = ProfileShape::new(20, 24).unwrap().batch(&candles);
279        let mut b = ProfileShape::new(20, 24).unwrap();
280        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
281        assert_eq!(batch, streamed);
282    }
283
284    #[test]
285    fn flat_window_is_handled() {
286        // Zero high-low span skips the histogram pass entirely.
287        let mut p = ProfileShape::new(2, 4).unwrap();
288        p.update(c(50.0, 50.0, 10.0));
289        assert!(p.update(c(50.0, 50.0, 10.0)).is_some());
290    }
291
292    #[test]
293    fn zero_volume_window_is_handled() {
294        // Non-flat window of zero-volume candles hits the skip path.
295        let mut p = ProfileShape::new(2, 4).unwrap();
296        p.update(c(60.0, 40.0, 0.0));
297        assert!(p.update(c(60.0, 40.0, 0.0)).is_some());
298    }
299}