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    fn update(&mut self, candle: Candle) -> Option<CompositeProfileOutput> {
174        if self.window.len() == self.period {
175            self.window.pop_front();
176        }
177        self.window.push_back(candle);
178        if self.window.len() < self.period {
179            return None;
180        }
181        let out = self.compute();
182        self.last = Some(out);
183        Some(out)
184    }
185
186    fn reset(&mut self) {
187        self.window.clear();
188        self.last = None;
189    }
190
191    fn warmup_period(&self) -> usize {
192        self.period
193    }
194
195    fn is_ready(&self) -> bool {
196        self.last.is_some()
197    }
198
199    fn name(&self) -> &'static str {
200        "CompositeProfile"
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::traits::BatchExt;
208
209    fn c(high: f64, low: f64, volume: f64) -> Candle {
210        Candle::new_unchecked(
211            f64::midpoint(high, low),
212            high,
213            low,
214            f64::midpoint(high, low),
215            volume,
216            0,
217        )
218    }
219
220    #[test]
221    fn rejects_invalid_params() {
222        assert!(matches!(
223            CompositeProfile::new(0, 50, 0.7),
224            Err(Error::PeriodZero)
225        ));
226        assert!(matches!(
227            CompositeProfile::new(100, 0, 0.7),
228            Err(Error::PeriodZero)
229        ));
230        assert!(matches!(
231            CompositeProfile::new(100, 50, 0.0),
232            Err(Error::InvalidParameter { .. })
233        ));
234        assert!(matches!(
235            CompositeProfile::new(100, 50, 1.5),
236            Err(Error::InvalidParameter { .. })
237        ));
238    }
239
240    #[test]
241    fn accessors_and_metadata() {
242        let p = CompositeProfile::new(100, 50, 0.7).unwrap();
243        assert_eq!(p.params(), (100, 50, 0.7));
244        assert_eq!(p.warmup_period(), 100);
245        assert_eq!(p.name(), "CompositeProfile");
246        assert!(!p.is_ready());
247        assert_eq!(p.value(), None);
248    }
249
250    #[test]
251    fn first_emission_at_warmup_period() {
252        let mut p = CompositeProfile::new(4, 8, 0.7).unwrap();
253        let candles: Vec<Candle> = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
254        let out = p.batch(&candles);
255        for v in out.iter().take(3) {
256            assert!(v.is_none());
257        }
258        assert!(out[3].is_some());
259    }
260
261    #[test]
262    fn value_area_brackets_poc() {
263        let mut p = CompositeProfile::new(20, 30, 0.7).unwrap();
264        let candles: Vec<Candle> = (0..40)
265            .map(|i| {
266                c(
267                    110.0 + (f64::from(i) * 0.3).sin() * 8.0,
268                    90.0 + (f64::from(i) * 0.3).cos() * 8.0,
269                    1_000.0,
270                )
271            })
272            .collect();
273        for o in p.batch(&candles).into_iter().flatten() {
274            assert!(o.val <= o.poc && o.poc <= o.vah);
275        }
276    }
277
278    #[test]
279    fn poc_at_heavy_cluster() {
280        // Volume clustered at ~100; thin pokes elsewhere -> POC near 100.
281        let mut p = CompositeProfile::new(6, 30, 0.7).unwrap();
282        let mut candles: Vec<Candle> = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
283        candles.push(c(140.0, 60.0, 50.0));
284        let out = p.batch(&candles).into_iter().flatten().last().unwrap();
285        assert!(
286            (out.poc - 100.0).abs() < 5.0,
287            "POC should sit at the cluster, got {}",
288            out.poc
289        );
290    }
291
292    #[test]
293    fn reset_clears_state() {
294        let mut p = CompositeProfile::new(4, 8, 0.7).unwrap();
295        p.batch(&[c(110.0, 90.0, 1_000.0); 6]);
296        assert!(p.is_ready());
297        p.reset();
298        assert!(!p.is_ready());
299        assert_eq!(p.value(), None);
300        assert_eq!(p.update(c(110.0, 90.0, 1_000.0)), None);
301    }
302
303    #[test]
304    fn batch_equals_streaming() {
305        let candles: Vec<Candle> = (0..120)
306            .map(|i| {
307                c(
308                    110.0 + (f64::from(i) * 0.25).sin() * 9.0,
309                    90.0,
310                    1_000.0 + f64::from(i),
311                )
312            })
313            .collect();
314        let batch = CompositeProfile::new(50, 50, 0.7).unwrap().batch(&candles);
315        let mut b = CompositeProfile::new(50, 50, 0.7).unwrap();
316        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
317        assert_eq!(batch, streamed);
318    }
319
320    #[test]
321    fn flat_window_collapses_to_price() {
322        // Zero high-low span returns the price for POC, VAH and VAL.
323        let mut cp = CompositeProfile::new(2, 4, 0.7).unwrap();
324        cp.update(c(50.0, 50.0, 10.0));
325        let out = cp.update(c(50.0, 50.0, 10.0)).unwrap();
326        assert_eq!(out.poc, out.vah);
327        assert_eq!(out.poc, out.val);
328    }
329
330    #[test]
331    fn zero_volume_window_is_handled() {
332        // Non-flat window of zero-volume candles hits the skip path.
333        let mut cp = CompositeProfile::new(2, 4, 0.7).unwrap();
334        cp.update(c(60.0, 40.0, 0.0));
335        assert!(cp.update(c(60.0, 40.0, 0.0)).is_some());
336    }
337
338    #[test]
339    fn value_area_expands_down_from_top_poc() {
340        // POC sits in the top bin; with a wide value-area target the area runs
341        // out of bins above (the ceiling branch) and keeps expanding downward.
342        let mut cp = CompositeProfile::new(2, 3, 0.9).unwrap();
343        cp.update(c(100.0, 0.0, 30.0)); // thin spread across all three bins
344        let out = cp.update(c(100.0, 67.0, 60.0)).unwrap(); // heavy in the top bin
345        assert!(out.val <= out.poc && out.poc <= out.vah);
346    }
347}