Skip to main content

wickra_core/indicators/
tpo_profile.rs

1//! TPO Profile — the Time-Price-Opportunity (market-profile letter) distribution.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// TPO Profile output: the price domain plus the per-bin time-period counts.
10///
11/// `counts[i]` is the number of periods in the rolling window whose `[low, high]`
12/// range touched the price bucket `[price_low + i * w, price_low + (i + 1) * w)`
13/// where `w = (price_high - price_low) / counts.len()`. This is the classic
14/// market-profile "letter" count: one Time-Price-Opportunity per period per
15/// price level it traded at, independent of volume.
16#[derive(Debug, Clone, PartialEq)]
17pub struct TpoProfileOutput {
18    /// Lowest price in the window — the lower edge of bin 0.
19    pub price_low: f64,
20    /// Highest price in the window — the upper edge of the last bin.
21    pub price_high: f64,
22    /// Per-bin TPO count, lowest price bucket first. Length equals `bin_count`.
23    pub counts: Vec<f64>,
24}
25
26/// Rolling TPO (Time Price Opportunity) Profile over the last `period` candles.
27///
28/// Where [`crate::VolumeProfile`] distributes each bar's *volume* across the
29/// bins it touches, the TPO profile counts *time*: every period that trades at a
30/// price level contributes exactly one TPO mark there, regardless of how much
31/// volume it carried. The result highlights the prices the market spent the most
32/// time at — the market-profile bell curve. Each touched bin receives a full
33/// `+1` per period (no sharing), so a wide-range bar marks every level it spans.
34///
35/// A window whose bars are all single-print at one price (`price_high == price_low`)
36/// is degenerate: every period's mark lands in bin 0 and both edges collapse to
37/// that price.
38///
39/// # Example
40///
41/// ```
42/// use wickra_core::{Candle, Indicator, TpoProfile};
43///
44/// let mut tpo = TpoProfile::new(5, 10).unwrap();
45/// let mut last = None;
46/// for i in 0..10 {
47///     let base = 100.0 + f64::from(i);
48///     let candle =
49///         Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap();
50///     last = tpo.update(candle);
51/// }
52/// let profile = last.unwrap();
53/// assert_eq!(profile.counts.len(), 10);
54/// ```
55#[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    /// Construct a TPO Profile indicator.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`Error::PeriodZero`] if `period` or `bin_count` is zero.
70    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    /// Classic TPO Profile: 30-bar rolling window, 50 bins.
83    pub fn classic() -> Self {
84        Self::new(30, 50).expect("classic TpoProfile params are valid")
85    }
86
87    /// Configured `(period, bin_count)`.
88    pub const fn params(&self) -> (usize, usize) {
89        (self.period, self.bin_count)
90    }
91
92    /// How many values every emitted profile carries -- the bin count fixed at construction.
93    ///
94    /// Fixed for the lifetime of the indicator, so a caller can size a
95    /// buffer once instead of guessing.
96    pub const fn width(&self) -> usize {
97        self.bin_count
98    }
99
100    /// Most recent profile if available.
101    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            // All bars are single-print at the same price: every period marks bin 0.
127            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        // The point of `width` is that a caller can size a buffer before
203        // the first value arrives, so it has to equal what update emits.
204        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        // Window of 2 candles, 4 bins, domain 10..14, width 1.
265        // bar0: 10..14 touches bins 0,1,2,3 -> +1 each.
266        // bar1: 11..12 touches bins 1,2 -> +1 each.
267        // counts = [1, 2, 2, 1]. TPO is volume-agnostic.
268        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        // Identical ranges with wildly different volumes give identical TPO counts.
283        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        // A single-print bar inside a wider domain marks exactly its own bin.
307        let mut tpo = TpoProfile::new(2, 4).unwrap();
308        tpo.update(c(10.0, 14.0, 10.0, 12.0, 5.0, 0)); // domain setter
309        let out = tpo.update(c(13.0, 13.0, 13.0, 13.0, 5.0, 1)).unwrap();
310        // domain 10..14, width 1; price 13 -> bin 3.
311        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}