Skip to main content

wickra_core/indicators/
composite_profile.rs

1//! Composite Profile — POC and value area over a long composite window.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Output of [`CompositeProfile`]: the point of control and the value-area bounds.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct CompositeProfileOutput {
12    /// Point of Control — the price (bin centre) with the most volume.
13    pub poc: f64,
14    /// Value-Area High — top of the band holding `value_area_pct` of volume.
15    pub vah: f64,
16    /// Value-Area Low — bottom of that band.
17    pub val: f64,
18}
19
20/// Composite Profile — a multi-session volume profile reduced to its **point of
21/// control** and **value area**, built over a long composite window.
22///
23/// ```text
24/// build a `bins`-bucket volume profile over the last `period` candles
25/// POC = bin with the most volume
26/// expand from the POC, always adding the heavier adjacent bin, until the
27///   accumulated volume reaches `value_area_pct` of the total
28/// VAH / VAL = the highest / lowest price included
29/// ```
30///
31/// A composite profile merges many sessions into one structure to reveal the
32/// dominant value area and control price across a longer horizon — the levels that
33/// matter for swing positioning rather than a single day. The point of control is
34/// the fairest price (heaviest trade); the value area (classically 70% of volume)
35/// brackets where the market spent most of its time. Price inside the value area is
36/// "in balance"; acceptance outside it signals a value migration.
37///
38/// The first value lands after `period` candles; each `update` rebuilds the
39/// profile in O(`period · bins`).
40///
41/// # Example
42///
43/// ```
44/// use wickra_core::{Candle, Indicator, CompositeProfile};
45///
46/// let mut indicator = CompositeProfile::new(100, 50, 0.70).unwrap();
47/// let mut last = None;
48/// for i in 0..150 {
49///     let base = 100.0 + (f64::from(i) * 0.1).sin() * 8.0;
50///     let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
51///     last = indicator.update(c);
52/// }
53/// assert!(last.is_some());
54/// ```
55#[derive(Debug, Clone)]
56pub struct CompositeProfile {
57    period: usize,
58    bins: usize,
59    value_area_pct: f64,
60    window: VecDeque<Candle>,
61    last: Option<CompositeProfileOutput>,
62}
63
64impl CompositeProfile {
65    /// Construct a Composite Profile.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`Error::PeriodZero`] if `period` or `bins` is zero, or
70    /// [`Error::InvalidParameter`] if `value_area_pct` is not in `(0, 1]`.
71    pub fn new(period: usize, bins: usize, value_area_pct: f64) -> Result<Self> {
72        if period == 0 || bins == 0 {
73            return Err(Error::PeriodZero);
74        }
75        if !value_area_pct.is_finite() || value_area_pct <= 0.0 || value_area_pct > 1.0 {
76            return Err(Error::InvalidParameter {
77                message: "value_area_pct must be in (0, 1]",
78            });
79        }
80        Ok(Self {
81            period,
82            bins,
83            value_area_pct,
84            window: VecDeque::with_capacity(period),
85            last: None,
86        })
87    }
88
89    /// Configured `(period, bins, value_area_pct)`.
90    pub const fn params(&self) -> (usize, usize, f64) {
91        (self.period, self.bins, self.value_area_pct)
92    }
93
94    /// Current value if available.
95    pub const fn value(&self) -> Option<CompositeProfileOutput> {
96        self.last
97    }
98
99    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
100    fn compute(&self) -> CompositeProfileOutput {
101        let mut low = f64::INFINITY;
102        let mut high = f64::NEG_INFINITY;
103        for c in &self.window {
104            low = low.min(c.low);
105            high = high.max(c.high);
106        }
107        let span = high - low;
108        if span <= 0.0 {
109            return CompositeProfileOutput {
110                poc: low,
111                vah: low,
112                val: low,
113            };
114        }
115        let width = span / self.bins as f64;
116        let centre = |idx: usize| low + (idx as f64 + 0.5) * width;
117        let mut hist = vec![0.0; self.bins];
118        for c in &self.window {
119            if c.volume == 0.0 {
120                continue;
121            }
122            let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
123            let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
124            let share = c.volume / (hi_idx - lo_idx + 1) as f64;
125            for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
126                *bin += share;
127            }
128        }
129        let total: f64 = hist.iter().sum();
130        let mut poc = 0;
131        let mut poc_vol = f64::NEG_INFINITY;
132        for (idx, &vol) in hist.iter().enumerate() {
133            if vol > poc_vol {
134                poc_vol = vol;
135                poc = idx;
136            }
137        }
138        let target = total * self.value_area_pct;
139        let mut acc = hist[poc];
140        let mut top = poc;
141        let mut bottom = poc;
142        while acc < target && (top < self.bins - 1 || bottom > 0) {
143            let above = if top < self.bins - 1 {
144                hist[top + 1]
145            } else {
146                f64::NEG_INFINITY
147            };
148            let below = if bottom > 0 {
149                hist[bottom - 1]
150            } else {
151                f64::NEG_INFINITY
152            };
153            if above >= below {
154                top += 1;
155                acc += hist[top];
156            } else {
157                bottom -= 1;
158                acc += hist[bottom];
159            }
160        }
161        CompositeProfileOutput {
162            poc: centre(poc),
163            vah: centre(top),
164            val: centre(bottom),
165        }
166    }
167}
168
169impl Indicator for CompositeProfile {
170    type Input = Candle;
171    type Output = CompositeProfileOutput;
172
173    #[inline]
174    fn update(&mut self, candle: Candle) -> Option<CompositeProfileOutput> {
175        if self.window.len() == self.period {
176            self.window.pop_front();
177        }
178        self.window.push_back(candle);
179        if self.window.len() < self.period {
180            return None;
181        }
182        let out = self.compute();
183        self.last = Some(out);
184        Some(out)
185    }
186
187    fn reset(&mut self) {
188        self.window.clear();
189        self.last = None;
190    }
191
192    #[inline]
193    fn warmup_period(&self) -> usize {
194        self.period
195    }
196
197    #[inline]
198    fn is_ready(&self) -> bool {
199        self.last.is_some()
200    }
201
202    #[inline]
203    fn name(&self) -> &'static str {
204        "CompositeProfile"
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::traits::BatchExt;
212
213    fn c(high: f64, low: f64, volume: f64) -> Candle {
214        Candle::new_unchecked(
215            f64::midpoint(high, low),
216            high,
217            low,
218            f64::midpoint(high, low),
219            volume,
220            0,
221        )
222    }
223
224    #[test]
225    fn rejects_invalid_params() {
226        assert!(matches!(
227            CompositeProfile::new(0, 50, 0.7),
228            Err(Error::PeriodZero)
229        ));
230        assert!(matches!(
231            CompositeProfile::new(100, 0, 0.7),
232            Err(Error::PeriodZero)
233        ));
234        assert!(matches!(
235            CompositeProfile::new(100, 50, 0.0),
236            Err(Error::InvalidParameter { .. })
237        ));
238        assert!(matches!(
239            CompositeProfile::new(100, 50, 1.5),
240            Err(Error::InvalidParameter { .. })
241        ));
242    }
243
244    #[test]
245    fn accessors_and_metadata() {
246        let p = CompositeProfile::new(100, 50, 0.7).unwrap();
247        assert_eq!(p.params(), (100, 50, 0.7));
248        assert_eq!(p.warmup_period(), 100);
249        assert_eq!(p.name(), "CompositeProfile");
250        assert!(!p.is_ready());
251        assert_eq!(p.value(), None);
252    }
253
254    #[test]
255    fn first_emission_at_warmup_period() {
256        let mut p = CompositeProfile::new(4, 8, 0.7).unwrap();
257        let candles: Vec<Candle> = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
258        let out = p.batch(&candles);
259        for v in out.iter().take(3) {
260            assert!(v.is_none());
261        }
262        assert!(out[3].is_some());
263    }
264
265    #[test]
266    fn value_area_brackets_poc() {
267        let mut p = CompositeProfile::new(20, 30, 0.7).unwrap();
268        let candles: Vec<Candle> = (0..40)
269            .map(|i| {
270                c(
271                    110.0 + (f64::from(i) * 0.3).sin() * 8.0,
272                    90.0 + (f64::from(i) * 0.3).cos() * 8.0,
273                    1_000.0,
274                )
275            })
276            .collect();
277        for o in p.batch(&candles).into_iter().flatten() {
278            assert!(o.val <= o.poc && o.poc <= o.vah);
279        }
280    }
281
282    #[test]
283    fn poc_at_heavy_cluster() {
284        // Volume clustered at ~100; thin pokes elsewhere -> POC near 100.
285        let mut p = CompositeProfile::new(6, 30, 0.7).unwrap();
286        let mut candles: Vec<Candle> = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
287        candles.push(c(140.0, 60.0, 50.0));
288        let out = p.batch(&candles).into_iter().flatten().last().unwrap();
289        assert!(
290            (out.poc - 100.0).abs() < 5.0,
291            "POC should sit at the cluster, got {}",
292            out.poc
293        );
294    }
295
296    #[test]
297    fn reset_clears_state() {
298        let mut p = CompositeProfile::new(4, 8, 0.7).unwrap();
299        p.batch(&[c(110.0, 90.0, 1_000.0); 6]);
300        assert!(p.is_ready());
301        p.reset();
302        assert!(!p.is_ready());
303        assert_eq!(p.value(), None);
304        assert_eq!(p.update(c(110.0, 90.0, 1_000.0)), None);
305    }
306
307    #[test]
308    fn batch_equals_streaming() {
309        let candles: Vec<Candle> = (0..120)
310            .map(|i| {
311                c(
312                    110.0 + (f64::from(i) * 0.25).sin() * 9.0,
313                    90.0,
314                    1_000.0 + f64::from(i),
315                )
316            })
317            .collect();
318        let batch = CompositeProfile::new(50, 50, 0.7).unwrap().batch(&candles);
319        let mut b = CompositeProfile::new(50, 50, 0.7).unwrap();
320        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
321        assert_eq!(batch, streamed);
322    }
323
324    #[test]
325    fn flat_window_collapses_to_price() {
326        // Zero high-low span returns the price for POC, VAH and VAL.
327        let mut cp = CompositeProfile::new(2, 4, 0.7).unwrap();
328        cp.update(c(50.0, 50.0, 10.0));
329        let out = cp.update(c(50.0, 50.0, 10.0)).unwrap();
330        assert_eq!(out.poc, out.vah);
331        assert_eq!(out.poc, out.val);
332    }
333
334    #[test]
335    fn zero_volume_window_is_handled() {
336        // Non-flat window of zero-volume candles hits the skip path.
337        let mut cp = CompositeProfile::new(2, 4, 0.7).unwrap();
338        cp.update(c(60.0, 40.0, 0.0));
339        assert!(cp.update(c(60.0, 40.0, 0.0)).is_some());
340    }
341
342    #[test]
343    fn value_area_expands_down_from_top_poc() {
344        // POC sits in the top bin; with a wide value-area target the area runs
345        // out of bins above (the ceiling branch) and keeps expanding downward.
346        let mut cp = CompositeProfile::new(2, 3, 0.9).unwrap();
347        cp.update(c(100.0, 0.0, 30.0)); // thin spread across all three bins
348        let out = cp.update(c(100.0, 67.0, 60.0)).unwrap(); // heavy in the top bin
349        assert!(out.val <= out.poc && out.poc <= out.vah);
350    }
351}