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 fn value(&self) -> Option<&TpoProfileOutput> {
94 self.last.as_ref()
95 }
96
97 fn price_to_bin(&self, price: f64, win_low: f64, bin_width: f64) -> usize {
98 let raw = ((price - win_low) / bin_width).floor();
99 let max = (self.bin_count - 1) as f64;
100 raw.clamp(0.0, max) as usize
101 }
102
103 fn compute(&self) -> TpoProfileOutput {
104 let mut win_low = f64::INFINITY;
105 let mut win_high = f64::NEG_INFINITY;
106 for candle in &self.window {
107 if candle.low < win_low {
108 win_low = candle.low;
109 }
110 if candle.high > win_high {
111 win_high = candle.high;
112 }
113 }
114 let span = win_high - win_low;
115 let mut counts = vec![0.0_f64; self.bin_count];
116
117 if span <= 0.0 {
118 counts[0] = self.window.len() as f64;
120 return TpoProfileOutput {
121 price_low: win_low,
122 price_high: win_low,
123 counts,
124 };
125 }
126
127 let bin_width = span / self.bin_count as f64;
128 for candle in &self.window {
129 if candle.high <= candle.low {
130 let idx = self.price_to_bin(candle.low, win_low, bin_width);
131 counts[idx] += 1.0;
132 continue;
133 }
134 let lo_idx = self.price_to_bin(candle.low, win_low, bin_width);
135 let hi_idx = self.price_to_bin(candle.high, win_low, bin_width);
136 for count in counts.iter_mut().take(hi_idx + 1).skip(lo_idx) {
137 *count += 1.0;
138 }
139 }
140
141 TpoProfileOutput {
142 price_low: win_low,
143 price_high: win_high,
144 counts,
145 }
146 }
147}
148
149impl Indicator for TpoProfile {
150 type Input = Candle;
151 type Output = TpoProfileOutput;
152
153 fn update(&mut self, candle: Candle) -> Option<TpoProfileOutput> {
154 if self.window.len() == self.period {
155 self.window.pop_front();
156 }
157 self.window.push_back(candle);
158 if self.window.len() < self.period {
159 return None;
160 }
161 let out = self.compute();
162 self.last = Some(out.clone());
163 Some(out)
164 }
165
166 fn reset(&mut self) {
167 self.window.clear();
168 self.last = None;
169 }
170
171 fn warmup_period(&self) -> usize {
172 self.period
173 }
174
175 fn is_ready(&self) -> bool {
176 self.last.is_some()
177 }
178
179 fn name(&self) -> &'static str {
180 "TpoProfile"
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::traits::BatchExt;
188 use approx::assert_relative_eq;
189
190 fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
191 Candle::new(open, high, low, close, volume, ts).unwrap()
192 }
193
194 #[test]
195 fn rejects_zero_period() {
196 assert!(matches!(TpoProfile::new(0, 50), Err(Error::PeriodZero)));
197 }
198
199 #[test]
200 fn rejects_zero_bin_count() {
201 assert!(matches!(TpoProfile::new(20, 0), Err(Error::PeriodZero)));
202 }
203
204 #[test]
205 fn accessors_and_metadata() {
206 let tpo = TpoProfile::new(30, 50).unwrap();
207 assert_eq!(tpo.name(), "TpoProfile");
208 assert_eq!(tpo.warmup_period(), 30);
209 assert_eq!(tpo.params(), (30, 50));
210 assert!(tpo.value().is_none());
211 assert!(!tpo.is_ready());
212 }
213
214 #[test]
215 fn classic_params() {
216 let tpo = TpoProfile::classic();
217 assert_eq!(tpo.params(), (30, 50));
218 }
219
220 #[test]
221 fn warms_up_over_period() {
222 let mut tpo = TpoProfile::new(3, 4).unwrap();
223 assert!(tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0)).is_none());
224 assert!(tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1)).is_none());
225 assert!(tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 2)).is_some());
226 assert!(tpo.is_ready());
227 }
228
229 #[test]
230 fn reference_counts() {
231 let mut tpo = TpoProfile::new(2, 4).unwrap();
236 assert!(tpo.update(c(10.0, 14.0, 10.0, 12.0, 5.0, 0)).is_none());
237 let out = tpo.update(c(11.0, 12.0, 11.0, 11.5, 999.0, 1)).unwrap();
238 assert_relative_eq!(out.price_low, 10.0, epsilon = 1e-12);
239 assert_relative_eq!(out.price_high, 14.0, epsilon = 1e-12);
240 assert_eq!(out.counts.len(), 4);
241 assert_relative_eq!(out.counts[0], 1.0, epsilon = 1e-12);
242 assert_relative_eq!(out.counts[1], 2.0, epsilon = 1e-12);
243 assert_relative_eq!(out.counts[2], 2.0, epsilon = 1e-12);
244 assert_relative_eq!(out.counts[3], 1.0, epsilon = 1e-12);
245 }
246
247 #[test]
248 fn volume_independent() {
249 let mut a = TpoProfile::new(2, 4).unwrap();
251 let mut b = TpoProfile::new(2, 4).unwrap();
252 a.update(c(10.0, 14.0, 10.0, 12.0, 1.0, 0));
253 let out_a = a.update(c(10.0, 14.0, 10.0, 12.0, 1.0, 1)).unwrap();
254 b.update(c(10.0, 14.0, 10.0, 12.0, 9_999.0, 0));
255 let out_b = b.update(c(10.0, 14.0, 10.0, 12.0, 9_999.0, 1)).unwrap();
256 assert_eq!(out_a.counts, out_b.counts);
257 }
258
259 #[test]
260 fn degenerate_single_price_window() {
261 let mut tpo = TpoProfile::new(3, 4).unwrap();
262 tpo.update(c(50.0, 50.0, 50.0, 50.0, 10.0, 0));
263 tpo.update(c(50.0, 50.0, 50.0, 50.0, 20.0, 1));
264 let out = tpo.update(c(50.0, 50.0, 50.0, 50.0, 30.0, 2)).unwrap();
265 assert_relative_eq!(out.price_low, 50.0, epsilon = 1e-12);
266 assert_relative_eq!(out.price_high, 50.0, epsilon = 1e-12);
267 assert_relative_eq!(out.counts[0], 3.0, epsilon = 1e-12);
268 assert_relative_eq!(out.counts[1], 0.0, epsilon = 1e-12);
269 }
270
271 #[test]
272 fn single_print_bar_marks_one_bin() {
273 let mut tpo = TpoProfile::new(2, 4).unwrap();
275 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();
277 assert_relative_eq!(out.counts[3], 2.0, epsilon = 1e-12);
279 }
280
281 #[test]
282 fn reset_clears_state() {
283 let mut tpo = TpoProfile::new(2, 4).unwrap();
284 tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0));
285 tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1));
286 assert!(tpo.is_ready());
287 tpo.reset();
288 assert!(!tpo.is_ready());
289 assert!(tpo.value().is_none());
290 }
291
292 #[test]
293 fn batch_equals_streaming() {
294 let candles: Vec<Candle> = (0..30)
295 .map(|i| {
296 let base = 100.0 + f64::from(i % 7);
297 c(
298 base,
299 base + 2.0,
300 base - 2.0,
301 base,
302 10.0 + f64::from(i),
303 i64::from(i),
304 )
305 })
306 .collect();
307 let mut a = TpoProfile::new(10, 16).unwrap();
308 let mut b = TpoProfile::new(10, 16).unwrap();
309 assert_eq!(
310 a.batch(&candles),
311 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
312 );
313 }
314}