Skip to main content

egui_charts/model/
tpo.rs

1//! TPO (Time Price Opportunity) / Market Profile chart model.
2//!
3//! Market Profile charts show price distribution over time by placing a
4//! letter (A, B, C, ...) at each price level touched during successive
5//! time periods (typically 30-minute brackets). The resulting profile
6//! reveals key structural levels:
7//!
8//! - **POC** (Point of Control) -- price with the most TPOs.
9//! - **Value Area** -- price range containing the configured share of TPO activity.
10//! - **Initial Balance** -- range established during the configured opening period.
11//! - **Single Prints** -- levels touched during only one TPO period.
12//!
13//! Use [`to_tpo_profiles`] to transform OHLCV bars into per-session
14//! [`TPOProfile`]s.
15
16use crate::model::bar::{bar_level_keys, level_grid, level_key};
17use crate::model::{Bar, BarClock, SessionProvider};
18use chrono::{DateTime, Utc};
19use std::collections::HashMap;
20use std::ops::RangeInclusive;
21
22/// Letter sequence for TPO periods (A, B, C... Z, a, b...)
23const TPO_LETTERS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
24
25/// A single TPO letter placed at a price level during one time period.
26#[derive(Debug, Clone)]
27pub struct TPOLetter {
28    /// Price of the letter's row: its lowest tick (see [`TPOProfile::row_price`]).
29    pub price: f64,
30    /// The letter assigned to this time period (A, B, C, ...).
31    pub letter: char,
32    /// Zero-based index of the time period within the session.
33    pub period_idx: usize,
34    /// Open time of the first bar in this letter's period.
35    pub ts: DateTime<Utc>,
36}
37
38/// A complete TPO profile for one trading session.
39///
40/// Contains all TPO letters, statistical levels (POC, value area,
41/// initial balance), and structural flags (poor high/low, single prints).
42#[derive(Debug, Clone)]
43pub struct TPOProfile {
44    /// Session timestamp
45    pub session_date: DateTime<Utc>,
46    /// Row height the profile's prices are keyed on: `row_ticks` ticks of `instrument_tick`. It is
47    /// the configured tick size, rounded up to whole instrument ticks when one is configured and
48    /// widened to a whole number of rows when the bars span more than 1,000 of them, or the span
49    /// divided into 1,000 rows without a usable tick size.
50    pub tick_size: f64,
51    /// Tick the rows are counted in: [`TPOConfig::instrument_tick`], or `tick_size` itself, making
52    /// every row one tick, without one or when prices are more than 2^50 ticks from zero.
53    pub instrument_tick: f64,
54    /// Ticks of `instrument_tick` in each row. A row holds the ticks from a multiple of
55    /// `row_ticks` up to the next and spans from half a tick below its first tick to half a tick
56    /// below the next row's, as footprint rows do.
57    pub row_ticks: u64,
58    /// All TPO letters in this profile
59    pub letters: Vec<TPOLetter>,
60    /// TPO count at each price level
61    pub price_tpo_count: HashMap<i64, usize>,
62    /// Point of control: the price (lowest tick) of the row with the most TPOs. A tie takes the
63    /// lower price.
64    pub poc_price: f64,
65    /// Price of the highest row of the configured TPO-count value area. The area grows from the
66    /// point of control toward whichever of the next two rows above or the next two rows below
67    /// holds more TPOs; equal counts add one row on each side.
68    pub value_area_high: f64,
69    /// Price of its lowest row.
70    pub value_area_low: f64,
71    /// Highest price of the bars that open within the configured initial balance period, or
72    /// `f64::MIN` when none does, so the profile has no initial balance.
73    pub initial_balance_high: f64,
74    /// Lowest price of those bars, or `f64::MAX` when none does.
75    pub initial_balance_low: f64,
76    /// Profile high
77    pub profile_high: f64,
78    /// Profile low
79    pub profile_low: f64,
80    /// Opening price
81    pub opening_price: f64,
82    /// Single prints: the prices of the rows with exactly one TPO, in ascending order
83    pub single_prints: Vec<f64>,
84    /// Poor highs (multiple TPOs at high)
85    pub has_poor_high: bool,
86    /// Poor lows (multiple TPOs at low)
87    pub has_poor_low: bool,
88}
89
90/// Configuration for TPO chart generation and rendering.
91#[derive(Debug, Clone)]
92pub struct TPOConfig {
93    /// Row height for price grouping, such as 0.25 for one ES tick or 1.0 for four. With
94    /// `instrument_tick` it rounds up to whole ticks. [`to_tpo_profiles`] widens it to bound the
95    /// rows; see [`TPOProfile::tick_size`].
96    pub tick_size: f64,
97    /// Duration of each period in minutes (typically 30), raised to the bar interval when
98    /// shorter so that every bar takes a period of its own
99    pub period_minutes: u32,
100    /// Duration of initial balance in minutes (typically 60)
101    pub initial_balance_minutes: u32,
102    /// Value area percentage (typically 0.70 for 70%)
103    pub value_area_pct: f64,
104    /// Show letters or blocks
105    pub display_mode: TPODisplayMode,
106    /// Color mode for TPO letters
107    pub color_mode: TPOColorMode,
108    /// Show POC line
109    pub show_poc: bool,
110    /// Show Value Area
111    pub show_value_area: bool,
112    /// Show Initial Balance
113    pub show_initial_balance: bool,
114    /// Draw one and two initial balance ranges above its high and below its low.
115    pub show_initial_balance_extensions: bool,
116    /// Mark the highest and lowest rows when they hold more than one TPO.
117    pub show_poor_highs_lows: bool,
118    /// Show single prints
119    pub show_single_prints: bool,
120    /// Highlight opening range
121    pub show_opening_range: bool,
122    /// Split each session where it moves between extended and regular hours, giving separate
123    /// pre-market, regular and post-market profiles
124    pub split_sessions: bool,
125    /// Custom letters sequence; an empty string uses the default letters
126    pub custom_letters: Option<String>,
127    /// Sessions to combine: the profiles whose sessions open within one range form a single
128    /// profile. Adding a range over adjacent sessions merges them, removing it splits them back,
129    /// and a range over many sessions builds a composite profile.
130    pub composites: Vec<RangeInclusive<DateTime<Utc>>>,
131    /// Draw only the newest session's profile instead of every visible one
132    pub latest_session_only: bool,
133    /// The instrument's minimum price increment. When set, rows are whole ticks counted as
134    /// footprint rows are: a row of n ticks holds the ticks from a multiple of n up to the next.
135    /// `None`, or a tick that is not positive and finite, counts each row as one tick.
136    pub instrument_tick: Option<f64>,
137}
138
139impl Default for TPOConfig {
140    fn default() -> Self {
141        Self {
142            tick_size: 1.0,
143            period_minutes: 30,
144            initial_balance_minutes: 60,
145            value_area_pct: 0.70,
146            display_mode: TPODisplayMode::Letters,
147            color_mode: TPOColorMode::ByPeriod,
148            show_poc: true,
149            show_value_area: true,
150            show_initial_balance: true,
151            show_initial_balance_extensions: false,
152            show_poor_highs_lows: false,
153            show_single_prints: false,
154            show_opening_range: true,
155            split_sessions: false,
156            custom_letters: None,
157            composites: Vec::new(),
158            latest_session_only: false,
159            instrument_tick: None,
160        }
161    }
162}
163
164/// How to render TPO entries on the chart.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum TPODisplayMode {
167    /// Show letters (A, B, C...)
168    Letters,
169    /// Show blocks
170    Blocks,
171    /// Show both letters and blocks
172    Both,
173}
174
175/// Color-mapping strategy for TPO letters/blocks.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum TPOColorMode {
178    /// Color by time period
179    ByPeriod,
180    /// Single color
181    Solid,
182    /// Color by value area
183    ByValueArea,
184    /// Color by initial balance
185    ByInitialBalance,
186}
187
188/// Session classification for splitting TPO profiles.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum SessionType {
191    /// Regular Trading Hours
192    RTH,
193    /// Extended Trading Hours
194    ETH,
195    /// Full session (RTH + ETH combined)
196    Full,
197}
198
199impl TPOProfile {
200    /// Create a new empty TPO profile on rows of one tick of `tick_size`
201    pub fn new(session_date: DateTime<Utc>, tick_size: f64) -> Self {
202        Self {
203            session_date,
204            tick_size,
205            instrument_tick: tick_size,
206            row_ticks: 1,
207            letters: Vec::new(),
208            price_tpo_count: HashMap::new(),
209            poc_price: 0.0,
210            value_area_high: 0.0,
211            value_area_low: 0.0,
212            initial_balance_high: f64::MIN,
213            initial_balance_low: f64::MAX,
214            profile_high: f64::MIN,
215            profile_low: f64::MAX,
216            opening_price: 0.0,
217            single_prints: Vec::new(),
218            has_poor_high: false,
219            has_poor_low: false,
220        }
221    }
222
223    /// Row key of `price` on this profile's rows.
224    fn key(&self, price: f64) -> i64 {
225        level_key(price, self.instrument_tick, self.row_ticks)
226    }
227
228    /// Price of the row holding `price`: the row's lowest tick.
229    pub fn row_price(&self, price: f64) -> f64 {
230        key_to_price(self.key(price), self.tick_size)
231    }
232
233    /// Lowest and highest price of the row holding `price`, from half a tick below its first tick
234    /// to half a tick below the next row's first tick.
235    pub fn row_span(&self, price: f64) -> (f64, f64) {
236        let (row, half_tick) = (self.row_price(price), self.instrument_tick * 0.5);
237        (row - half_tick, row + self.tick_size - half_tick)
238    }
239
240    /// Get the TPO count at a specific price level
241    pub fn tpo_count_at(&self, price: f64) -> usize {
242        let price_key = self.key(price);
243        *self.price_tpo_count.get(&price_key).unwrap_or(&0)
244    }
245
246    /// Get all unique price levels with TPOs
247    pub fn price_levels(&self) -> Vec<f64> {
248        let mut levels: Vec<f64> = self
249            .price_tpo_count
250            .keys()
251            .map(|&k| key_to_price(k, self.tick_size))
252            .collect();
253        levels.sort_by(|a, b| b.total_cmp(a));
254        levels
255    }
256
257    /// Check if a price is in the value area
258    pub fn is_in_value_area(&self, price: f64) -> bool {
259        price >= self.value_area_low && price <= self.value_area_high
260    }
261
262    /// Check if a price is in the initial balance
263    pub fn is_in_initial_balance(&self, price: f64) -> bool {
264        price >= self.initial_balance_low && price <= self.initial_balance_high
265    }
266
267    /// Get profile width (number of periods)
268    pub fn width(&self) -> usize {
269        self.letters.iter().map(|l| l.period_idx).max().unwrap_or(0) + 1
270    }
271
272    /// Add the letters of a later session on the same rows, keeping this session's open and
273    /// initial balance. The statistical levels need recalculating.
274    fn merge(&mut self, later: TPOProfile) {
275        for (key, count) in later.price_tpo_count {
276            *self.price_tpo_count.entry(key).or_insert(0) += count;
277        }
278        self.letters.extend(later.letters);
279        self.profile_high = self.profile_high.max(later.profile_high);
280        self.profile_low = self.profile_low.min(later.profile_low);
281    }
282
283    /// Get the letters at a specific price level
284    pub fn letters_at(&self, price: f64) -> Vec<&TPOLetter> {
285        let price_key = self.key(price);
286        self.letters
287            .iter()
288            .filter(|l| self.key(l.price) == price_key)
289            .collect()
290    }
291}
292
293/// Derive a sensible TPO row size (price-bin height) from a price range and a
294/// target number of rows.
295///
296/// Market Profile groups price into equal bins; the bin height controls how
297/// many letters stack at each level. Rather than hard-coding a tick size that
298/// only suits one instrument, this picks a "nice" step (1, 2, 2.5 or 5 times a
299/// power of ten) so the profile lands close to `target_rows` regardless of
300/// whether the series trades near 1.0 or 50_000.0. Degenerate inputs (zero or
301/// non-finite range, non-positive target) fall back to `1.0`.
302pub fn derive_tick_size(price_range: f64, target_rows: usize) -> f64 {
303    if !price_range.is_finite() || price_range <= 0.0 || target_rows == 0 {
304        return 1.0;
305    }
306    let raw = price_range / target_rows as f64;
307    let magnitude = 10f64.powf(raw.log10().floor());
308    let normalized = raw / magnitude;
309    let nice = if normalized <= 1.0 {
310        1.0
311    } else if normalized <= 2.0 {
312        2.0
313    } else if normalized <= 2.5 {
314        2.5
315    } else if normalized <= 5.0 {
316        5.0
317    } else {
318        10.0
319    };
320    let tick = nice * magnitude;
321    if tick.is_finite() && tick > 0.0 {
322        tick
323    } else {
324        1.0
325    }
326}
327
328/// Largest tick number whose rows keep finding their own prices.
329const EXACT_TICKS: f64 = (1u64 << 50) as f64;
330
331/// The fewest whole ticks that reach `height`, at least one, within one part in a billion so that
332/// a height of exactly n ticks stays n ticks despite rounding.
333pub(crate) fn whole_ticks(height: f64, tick: f64) -> f64 {
334    (height / tick * (1.0 - 1e-9)).ceil().max(1.0)
335}
336
337/// The tick and ticks per row of rows of `grid` for prices up to `extreme` in magnitude: whole
338/// instrument ticks reaching the grid, or the grid as one tick without a usable instrument tick
339/// or when tick numbers reach 2^50, past which a row's price can key the row below.
340fn row_ticks(grid: f64, instrument_tick: Option<f64>, extreme: f64) -> (f64, u64) {
341    instrument_tick
342        .filter(|tick| *tick > 0.0 && tick.is_finite() && extreme / tick < EXACT_TICKS)
343        .map(|tick| (tick, whole_ticks(grid, tick)))
344        .filter(|&(_, ticks)| ticks < EXACT_TICKS)
345        .map_or((grid, 1), |(tick, ticks)| (tick, ticks as u64))
346}
347
348/// Convert integer key back to price
349fn key_to_price(key: i64, tick_size: f64) -> f64 {
350    key as f64 * tick_size
351}
352
353/// First usable bars of the profiles before composites, shared by the model, cache and menus.
354pub(crate) fn session_starts(
355    bars: &[Bar],
356    sessions: &crate::model::ExchangeSessionProvider,
357    split_sessions: bool,
358) -> Vec<usize> {
359    let mut starts: Vec<_> = std::iter::once(0)
360        .chain(sessions.session_breaks(bars).into_iter().map(|(i, _)| i))
361        .collect();
362    if split_sessions {
363        let extended: Vec<_> = bars
364            .iter()
365            .map(|bar| sessions.is_extended(bar.time))
366            .collect();
367        starts.extend((1..bars.len()).filter(|&i| extended[i] != extended[i - 1]));
368    }
369    starts.sort_unstable();
370    starts.dedup();
371    starts.push(bars.len());
372    starts
373        .windows(2)
374        .filter_map(|span| {
375            (span[0]..span[1]).find(|&i| {
376                let bar = &bars[i];
377                bar.high.is_finite() && bar.low.is_finite() && bar.high >= bar.low
378            })
379        })
380        .collect()
381}
382
383/// Transform bars into TPO profiles
384///
385/// Sessions are the trading days of `clock`: the exchange sessions it carries,
386/// or calendar days in its time zone, split further at the moves between
387/// extended and regular hours with `config.split_sessions` and combined over
388/// `config.composites`. A session's periods are whole multiples of
389/// `config.period_minutes`, or of the clock's interval when longer, since its
390/// open: that of its trading day with intraday bars and trading hours, or of
391/// its stretch of extended or regular hours when split, whatever time its
392/// first bar has. Calendar days, daily and longer bars and bars split off
393/// outside the hours count from their first bar, since their open is not
394/// known. A gap leaves empty periods, and each period places one letter on
395/// every row its bars touched. The initial balance covers the bars that open
396/// within `config.initial_balance_minutes` of the same instant.
397///
398/// Rows are `config.tick_size` widened so the bars cover at most about 1,000
399/// rows (see [`TPOProfile::tick_size`]). With `config.instrument_tick` rows are
400/// whole ticks grouped from a multiple of the row, and each row's price is its
401/// lowest tick. Bars with a non-finite high or low or an inverted range are
402/// skipped.
403///
404/// # Arguments
405/// * `bars` - Source OHLCV bars
406/// * `config` - TPO configuration
407/// * `clock` - Interval and calendar of the bars, such as the chart's `Chart::bar_clock`
408///
409/// # Returns
410/// Vector of TPO profiles, one per session
411pub fn to_tpo_profiles(bars: &[Bar], config: &TPOConfig, clock: &BarClock) -> Vec<TPOProfile> {
412    if bars.is_empty() {
413        return Vec::new();
414    }
415
416    let letters = config
417        .custom_letters
418        .as_deref()
419        .filter(|letters| !letters.is_empty())
420        .unwrap_or(TPO_LETTERS)
421        .chars()
422        .collect::<Vec<_>>();
423
424    let mut profiles: Vec<TPOProfile> = Vec::new();
425    let mut current_profile: Option<TPOProfile> = None;
426    let mut session_open = bars[0].time;
427    // The open period: its index, first bar time and row-range endpoints
428    let mut period_idx = 0;
429    let mut period_ts = bars[0].time;
430    let interval_minutes = clock.timeframe().map_or(0, |t| t.duration_ms() / 60_000);
431    let period_minutes = i64::from(config.period_minutes.max(1)).max(interval_minutes);
432    let sessions = clock.sessions();
433    let mut starts = session_starts(bars, &sessions, config.split_sessions)
434        .into_iter()
435        .peekable();
436    let grid = level_grid(bars, config.tick_size);
437    let extreme = bars
438        .iter()
439        .filter(|bar| bar.low.is_finite() && bar.high.is_finite() && bar.low <= bar.high)
440        .fold(0.0_f64, |m, bar| m.max(bar.low.abs()).max(bar.high.abs()));
441    let (tick, row_ticks) = row_ticks(grid, config.instrument_tick, extreme);
442    let row = row_ticks as f64 * tick;
443    let keys = bars
444        .iter()
445        .filter_map(|bar| bar_level_keys(bar, tick, row_ticks));
446    let Some((first_key, last_key)) = keys.reduce(|(low, high), (l, h)| (low.min(l), high.max(h)))
447    else {
448        return Vec::new();
449    };
450    // One extra endpoint closes a range ending at the highest row, including i64::MAX.
451    let mut row_changes = vec![0isize; last_key.abs_diff(first_key) as usize + 2];
452
453    let mut touched = RangeInclusive::new(usize::MAX, 0);
454
455    for (i, bar) in bars.iter().enumerate() {
456        let Some((low_key, high_key)) = bar_level_keys(bar, tick, row_ticks) else {
457            continue;
458        };
459
460        // A session starting at a skipped bar starts at the next usable one.
461        let mut is_new_session = current_profile.is_none();
462        while starts.next_if(|&start| start <= i).is_some() {
463            is_new_session = true;
464        }
465        if is_new_session {
466            session_open = sessions
467                .session_open(bar.time, config.split_sessions)
468                .unwrap_or(bar.time);
469        }
470        let elapsed = (bar.time - session_open).num_minutes().max(0);
471        let bar_period = (elapsed / period_minutes) as usize;
472
473        if is_new_session || bar_period != period_idx {
474            if let Some(ref mut profile) = current_profile {
475                let letter = letters[period_idx % letters.len()];
476                let (lo, hi) = std::mem::replace(&mut touched, RangeInclusive::new(usize::MAX, 0))
477                    .into_inner();
478                add_tpo_letters(
479                    profile,
480                    row_changes.get_mut(lo..=hi).unwrap_or_default(),
481                    first_key + lo as i64,
482                    letter,
483                    period_idx,
484                    period_ts,
485                );
486            }
487            period_idx = bar_period;
488            period_ts = bar.time;
489        }
490
491        if is_new_session {
492            // Save the current profile
493            profiles.extend(current_profile.take());
494
495            // Start new profile
496            current_profile = Some(TPOProfile {
497                instrument_tick: tick,
498                row_ticks,
499                opening_price: bar.open,
500                ..TPOProfile::new(bar.time, row)
501            });
502        }
503
504        let (lo, hi) = (
505            low_key.abs_diff(first_key) as usize,
506            high_key.abs_diff(first_key) as usize + 1,
507        );
508        row_changes[lo] += 1;
509        row_changes[hi] -= 1;
510        touched = (*touched.start()).min(lo)..=(*touched.end()).max(hi);
511
512        if let Some(ref mut profile) = current_profile {
513            // Update profile high/low
514            profile.profile_high = profile.profile_high.max(bar.high);
515            profile.profile_low = profile.profile_low.min(bar.low);
516
517            // Update initial balance (first N minutes)
518            if elapsed < i64::from(config.initial_balance_minutes) {
519                profile.initial_balance_high = profile.initial_balance_high.max(bar.high);
520                profile.initial_balance_low = profile.initial_balance_low.min(bar.low);
521            }
522        }
523    }
524
525    // Don't forget the last profile
526    if let Some(mut profile) = current_profile.take() {
527        let letter = letters[period_idx % letters.len()];
528        let (lo, hi) = touched.into_inner();
529        add_tpo_letters(
530            &mut profile,
531            row_changes.get_mut(lo..=hi).unwrap_or_default(),
532            first_key + lo as i64,
533            letter,
534            period_idx,
535            period_ts,
536        );
537        profiles.push(profile);
538    }
539
540    let mut combined: Vec<TPOProfile> = Vec::with_capacity(profiles.len());
541    let composite = |profile: &TPOProfile| {
542        let open = &profile.session_date;
543        config
544            .composites
545            .iter()
546            .position(|range| range.contains(open))
547    };
548    for profile in profiles {
549        match combined.last_mut() {
550            Some(last) if composite(last).is_some() && composite(last) == composite(&profile) => {
551                last.merge(profile)
552            }
553            _ => combined.push(profile),
554        }
555    }
556    for profile in &mut combined {
557        calculate_profile_stats(profile, config);
558    }
559    combined
560}
561
562/// Emit the union of a period's ranges once per row, then clear its endpoints.
563fn add_tpo_letters(
564    profile: &mut TPOProfile,
565    row_changes: &mut [isize],
566    first_key: i64,
567    letter: char,
568    period_idx: usize,
569    ts: DateTime<Utc>,
570) {
571    let mut covering = 0;
572    for (offset, change) in row_changes.iter_mut().enumerate() {
573        covering += *change;
574        *change = 0;
575        if covering == 0 {
576            continue;
577        }
578        let key = first_key + offset as i64;
579        let price = key_to_price(key, profile.tick_size);
580        profile.letters.push(TPOLetter {
581            price,
582            letter,
583            period_idx,
584            ts,
585        });
586        *profile.price_tpo_count.entry(key).or_insert(0) += 1;
587    }
588}
589
590/// Calculate profile statistics (POC, Value Area, etc.)
591fn calculate_profile_stats(profile: &mut TPOProfile, config: &TPOConfig) {
592    if profile.price_tpo_count.is_empty() {
593        return;
594    }
595
596    // Find POC (price with most TPOs); a tie takes the lower price
597    let (poc_key, _) = profile
598        .price_tpo_count
599        .iter()
600        .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
601        .unwrap();
602    profile.poc_price = key_to_price(*poc_key, profile.tick_size);
603
604    // Calculate total TPOs
605    let total_tpos: usize = profile.price_tpo_count.values().sum();
606
607    // Calculate the Value Area (default 70% of TPOs) around the POC.
608    //
609    // This follows the canonical Market Profile expansion rule: start at the
610    // POC, then repeatedly extend the contiguous range one row at a time,
611    // always toward whichever adjacent side (the two rows immediately above
612    // the current high, or the two rows immediately below the current low)
613    // holds more TPOs; equal sums add one row on each side, as volume profiles
614    // do. Counts are accumulated until the running total reaches
615    // `value_area_pct` of all TPOs. The result is guaranteed contiguous and
616    // centered on the POC, the usual market-profile convention.
617    let target_tpos = (total_tpos as f64 * config.value_area_pct).ceil() as usize;
618    let count_at = |key: i64| -> usize { *profile.price_tpo_count.get(&key).unwrap_or(&0) };
619
620    let &min_key = profile.price_tpo_count.keys().min().unwrap();
621    let &max_key = profile.price_tpo_count.keys().max().unwrap();
622
623    let mut accumulated_tpos = count_at(*poc_key);
624    let mut va_low_key = *poc_key;
625    let mut va_high_key = *poc_key;
626
627    // Two-rows-per-step look: pair the next row above the high with the row
628    // beyond it, likewise below the low, then advance toward the heavier pair.
629    while accumulated_tpos < target_tpos && (va_low_key > min_key || va_high_key < max_key) {
630        let up_avail = va_high_key < max_key;
631        let down_avail = va_low_key > min_key;
632
633        let up_pair = if up_avail {
634            count_at(va_high_key + 1) + count_at(va_high_key + 2)
635        } else {
636            0
637        };
638        let down_pair = if down_avail {
639            count_at(va_low_key - 1) + count_at(va_low_key - 2)
640        } else {
641            0
642        };
643
644        // Prefer the heavier side; equal sums add one row on each side, as volume profiles do;
645        // when only one side remains, take it.
646        let go_up = match (up_avail, down_avail) {
647            (true, true) if up_pair == down_pair => {
648                va_low_key -= 1;
649                va_high_key += 1;
650                accumulated_tpos += count_at(va_low_key) + count_at(va_high_key);
651                continue;
652            }
653            (true, true) => up_pair > down_pair,
654            (true, false) => true,
655            (false, true) => false,
656            (false, false) => break,
657        };
658
659        if go_up {
660            va_high_key += 1;
661            accumulated_tpos += count_at(va_high_key);
662            if accumulated_tpos < target_tpos && va_high_key < max_key {
663                va_high_key += 1;
664                accumulated_tpos += count_at(va_high_key);
665            }
666        } else {
667            va_low_key -= 1;
668            accumulated_tpos += count_at(va_low_key);
669            if accumulated_tpos < target_tpos && va_low_key > min_key {
670                va_low_key -= 1;
671                accumulated_tpos += count_at(va_low_key);
672            }
673        }
674    }
675
676    profile.value_area_low = key_to_price(va_low_key, profile.tick_size);
677    profile.value_area_high = key_to_price(va_high_key, profile.tick_size);
678
679    // Find single prints (price levels with only 1 TPO)
680    profile.single_prints = profile
681        .price_tpo_count
682        .iter()
683        .filter(|(_, count)| **count == 1)
684        .map(|(&key, _)| key_to_price(key, profile.tick_size))
685        .collect();
686    profile.single_prints.sort_by(f64::total_cmp);
687
688    // Check for poor high/low (multiple TPOs at extreme)
689    let high_key = profile.key(profile.profile_high);
690    let low_key = profile.key(profile.profile_low);
691
692    profile.has_poor_high = profile.price_tpo_count.get(&high_key).unwrap_or(&0) > &1;
693    profile.has_poor_low = profile.price_tpo_count.get(&low_key).unwrap_or(&0) > &1;
694}
695
696/// Market profile shape classification.
697///
698/// The shape of a TPO profile conveys the type of market activity during
699/// that session. Use [`TPOProfile::classify_shape`] to determine it.
700#[derive(Debug, Clone, Copy, PartialEq, Eq)]
701pub enum ProfileShape {
702    /// Normal distribution - balanced market
703    Normal,
704    /// Double distribution - potential breakout
705    Double,
706    /// P-shape - strong buying
707    P,
708    /// b-shape - strong selling
709    B,
710    /// D-shape - trending market
711    D,
712}
713
714impl TPOProfile {
715    /// Classify the profile shape
716    pub fn classify_shape(&self) -> ProfileShape {
717        let levels = self.price_levels();
718        if levels.len() < 3 {
719            return ProfileShape::Normal;
720        }
721
722        let total_tpos: usize = self.price_tpo_count.values().sum();
723        let upper_third = &levels[..levels.len() / 3];
724        let lower_third = &levels[levels.len() * 2 / 3..];
725
726        let upper_tpos: usize = upper_third.iter().map(|p| self.tpo_count_at(*p)).sum();
727        let lower_tpos: usize = lower_third.iter().map(|p| self.tpo_count_at(*p)).sum();
728
729        let upper_pct = upper_tpos as f64 / total_tpos as f64;
730        let lower_pct = lower_tpos as f64 / total_tpos as f64;
731
732        if upper_pct > 0.5 && lower_pct < 0.2 {
733            ProfileShape::P
734        } else if lower_pct > 0.5 && upper_pct < 0.2 {
735            ProfileShape::B
736        } else if upper_pct > 0.35 && lower_pct > 0.35 {
737            ProfileShape::Double
738        } else if (upper_pct - lower_pct).abs() < 0.1 {
739            ProfileShape::Normal
740        } else {
741            ProfileShape::D
742        }
743    }
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749    use crate::model::Timeframe;
750    use chrono::{Duration, TimeZone};
751
752    #[test]
753    fn initial_balance_spans_its_bars_whatever_their_sign() {
754        // (minutes between bars, bars as (high, low), initial balance as (high, low)), tick 0.25
755        // and the default 60-minute balance from 14:30 UTC
756        type Case<'a> = (i64, &'a [(f64, f64)], (f64, f64));
757        let cases: [Case; 3] = [
758            // The first bar's high is zero
759            (30, &[(0.0, -1.0), (-0.25, -0.5)], (0.0, -1.0)),
760            // The running high reaches zero at the second bar
761            (1, &[(-1.0, -2.0), (0.0, -1.5), (-0.5, -0.75)], (0.0, -2.0)),
762            // Only the bars of the first hour count: not those at 60 and 90 minutes
763            (
764                30,
765                &[
766                    (102.0, 99.0),
767                    (103.0, 100.0),
768                    (104.0, 101.0),
769                    (104.0, 102.0),
770                ],
771                (103.0, 99.0),
772            ),
773        ];
774        let start = Utc.with_ymd_and_hms(2024, 1, 2, 14, 30, 0).unwrap();
775        let config = TPOConfig {
776            tick_size: 0.25,
777            ..Default::default()
778        };
779        for (spacing, bars, expected) in cases {
780            let bars: Vec<_> = bars
781                .iter()
782                .enumerate()
783                .map(|(i, &(high, low))| bar_at(start, spacing * i as i64, low, high, low, high))
784                .collect();
785            let profile = &to_tpo_profiles(&bars, &config, &Timeframe::Min1.into())[0];
786            assert_eq!(
787                (profile.initial_balance_high, profile.initial_balance_low),
788                expected,
789                "{bars:?}"
790            );
791        }
792    }
793
794    /// Build a single bar at a chosen offset from a fixed session start.
795    fn bar_at(start: DateTime<Utc>, mins: i64, o: f64, h: f64, l: f64, c: f64) -> Bar {
796        Bar {
797            time: start + Duration::minutes(mins),
798            open: o,
799            high: h,
800            low: l,
801            close: c,
802            volume: 1.0,
803            colors: None,
804            payload: None,
805        }
806    }
807
808    #[test]
809    fn each_period_counts_once_per_row() {
810        // (bars as (minute, low, high), expected letters as (profile, period, letter, minute of ts, rows))
811        type Case = (
812            &'static [(i64, f64, f64)],
813            &'static [(usize, usize, char, i64, RangeInclusive<i64>)],
814        );
815        let cases: [Case; 7] = [
816            // One 30-minute bar: one TPO on each row it spans
817            (&[(0, 100.0, 104.0)], &[(0, 0, 'A', 0, 100..=104)]),
818            // Two bars of one period: their union, one TPO per row, stamped with the first bar
819            (
820                &[(0, 100.0, 102.0), (1, 101.0, 104.0)],
821                &[(0, 0, 'A', 0, 100..=104)],
822            ),
823            // Rows between the bars of a period stay empty
824            (
825                &[(0, 103.0, 104.0), (1, 100.0, 101.0)],
826                &[(0, 0, 'A', 0, 100..=101), (0, 0, 'A', 0, 103..=104)],
827            ),
828            // 95 minutes in is the fourth period, after two empty ones
829            (
830                &[(0, 100.0, 101.0), (95, 102.0, 103.0)],
831                &[(0, 0, 'A', 0, 100..=101), (0, 3, 'D', 95, 102..=103)],
832            ),
833            // Periods follow the session start, not the bar that opened the previous period
834            (
835                &[(0, 100.0, 100.0), (45, 101.0, 101.0), (61, 102.0, 102.0)],
836                &[
837                    (0, 0, 'A', 0, 100..=100),
838                    (0, 1, 'B', 45, 101..=101),
839                    (0, 2, 'C', 61, 102..=102),
840                ],
841            ),
842            // A new UTC date closes the open period in its own profile, even when both are period 0
843            (
844                &[(0, 100.0, 100.0), (900, 101.0, 101.0)],
845                &[(0, 0, 'A', 0, 100..=100), (1, 0, 'A', 900, 101..=101)],
846            ),
847            // An inverted bar stays unusable even when both endpoints round to the same row.
848            (
849                &[(0, 100.0, 100.0), (900, 100.2, 100.1), (960, 101.0, 101.0)],
850                &[(0, 0, 'A', 0, 100..=100), (1, 0, 'A', 960, 101..=101)],
851            ),
852        ];
853        let start = Utc.with_ymd_and_hms(2024, 1, 2, 9, 30, 0).unwrap();
854        let config = TPOConfig {
855            tick_size: 1.0,
856            ..Default::default()
857        };
858        for (bars, expected) in cases {
859            let bars: Vec<Bar> = bars
860                .iter()
861                .map(|&(m, low, high)| bar_at(start, m, low, high, low, high))
862                .collect();
863            let profiles = to_tpo_profiles(&bars, &config, &Timeframe::Min1.into());
864            let mut letters = Vec::new();
865            let mut counts = Vec::new();
866            for (i, profile) in profiles.iter().enumerate() {
867                letters.extend(
868                    profile
869                        .letters
870                        .iter()
871                        .map(|l| (i, l.period_idx, l.letter, l.ts, profile.key(l.price))),
872                );
873                counts.extend(profile.price_tpo_count.iter().map(|(&k, &c)| (i, k, c)));
874            }
875            letters.sort_by_key(|&(i, period, _, _, key)| (i, period, key));
876            counts.sort_unstable();
877            let want: Vec<_> = expected
878                .iter()
879                .flat_map(|(i, period, letter, minute, rows)| {
880                    let ts = start + Duration::minutes(*minute);
881                    rows.clone().map(move |key| (*i, *period, *letter, ts, key))
882                })
883                .collect();
884            assert_eq!(letters, want, "bars {bars:?}");
885            // No row is shared between periods here, so every row counts one TPO
886            let ones: Vec<_> = want.iter().map(|&(i, _, _, _, key)| (i, key, 1)).collect();
887            assert_eq!(counts, ones, "bars {bars:?}");
888        }
889    }
890
891    #[test]
892    fn sessions_follow_the_clock_and_combine_over_composites() {
893        use crate::model::{Session, SessionWindow, TimezoneId};
894        use chrono::{NaiveTime, Weekday::*};
895        let at = |h, m| NaiveTime::from_hms_opt(h, m, 0).unwrap();
896        let new_york = TimezoneId::fixed_offset(-5 * 3600).unwrap();
897        let weekdays = [Mon, Tue, Wed, Thu, Fri];
898        // 18:00 to 17:00 New York, so a session spans the UTC midnight
899        let overnight = BarClock::new(Timeframe::Min30).with_session(
900            new_york.clone(),
901            Session::new([SessionWindow::new(
902                [Sun, Mon, Tue, Wed, Thu],
903                at(18, 0),
904                at(17, 0),
905            )]),
906            false,
907        );
908        let stock = BarClock::new(Timeframe::Min30).with_session(
909            new_york.clone(),
910            Session::new([SessionWindow::new(weekdays, at(9, 30), at(16, 0))])
911                .with_extended_windows([
912                    SessionWindow::new(weekdays, at(4, 0), at(9, 30)),
913                    SessionWindow::new(weekdays, at(16, 0), at(20, 0)),
914                ]),
915            true,
916        );
917        // 2024-03-05 is a Tuesday, 13:00 UTC is 08:00 in New York
918        let start = Utc.with_ymd_and_hms(2024, 3, 5, 13, 0, 0).unwrap();
919        let both_days = start..=start + Duration::days(1);
920        // (case, clock, bar minutes from `start`, split, composites, profiles as (open, letters))
921        type Case<'a> = (
922            &'a str,
923            BarClock,
924            Vec<i64>,
925            bool,
926            Vec<RangeInclusive<DateTime<Utc>>>,
927            &'a [(i64, &'a str)],
928        );
929        let cases: [Case; 8] = [
930            (
931                "exchange day across UTC midnight",
932                overnight,
933                vec![600, 630, 690, 1950, 2040],
934                false,
935                vec![],
936                &[(600, "ABDt"), (2040, "A")],
937            ),
938            (
939                "pre-market, regular and post-market in one day",
940                stock.clone(),
941                (0..19).map(|i| i * 30).collect(),
942                false,
943                vec![],
944                // Pre-market opens at 04:00, eight periods before the first bar at 08:00
945                &[(0, "IJKLMNOPQRSTUVWXYZa")],
946            ),
947            (
948                "split at extended hours",
949                stock,
950                (0..19).map(|i| i * 30).collect(),
951                true,
952                vec![],
953                &[(0, "IJK"), (90, "ABCDEFGHIJKLM"), (480, "ABC")],
954            ),
955            // Post-market opens where regular hours close, inside its all-day extended window
956            (
957                "split inside an extended window spanning regular hours",
958                BarClock::new(Timeframe::Min30).with_session(
959                    new_york.clone(),
960                    Session::new([SessionWindow::new(weekdays, at(9, 30), at(16, 0))])
961                        .with_extended_windows([SessionWindow::new(weekdays, at(4, 0), at(20, 0))]),
962                    true,
963                ),
964                vec![540, 570],
965                true,
966                vec![],
967                &[(540, "CD")],
968            ),
969            // No extended hours divide a lunch break, so 13:00 counts from the 09:00 open
970            (
971                "split across a lunch break",
972                BarClock::new(Timeframe::Min30).with_session(
973                    new_york.clone(),
974                    Session::new([
975                        SessionWindow::new(weekdays, at(9, 0), at(11, 30)),
976                        SessionWindow::new(weekdays, at(12, 30), at(15, 0)),
977                    ]),
978                    false,
979                ),
980                vec![300],
981                true,
982                vec![],
983                &[(300, "I")],
984            ),
985            (
986                "hourly bars take one period each",
987                BarClock::new(Timeframe::Hour1),
988                vec![0, 60, 120],
989                false,
990                vec![],
991                &[(0, "ABC")],
992            ),
993            (
994                "UTC days",
995                Timeframe::Min30.into(),
996                vec![0, 30, 1440, 1470],
997                false,
998                vec![],
999                &[(0, "AB"), (1440, "AB")],
1000            ),
1001            (
1002                "UTC days merged",
1003                Timeframe::Min30.into(),
1004                vec![0, 30, 1440, 1470],
1005                false,
1006                vec![both_days],
1007                &[(0, "ABAB")],
1008            ),
1009        ];
1010        for (case, clock, minutes, split_sessions, composites, expected) in cases {
1011            let bars: Vec<_> = minutes
1012                .iter()
1013                .map(|&m| bar_at(start, m, 100.0, 100.0, 100.0, 100.0))
1014                .collect();
1015            let config = TPOConfig {
1016                tick_size: 1.0,
1017                split_sessions,
1018                composites,
1019                ..Default::default()
1020            };
1021            let profiles: Vec<_> = to_tpo_profiles(&bars, &config, &clock)
1022                .iter()
1023                .map(|profile| {
1024                    let open = (profile.session_date - start).num_minutes();
1025                    let letters: String = profile.letters.iter().map(|l| l.letter).collect();
1026                    assert_eq!(profile.tpo_count_at(100.0), letters.len(), "{case}");
1027                    (open, letters)
1028                })
1029                .collect();
1030            let expected: Vec<_> = expected.iter().map(|&(m, l)| (m, l.to_owned())).collect();
1031            assert_eq!(profiles, expected, "{case}");
1032        }
1033    }
1034
1035    #[test]
1036    fn periods_and_initial_balance_count_from_the_session_open() {
1037        use crate::model::{Session, SessionWindow, TimezoneId};
1038        use chrono::{NaiveTime, Weekday::*};
1039        let at = |h, m| NaiveTime::from_hms_opt(h, m, 0).unwrap();
1040        // Regular hours 09:30 to 16:00 New York (UTC-5); 2024-03-05 opens at 14:30 UTC.
1041        let clock = BarClock::new(Timeframe::Min5).with_session(
1042            TimezoneId::fixed_offset(-5 * 3600).unwrap(),
1043            Session::new([SessionWindow::new(
1044                [Mon, Tue, Wed, Thu, Fri],
1045                at(9, 30),
1046                at(16, 0),
1047            )]),
1048            false,
1049        );
1050        let open = Utc.with_ymd_and_hms(2024, 3, 5, 14, 30, 0).unwrap();
1051        // (case, bars as (minutes after the open, low, high), letters as (letter, row), initial
1052        // balance as (high, low)), tick 1, 30-minute periods
1053        type Case<'a> = (
1054            &'a str,
1055            &'a [(i64, f64, f64)],
1056            &'a [(char, f64)],
1057            (f64, f64),
1058        );
1059        let cases: [Case; 2] = [
1060            // First trade at 09:45. A is 09:30 to 10:00, B 10:00 to 10:30, C 10:30 to 11:00: the
1061            // 10:05 bar prints B and the 10:40 bar C, after the 09:30 to 10:30 initial balance.
1062            (
1063                "late first bar",
1064                &[(15, 100.0, 101.0), (35, 101.0, 102.0), (70, 103.0, 104.0)],
1065                &[
1066                    ('A', 100.0),
1067                    ('A', 101.0),
1068                    ('B', 101.0),
1069                    ('B', 102.0),
1070                    ('C', 103.0),
1071                    ('C', 104.0),
1072                ],
1073                (102.0, 100.0),
1074            ),
1075            // History that starts at 11:00, 90 minutes after the open: the fourth period, D, past
1076            // the initial balance.
1077            (
1078                "history from 11:00",
1079                &[(90, 100.0, 100.0), (125, 101.0, 101.0)],
1080                &[('D', 100.0), ('E', 101.0)],
1081                (f64::MIN, f64::MAX),
1082            ),
1083        ];
1084        let config = TPOConfig {
1085            tick_size: 1.0,
1086            ..Default::default()
1087        };
1088        for (case, bars, letters, initial_balance) in cases {
1089            let bars: Vec<_> = bars
1090                .iter()
1091                .map(|&(m, low, high)| bar_at(open, m, low, high, low, high))
1092                .collect();
1093            let profiles = to_tpo_profiles(&bars, &config, &clock);
1094            assert_eq!(profiles.len(), 1, "{case}");
1095            let profile = &profiles[0];
1096            let mut placed: Vec<_> = profile
1097                .letters
1098                .iter()
1099                .map(|l| (l.letter, l.price))
1100                .collect();
1101            placed.sort_by(|a, b| a.partial_cmp(b).unwrap());
1102            assert_eq!(placed, letters, "{case}");
1103            assert_eq!(
1104                (profile.initial_balance_high, profile.initial_balance_low),
1105                initial_balance,
1106                "{case}"
1107            );
1108        }
1109    }
1110
1111    #[test]
1112    fn custom_letters_name_the_periods() {
1113        // Bars at minutes 0 and 45 fall in periods 0 and 1; an empty sequence means the default letters
1114        let start = Utc.with_ymd_and_hms(2024, 1, 2, 9, 30, 0).unwrap();
1115        let bars = [0, 45].map(|m| bar_at(start, m, 100.0, 100.0, 100.0, 100.0));
1116        for (custom, want) in [(Some(""), ['A', 'B']), (Some("xy"), ['x', 'y'])] {
1117            let config = TPOConfig {
1118                tick_size: 1.0,
1119                custom_letters: custom.map(String::from),
1120                ..Default::default()
1121            };
1122            let profiles = to_tpo_profiles(&bars, &config, &Timeframe::Min1.into());
1123            let named: Vec<_> = profiles[0]
1124                .letters
1125                .iter()
1126                .map(|l| (l.period_idx, l.letter))
1127                .collect();
1128            assert_eq!(
1129                named,
1130                [(0, want[0]), (1, want[1])],
1131                "custom_letters {custom:?}"
1132            );
1133        }
1134    }
1135
1136    #[test]
1137    fn poc_is_the_highest_count_bin() {
1138        // Three periods all overlap 102.0, fewer overlap the extremes, so 102.0
1139        // must accumulate the most TPOs and become the POC.
1140        let start = Utc.with_ymd_and_hms(2024, 1, 2, 9, 30, 0).unwrap();
1141        let bars = vec![
1142            bar_at(start, 0, 100.0, 102.0, 100.0, 101.0),
1143            bar_at(start, 30, 101.0, 103.0, 101.0, 102.0),
1144            bar_at(start, 60, 102.0, 104.0, 102.0, 103.0),
1145        ];
1146        let config = TPOConfig {
1147            tick_size: 1.0,
1148            ..Default::default()
1149        };
1150        let profile = &to_tpo_profiles(&bars, &config, &Timeframe::Min1.into())[0];
1151        assert_eq!(profile.poc_price, 102.0);
1152    }
1153
1154    #[test]
1155    fn ties_give_one_point_of_control_and_value_area_on_every_build() {
1156        // (case, half-hour periods from 14:30 UTC as (low, high), (POC, VAL, VAH), single prints)
1157        type Case<'a> = (&'a str, Vec<(f64, f64)>, (f64, f64, f64), Vec<f64>);
1158        let cases: [Case; 2] = [
1159            // A spans 100 to 109, B to D print 100 and E to G 109. Rows 100 and 109 hold 4 TPOs
1160            // and 101 to 108 one each, 16 in all, so the 70% value area needs 12. The tie takes
1161            // 100, from which the area can only grow upward, one TPO per row, reaching 12 at 108.
1162            (
1163                "tied point of control",
1164                [(100.0, 109.0)]
1165                    .into_iter()
1166                    .chain([(100.0, 100.0); 3])
1167                    .chain([(109.0, 109.0); 3])
1168                    .collect(),
1169                (100.0, 100.0, 108.0),
1170                (101..=108).map(f64::from).collect(),
1171            ),
1172            // A spans 100 to 104, B and C print 102: rows of 1, 1, 3, 1 and 1 TPOs, 7 in all, so
1173            // the value area needs 5. From 102 the two rows above and the two below hold 2 each,
1174            // so it adds 101 and 103 and stops at 5.
1175            (
1176                "tied value area expansion",
1177                vec![(100.0, 104.0), (102.0, 102.0), (102.0, 102.0)],
1178                (102.0, 101.0, 103.0),
1179                vec![100.0, 101.0, 103.0, 104.0],
1180            ),
1181        ];
1182        let start = Utc.with_ymd_and_hms(2024, 1, 2, 14, 30, 0).unwrap();
1183        let config = TPOConfig {
1184            tick_size: 1.0,
1185            ..Default::default()
1186        };
1187        for (case, periods, levels, single_prints) in cases {
1188            let bars: Vec<_> = periods
1189                .iter()
1190                .enumerate()
1191                .map(|(i, &(low, high))| bar_at(start, 30 * i as i64, low, high, low, high))
1192                .collect();
1193            // Each build hashes its rows with a new seed, so an unbroken tie varies between builds.
1194            for _ in 0..64 {
1195                let profile = &to_tpo_profiles(&bars, &config, &Timeframe::Min30.into())[0];
1196                assert_eq!(
1197                    (
1198                        profile.poc_price,
1199                        profile.value_area_low,
1200                        profile.value_area_high
1201                    ),
1202                    levels,
1203                    "{case}"
1204                );
1205                assert_eq!(profile.single_prints, single_prints, "{case}");
1206            }
1207        }
1208    }
1209
1210    #[test]
1211    fn value_area_is_contiguous_and_covers_about_seventy_percent() {
1212        // Stack many overlapping periods to build a tall, well-populated profile.
1213        let start = Utc.with_ymd_and_hms(2024, 1, 2, 9, 30, 0).unwrap();
1214        let mut bars = Vec::new();
1215        for p in 0..10 {
1216            // Each period drifts upward by one tick, all sharing the mid-band.
1217            let base = 100.0 + p as f64;
1218            bars.push(bar_at(start, p * 30, base, base + 5.0, base, base + 2.0));
1219        }
1220        let config = TPOConfig {
1221            tick_size: 1.0,
1222            value_area_pct: 0.70,
1223            ..Default::default()
1224        };
1225        let profile = &to_tpo_profiles(&bars, &config, &Timeframe::Min1.into())[0];
1226
1227        // POC sits inside the value area.
1228        assert!(profile.is_in_value_area(profile.poc_price));
1229        assert!(profile.value_area_low <= profile.poc_price);
1230        assert!(profile.value_area_high >= profile.poc_price);
1231
1232        // The value area is a contiguous price band: every level between VAL and
1233        // VAH (inclusive) that exists in the profile is part of one run.
1234        let total: usize = profile.price_tpo_count.values().sum();
1235        let va_lo = profile.key(profile.value_area_low);
1236        let va_hi = profile.key(profile.value_area_high);
1237        assert!(va_lo <= va_hi);
1238
1239        let va_tpos: usize = profile
1240            .price_tpo_count
1241            .iter()
1242            .filter(|&(&k, _)| k >= va_lo && k <= va_hi)
1243            .map(|(_, &c)| c)
1244            .sum();
1245
1246        // Covers at least the 70% target, and isn't the whole profile (the tails
1247        // outside the value area must hold something).
1248        let target = (total as f64 * 0.70).ceil() as usize;
1249        assert!(
1250            va_tpos >= target,
1251            "value area {va_tpos} should reach target {target} of {total}"
1252        );
1253        assert!(
1254            va_tpos < total,
1255            "value area should exclude the thin tails (got all {total})"
1256        );
1257    }
1258
1259    #[test]
1260    fn derive_tick_size_picks_nice_steps_and_handles_degenerate_input() {
1261        // ~40 rows over a 200-wide range → a 5.0 step (40 rows * 5 = 200).
1262        assert_eq!(derive_tick_size(200.0, 40), 5.0);
1263        // Tiny range scales down to a sub-unit nice step.
1264        assert!(derive_tick_size(0.4, 40) > 0.0);
1265        // Degenerate inputs fall back to 1.0 rather than producing NaN/zero ticks.
1266        assert_eq!(derive_tick_size(0.0, 40), 1.0);
1267        assert_eq!(derive_tick_size(-5.0, 40), 1.0);
1268        assert_eq!(derive_tick_size(100.0, 0), 1.0);
1269        assert_eq!(derive_tick_size(f64::NAN, 40), 1.0);
1270    }
1271
1272    #[test]
1273    fn single_bar_zero_range_does_not_panic_and_yields_a_poc() {
1274        // A flat bar (high == low) is the degenerate edge: one price level, one
1275        // TPO. The profile must still build with that level as the POC.
1276        let start = Utc.with_ymd_and_hms(2024, 1, 2, 9, 30, 0).unwrap();
1277        let bars = vec![bar_at(start, 0, 100.0, 100.0, 100.0, 100.0)];
1278        let config = TPOConfig {
1279            tick_size: 1.0,
1280            ..Default::default()
1281        };
1282        let profiles = to_tpo_profiles(&bars, &config, &Timeframe::Min1.into());
1283        assert_eq!(profiles.len(), 1);
1284        let profile = &profiles[0];
1285        assert_eq!(profile.poc_price, 100.0);
1286        assert_eq!(profile.value_area_low, 100.0);
1287        assert_eq!(profile.value_area_high, 100.0);
1288    }
1289
1290    #[test]
1291    fn rows_per_series_are_bounded() {
1292        let start = Utc.with_ymd_and_hms(2024, 1, 2, 9, 30, 0).unwrap();
1293        let bars = [bar_at(start, 0, 0.0, 1e6, 0.0, 1.0)];
1294        let config = TPOConfig {
1295            tick_size: 1.0,
1296            ..Default::default()
1297        };
1298        let profile = &to_tpo_profiles(&bars, &config, &Timeframe::Min1.into())[0];
1299        // 1e6 ticks widen to rows of 1,000 ticks: 0, 1,000, ..., 1e6
1300        assert_eq!((profile.tick_size, profile.letters.len()), (1_000.0, 1_001));
1301    }
1302
1303    #[test]
1304    fn rows_of_several_ticks_hold_whole_ticks_from_a_multiple_of_the_row() {
1305        // (tick, ticks per row, tick numbers swept, quote divisor, prices with their row price and
1306        // span), worked out by hand from row = floor(round(price / tick) / ticks per row)
1307        type Case<'a> = (
1308            f64,
1309            u64,
1310            std::ops::Range<i64>,
1311            f64,
1312            &'a [(f64, f64, (f64, f64))],
1313        );
1314        let cases: [Case; 5] = [
1315            (
1316                0.01,
1317                2,
1318                10_000..20_000,
1319                100.0,
1320                &[(100.01, 100.0, (99.995, 100.015))],
1321            ),
1322            (
1323                0.01,
1324                2,
1325                -100..100,
1326                100.0,
1327                &[
1328                    (0.0, 0.0, (-0.005, 0.015)),
1329                    (-0.01, -0.02, (-0.025, -0.005)),
1330                ],
1331            ),
1332            (
1333                0.01,
1334                10,
1335                10_000..20_000,
1336                100.0,
1337                &[
1338                    (100.05, 100.0, (99.995, 100.095)),
1339                    (199.99, 199.9, (199.895, 199.995)),
1340                ],
1341            ),
1342            (
1343                0.25,
1344                4,
1345                -40..40,
1346                4.0,
1347                &[
1348                    (110.0, 110.0, (109.875, 110.875)),
1349                    // Between two ticks, and half a tick from the next row
1350                    (110.6, 110.0, (109.875, 110.875)),
1351                    (110.875, 111.0, (110.875, 111.875)),
1352                    (0.0, 0.0, (-0.125, 0.875)),
1353                    (-0.25, -1.0, (-1.125, -0.125)),
1354                ],
1355            ),
1356            (
1357                0.25,
1358                3,
1359                -12..12,
1360                4.0,
1361                &[
1362                    (110.0, 109.5, (109.375, 110.125)),
1363                    (0.0, 0.0, (-0.125, 0.625)),
1364                    (-0.25, -0.75, (-0.875, -0.125)),
1365                ],
1366            ),
1367        ];
1368        let close = |a: f64, b: f64| (a - b).abs() <= 1e-9 * b.abs().max(1.0);
1369        for (tick, row_ticks, sweep, divisor, rows) in cases {
1370            let profile = TPOProfile {
1371                instrument_tick: tick,
1372                row_ticks,
1373                ..TPOProfile::new(DateTime::UNIX_EPOCH, row_ticks as f64 * tick)
1374            };
1375            let mut held: HashMap<u64, u64> = HashMap::new();
1376            for n in sweep.clone() {
1377                let price = n as f64 / divisor;
1378                let (low, high) = profile.row_span(price);
1379                assert!(
1380                    low < price && price < high,
1381                    "{price} on {tick} x {row_ticks}"
1382                );
1383                *held.entry(profile.row_price(price).to_bits()).or_insert(0) += 1;
1384            }
1385            assert!(
1386                held.values().all(|&ticks| ticks == row_ticks),
1387                "uneven rows on {tick} x {row_ticks}"
1388            );
1389            assert_eq!(
1390                held.len() as u64 * row_ticks,
1391                (sweep.end - sweep.start) as u64,
1392                "{tick} x {row_ticks}"
1393            );
1394            for &(price, row, (low, high)) in rows {
1395                let at = (tick, row_ticks, price);
1396                assert!(close(profile.row_price(price), row), "{at:?}");
1397                let span = profile.row_span(price);
1398                assert!(close(span.0, low) && close(span.1, high), "{at:?} {span:?}");
1399                let own = profile.row_price(price);
1400                assert_eq!(profile.row_price(own), own, "{at:?}");
1401            }
1402        }
1403
1404        // Past 2^50 ticks a row's price can key the row below, so the rows count as one tick.
1405        let start = Utc.with_ymd_and_hms(2024, 1, 2, 14, 30, 0).unwrap();
1406        let bars = [bar_at(start, 0, 1e4, 1e4 + 1e-4, 1e4, 1e4)];
1407        let config = TPOConfig {
1408            tick_size: 1e-6,
1409            instrument_tick: Some(1e-12),
1410            ..Default::default()
1411        };
1412        let profile = &to_tpo_profiles(&bars, &config, &Timeframe::Min1.into())[0];
1413        assert_eq!(profile.letters.len(), 101);
1414        assert!(
1415            profile
1416                .letters
1417                .iter()
1418                .all(|letter| profile.row_price(letter.price) == letter.price)
1419        );
1420    }
1421}