1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct CompositeProfileOutput {
12 pub poc: f64,
14 pub vah: f64,
16 pub val: f64,
18}
19
20#[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 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 pub const fn params(&self) -> (usize, usize, f64) {
91 (self.period, self.bins, self.value_area_pct)
92 }
93
94 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 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 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 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 let mut cp = CompositeProfile::new(2, 3, 0.9).unwrap();
347 cp.update(c(100.0, 0.0, 30.0)); let out = cp.update(c(100.0, 67.0, 60.0)).unwrap(); assert!(out.val <= out.poc && out.poc <= out.vah);
350 }
351}