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 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 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 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 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 let mut cp = CompositeProfile::new(2, 3, 0.9).unwrap();
343 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);
346 }
347}