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