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    /// Most recent profile if available.
93    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            // All bars are single-print at the same price: every period marks bin 0.
119            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        // Window of 2 candles, 4 bins, domain 10..14, width 1.
232        // bar0: 10..14 touches bins 0,1,2,3 -> +1 each.
233        // bar1: 11..12 touches bins 1,2 -> +1 each.
234        // counts = [1, 2, 2, 1]. TPO is volume-agnostic.
235        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        // Identical ranges with wildly different volumes give identical TPO counts.
250        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        // A single-print bar inside a wider domain marks exactly its own bin.
274        let mut tpo = TpoProfile::new(2, 4).unwrap();
275        tpo.update(c(10.0, 14.0, 10.0, 12.0, 5.0, 0)); // domain setter
276        let out = tpo.update(c(13.0, 13.0, 13.0, 13.0, 5.0, 1)).unwrap();
277        // domain 10..14, width 1; price 13 -> bin 3.
278        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}