1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone, PartialEq)]
17pub struct TpoProfileOutput {
18 pub price_low: f64,
20 pub price_high: f64,
22 pub counts: Vec<f64>,
24}
25
26#[allow(clippy::struct_field_names)]
56#[derive(Debug, Clone)]
57pub struct TpoProfile {
58 period: usize,
59 bin_count: usize,
60 window: VecDeque<Candle>,
61 last: Option<TpoProfileOutput>,
62}
63
64impl TpoProfile {
65 pub fn new(period: usize, bin_count: usize) -> Result<Self> {
71 if period == 0 || bin_count == 0 {
72 return Err(Error::PeriodZero);
73 }
74 Ok(Self {
75 period,
76 bin_count,
77 window: VecDeque::with_capacity(period),
78 last: None,
79 })
80 }
81
82 pub fn classic() -> Self {
84 Self::new(30, 50).expect("classic TpoProfile params are valid")
85 }
86
87 pub const fn params(&self) -> (usize, usize) {
89 (self.period, self.bin_count)
90 }
91
92 pub const fn width(&self) -> usize {
97 self.bin_count
98 }
99
100 pub fn value(&self) -> Option<&TpoProfileOutput> {
102 self.last.as_ref()
103 }
104
105 fn price_to_bin(&self, price: f64, win_low: f64, bin_width: f64) -> usize {
106 let raw = ((price - win_low) / bin_width).floor();
107 let max = (self.bin_count - 1) as f64;
108 raw.clamp(0.0, max) as usize
109 }
110
111 fn compute(&self) -> TpoProfileOutput {
112 let mut win_low = f64::INFINITY;
113 let mut win_high = f64::NEG_INFINITY;
114 for candle in &self.window {
115 if candle.low < win_low {
116 win_low = candle.low;
117 }
118 if candle.high > win_high {
119 win_high = candle.high;
120 }
121 }
122 let span = win_high - win_low;
123 let mut counts = vec![0.0_f64; self.bin_count];
124
125 if span <= 0.0 {
126 counts[0] = self.window.len() as f64;
128 return TpoProfileOutput {
129 price_low: win_low,
130 price_high: win_low,
131 counts,
132 };
133 }
134
135 let bin_width = span / self.bin_count as f64;
136 for candle in &self.window {
137 if candle.high <= candle.low {
138 let idx = self.price_to_bin(candle.low, win_low, bin_width);
139 counts[idx] += 1.0;
140 continue;
141 }
142 let lo_idx = self.price_to_bin(candle.low, win_low, bin_width);
143 let hi_idx = self.price_to_bin(candle.high, win_low, bin_width);
144 for count in counts.iter_mut().take(hi_idx + 1).skip(lo_idx) {
145 *count += 1.0;
146 }
147 }
148
149 TpoProfileOutput {
150 price_low: win_low,
151 price_high: win_high,
152 counts,
153 }
154 }
155}
156
157impl Indicator for TpoProfile {
158 type Input = Candle;
159 type Output = TpoProfileOutput;
160
161 #[inline]
162 fn update(&mut self, candle: Candle) -> Option<TpoProfileOutput> {
163 if self.window.len() == self.period {
164 self.window.pop_front();
165 }
166 self.window.push_back(candle);
167 if self.window.len() < self.period {
168 return None;
169 }
170 let out = self.compute();
171 self.last = Some(out.clone());
172 Some(out)
173 }
174
175 fn reset(&mut self) {
176 self.window.clear();
177 self.last = None;
178 }
179
180 #[inline]
181 fn warmup_period(&self) -> usize {
182 self.period
183 }
184
185 #[inline]
186 fn is_ready(&self) -> bool {
187 self.last.is_some()
188 }
189
190 #[inline]
191 fn name(&self) -> &'static str {
192 "TpoProfile"
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn width_matches_the_emitted_payload() {
202 let mut ind = TpoProfile::new(10, 20).unwrap();
205 let width = ind.width();
206 let mut emitted = 0;
207 for i in 0..40 {
208 #[allow(clippy::cast_precision_loss)]
209 let step = i as f64;
210 let price = 100.0 + step;
211 let candle =
212 Candle::new(price, price + 1.0, price - 1.0, price, 10.0, i * 3_600_000).unwrap();
213 if let Some(out) = ind.update(candle) {
214 assert_eq!(out.counts.len(), width);
215 emitted += 1;
216 }
217 }
218 assert!(emitted > 0, "the fixture must clear warmup");
219 }
220 use crate::traits::BatchExt;
221 use approx::assert_relative_eq;
222
223 fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
224 Candle::new(open, high, low, close, volume, ts).unwrap()
225 }
226
227 #[test]
228 fn rejects_zero_period() {
229 assert!(matches!(TpoProfile::new(0, 50), Err(Error::PeriodZero)));
230 }
231
232 #[test]
233 fn rejects_zero_bin_count() {
234 assert!(matches!(TpoProfile::new(20, 0), Err(Error::PeriodZero)));
235 }
236
237 #[test]
238 fn accessors_and_metadata() {
239 let tpo = TpoProfile::new(30, 50).unwrap();
240 assert_eq!(tpo.name(), "TpoProfile");
241 assert_eq!(tpo.warmup_period(), 30);
242 assert_eq!(tpo.params(), (30, 50));
243 assert!(tpo.value().is_none());
244 assert!(!tpo.is_ready());
245 }
246
247 #[test]
248 fn classic_params() {
249 let tpo = TpoProfile::classic();
250 assert_eq!(tpo.params(), (30, 50));
251 }
252
253 #[test]
254 fn warms_up_over_period() {
255 let mut tpo = TpoProfile::new(3, 4).unwrap();
256 assert!(tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0)).is_none());
257 assert!(tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1)).is_none());
258 assert!(tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 2)).is_some());
259 assert!(tpo.is_ready());
260 }
261
262 #[test]
263 fn reference_counts() {
264 let mut tpo = TpoProfile::new(2, 4).unwrap();
269 assert!(tpo.update(c(10.0, 14.0, 10.0, 12.0, 5.0, 0)).is_none());
270 let out = tpo.update(c(11.0, 12.0, 11.0, 11.5, 999.0, 1)).unwrap();
271 assert_relative_eq!(out.price_low, 10.0, epsilon = 1e-12);
272 assert_relative_eq!(out.price_high, 14.0, epsilon = 1e-12);
273 assert_eq!(out.counts.len(), 4);
274 assert_relative_eq!(out.counts[0], 1.0, epsilon = 1e-12);
275 assert_relative_eq!(out.counts[1], 2.0, epsilon = 1e-12);
276 assert_relative_eq!(out.counts[2], 2.0, epsilon = 1e-12);
277 assert_relative_eq!(out.counts[3], 1.0, epsilon = 1e-12);
278 }
279
280 #[test]
281 fn volume_independent() {
282 let mut a = TpoProfile::new(2, 4).unwrap();
284 let mut b = TpoProfile::new(2, 4).unwrap();
285 a.update(c(10.0, 14.0, 10.0, 12.0, 1.0, 0));
286 let out_a = a.update(c(10.0, 14.0, 10.0, 12.0, 1.0, 1)).unwrap();
287 b.update(c(10.0, 14.0, 10.0, 12.0, 9_999.0, 0));
288 let out_b = b.update(c(10.0, 14.0, 10.0, 12.0, 9_999.0, 1)).unwrap();
289 assert_eq!(out_a.counts, out_b.counts);
290 }
291
292 #[test]
293 fn degenerate_single_price_window() {
294 let mut tpo = TpoProfile::new(3, 4).unwrap();
295 tpo.update(c(50.0, 50.0, 50.0, 50.0, 10.0, 0));
296 tpo.update(c(50.0, 50.0, 50.0, 50.0, 20.0, 1));
297 let out = tpo.update(c(50.0, 50.0, 50.0, 50.0, 30.0, 2)).unwrap();
298 assert_relative_eq!(out.price_low, 50.0, epsilon = 1e-12);
299 assert_relative_eq!(out.price_high, 50.0, epsilon = 1e-12);
300 assert_relative_eq!(out.counts[0], 3.0, epsilon = 1e-12);
301 assert_relative_eq!(out.counts[1], 0.0, epsilon = 1e-12);
302 }
303
304 #[test]
305 fn single_print_bar_marks_one_bin() {
306 let mut tpo = TpoProfile::new(2, 4).unwrap();
308 tpo.update(c(10.0, 14.0, 10.0, 12.0, 5.0, 0)); let out = tpo.update(c(13.0, 13.0, 13.0, 13.0, 5.0, 1)).unwrap();
310 assert_relative_eq!(out.counts[3], 2.0, epsilon = 1e-12);
312 }
313
314 #[test]
315 fn reset_clears_state() {
316 let mut tpo = TpoProfile::new(2, 4).unwrap();
317 tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0));
318 tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1));
319 assert!(tpo.is_ready());
320 tpo.reset();
321 assert!(!tpo.is_ready());
322 assert!(tpo.value().is_none());
323 }
324
325 #[test]
326 fn batch_equals_streaming() {
327 let candles: Vec<Candle> = (0..30)
328 .map(|i| {
329 let base = 100.0 + f64::from(i % 7);
330 c(
331 base,
332 base + 2.0,
333 base - 2.0,
334 base,
335 10.0 + f64::from(i),
336 i64::from(i),
337 )
338 })
339 .collect();
340 let mut a = TpoProfile::new(10, 16).unwrap();
341 let mut b = TpoProfile::new(10, 16).unwrap();
342 assert_eq!(
343 a.batch(&candles),
344 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
345 );
346 }
347}