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 bins < 3 {
65            return Err(Error::InvalidPeriod {
66                message: "profile shape needs bins >= 3",
67            });
68        }
69        Ok(Self {
70            period,
71            bins,
72            window: VecDeque::with_capacity(period),
73            last: None,
74        })
75    }
76
77    /// Configured `(period, bins)`.
78    pub const fn params(&self) -> (usize, usize) {
79        (self.period, self.bins)
80    }
81
82    /// Current value if available.
83    pub const fn value(&self) -> Option<f64> {
84        self.last
85    }
86
87    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
88    fn poc_index(&self) -> usize {
89        let mut low = f64::INFINITY;
90        let mut high = f64::NEG_INFINITY;
91        for c in &self.window {
92            low = low.min(c.low);
93            high = high.max(c.high);
94        }
95        let mut hist = vec![0.0; self.bins];
96        let span = high - low;
97        if span > 0.0 {
98            let width = span / self.bins as f64;
99            for c in &self.window {
100                if c.volume == 0.0 {
101                    continue;
102                }
103                let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
104                let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
105                let share = c.volume / (hi_idx - lo_idx + 1) as f64;
106                for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
107                    *bin += share;
108                }
109            }
110        }
111        let mut poc_idx = 0;
112        let mut poc_vol = f64::NEG_INFINITY;
113        for (idx, &vol) in hist.iter().enumerate() {
114            if vol > poc_vol {
115                poc_vol = vol;
116                poc_idx = idx;
117            }
118        }
119        poc_idx
120    }
121}
122
123impl Indicator for ProfileShape {
124    type Input = Candle;
125    type Output = f64;
126
127    fn update(&mut self, candle: Candle) -> Option<f64> {
128        if self.window.len() == self.period {
129            self.window.pop_front();
130        }
131        self.window.push_back(candle);
132        if self.window.len() < self.period {
133            return None;
134        }
135        let poc = self.poc_index();
136        let lower = self.bins / 3;
137        let upper = self.bins - self.bins / 3;
138        let shape = if poc >= upper {
139            1.0
140        } else if poc < lower {
141            -1.0
142        } else {
143            0.0
144        };
145        self.last = Some(shape);
146        Some(shape)
147    }
148
149    fn reset(&mut self) {
150        self.window.clear();
151        self.last = None;
152    }
153
154    fn warmup_period(&self) -> usize {
155        self.period
156    }
157
158    fn is_ready(&self) -> bool {
159        self.last.is_some()
160    }
161
162    fn name(&self) -> &'static str {
163        "ProfileShape"
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::traits::BatchExt;
171
172    fn c(high: f64, low: f64, volume: f64) -> Candle {
173        Candle::new_unchecked(
174            f64::midpoint(high, low),
175            high,
176            low,
177            f64::midpoint(high, low),
178            volume,
179            0,
180        )
181    }
182
183    #[test]
184    fn rejects_invalid_params() {
185        assert!(matches!(ProfileShape::new(0, 24), Err(Error::PeriodZero)));
186        assert!(matches!(
187            ProfileShape::new(20, 2),
188            Err(Error::InvalidPeriod { .. })
189        ));
190    }
191
192    #[test]
193    fn accessors_and_metadata() {
194        let p = ProfileShape::new(20, 24).unwrap();
195        assert_eq!(p.params(), (20, 24));
196        assert_eq!(p.warmup_period(), 20);
197        assert_eq!(p.name(), "ProfileShape");
198        assert!(!p.is_ready());
199        assert_eq!(p.value(), None);
200    }
201
202    #[test]
203    fn first_emission_at_warmup_period() {
204        let mut p = ProfileShape::new(4, 9).unwrap();
205        let candles: Vec<Candle> = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
206        let out = p.batch(&candles);
207        for v in out.iter().take(3) {
208            assert!(v.is_none());
209        }
210        assert!(out[3].is_some());
211    }
212
213    #[test]
214    fn heavy_top_is_p_shape() {
215        // Volume concentrated near the top of the range -> P-shape -> +1.
216        let mut p = ProfileShape::new(6, 9).unwrap();
217        let mut candles: Vec<Candle> = (0..5).map(|_| c(119.0, 117.0, 5_000.0)).collect();
218        candles.push(c(119.0, 80.0, 50.0)); // a thin tail down to 80
219        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
220        assert_eq!(last, 1.0);
221    }
222
223    #[test]
224    fn heavy_bottom_is_b_shape() {
225        let mut p = ProfileShape::new(6, 9).unwrap();
226        let mut candles: Vec<Candle> = (0..5).map(|_| c(83.0, 81.0, 5_000.0)).collect();
227        candles.push(c(120.0, 81.0, 50.0)); // a thin tail up to 120
228        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
229        assert_eq!(last, -1.0);
230    }
231
232    #[test]
233    fn balanced_is_d_shape() {
234        // Volume concentrated in the middle -> D/normal -> 0.
235        let mut p = ProfileShape::new(6, 9).unwrap();
236        let mut candles: Vec<Candle> = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
237        candles.push(c(120.0, 80.0, 50.0)); // thin tails both ways, POC in the middle
238        let last = p.batch(&candles).into_iter().flatten().last().unwrap();
239        assert_eq!(last, 0.0);
240    }
241
242    #[test]
243    fn reset_clears_state() {
244        let mut p = ProfileShape::new(4, 9).unwrap();
245        p.batch(&[c(110.0, 90.0, 1_000.0); 6]);
246        assert!(p.is_ready());
247        p.reset();
248        assert!(!p.is_ready());
249        assert_eq!(p.value(), None);
250        assert_eq!(p.update(c(110.0, 90.0, 1_000.0)), None);
251    }
252
253    #[test]
254    fn batch_equals_streaming() {
255        let candles: Vec<Candle> = (0..80)
256            .map(|i| {
257                c(
258                    110.0 + (f64::from(i) * 0.25).sin() * 9.0,
259                    90.0,
260                    1_000.0 + f64::from(i),
261                )
262            })
263            .collect();
264        let batch = ProfileShape::new(20, 24).unwrap().batch(&candles);
265        let mut b = ProfileShape::new(20, 24).unwrap();
266        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
267        assert_eq!(batch, streamed);
268    }
269
270    #[test]
271    fn flat_window_is_handled() {
272        // Zero high-low span skips the histogram pass entirely.
273        let mut p = ProfileShape::new(2, 4).unwrap();
274        p.update(c(50.0, 50.0, 10.0));
275        assert!(p.update(c(50.0, 50.0, 10.0)).is_some());
276    }
277
278    #[test]
279    fn zero_volume_window_is_handled() {
280        // Non-flat window of zero-volume candles hits the skip path.
281        let mut p = ProfileShape::new(2, 4).unwrap();
282        p.update(c(60.0, 40.0, 0.0));
283        assert!(p.update(c(60.0, 40.0, 0.0)).is_some());
284    }
285}