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