egui-charts 0.2.0

High-performance financial charting engine for egui — candlesticks, 95 drawing tools, 130+ indicators, and a full design-token theme system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
//! TPO (Time Price Opportunity) / Market Profile chart model.
//!
//! Market Profile charts show price distribution over time by placing a
//! letter (A, B, C, ...) at each price level touched during successive
//! time periods (typically 30-minute brackets). The resulting profile
//! reveals key structural levels:
//!
//! - **POC** (Point of Control) -- price with the most TPOs (highest volume).
//! - **Value Area** -- price range containing 70% of TPO activity.
//! - **Initial Balance** -- range established in the first hour of trading.
//! - **Single Prints** -- levels with only one TPO, often acting as
//!   support/resistance.
//!
//! Use [`to_tpo_profiles`] to transform OHLCV bars into per-session
//! [`TPOProfile`]s.

use crate::model::Bar;
use chrono::{DateTime, Utc};
use std::collections::HashMap;

/// Letter sequence for TPO periods (A, B, C... Z, a, b...)
const TPO_LETTERS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

/// A single TPO letter placed at a price level during one time period.
#[derive(Debug, Clone)]
pub struct TPOLetter {
    /// Price level (quantised to `tick_size`).
    pub price: f64,
    /// The letter assigned to this time period (A, B, C, ...).
    pub letter: char,
    /// Zero-based index of the time period within the session.
    pub period_idx: usize,
    /// Timestamp of the bar that generated this letter.
    pub ts: DateTime<Utc>,
}

/// A complete TPO profile for one trading session.
///
/// Contains all TPO letters, statistical levels (POC, value area,
/// initial balance), and structural flags (poor high/low, single prints).
#[derive(Debug, Clone)]
pub struct TPOProfile {
    /// Session timestamp
    pub session_date: DateTime<Utc>,
    /// All TPO letters in this profile
    pub letters: Vec<TPOLetter>,
    /// TPO count at each price level
    pub price_tpo_count: HashMap<i64, usize>,
    /// Point of Control - price with most TPOs
    pub poc_price: f64,
    /// Value Area High (70% of volume)
    pub value_area_high: f64,
    /// Value Area Low (70% of volume)
    pub value_area_low: f64,
    /// Initial Balance High (first hour)
    pub initial_balance_high: f64,
    /// Initial Balance Low (first hour)
    pub initial_balance_low: f64,
    /// Profile high
    pub profile_high: f64,
    /// Profile low
    pub profile_low: f64,
    /// Opening price
    pub opening_price: f64,
    /// Single prints (price levels with only 1 TPO)
    pub single_prints: Vec<f64>,
    /// Poor highs (multiple TPOs at high)
    pub has_poor_high: bool,
    /// Poor lows (multiple TPOs at low)
    pub has_poor_low: bool,
}

/// Configuration for TPO chart generation and rendering.
#[derive(Debug, Clone)]
pub struct TPOConfig {
    /// Tick size for price grouping (e.g., 0.25 for ES futures)
    pub tick_size: f64,
    /// Duration of each period in minutes (typically 30)
    pub period_minutes: u32,
    /// Duration of initial balance in minutes (typically 60)
    pub initial_balance_minutes: u32,
    /// Value area percentage (typically 0.70 for 70%)
    pub value_area_pct: f64,
    /// Show letters or blocks
    pub display_mode: TPODisplayMode,
    /// Color mode for TPO letters
    pub color_mode: TPOColorMode,
    /// Show POC line
    pub show_poc: bool,
    /// Show Value Area
    pub show_value_area: bool,
    /// Show Initial Balance
    pub show_initial_balance: bool,
    /// Show single prints
    pub show_single_prints: bool,
    /// Highlight opening range
    pub show_opening_range: bool,
    /// Split by session (RTH vs ETH)
    pub split_sessions: bool,
    /// Custom letters sequence
    pub custom_letters: Option<String>,
}

impl Default for TPOConfig {
    fn default() -> Self {
        Self {
            tick_size: 1.0,
            period_minutes: 30,
            initial_balance_minutes: 60,
            value_area_pct: 0.70,
            display_mode: TPODisplayMode::Letters,
            color_mode: TPOColorMode::ByPeriod,
            show_poc: true,
            show_value_area: true,
            show_initial_balance: true,
            show_single_prints: true,
            show_opening_range: true,
            split_sessions: false,
            custom_letters: None,
        }
    }
}

/// How to render TPO entries on the chart.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TPODisplayMode {
    /// Show letters (A, B, C...)
    Letters,
    /// Show blocks
    Blocks,
    /// Show both letters and blocks
    Both,
}

/// Color-mapping strategy for TPO letters/blocks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TPOColorMode {
    /// Color by time period
    ByPeriod,
    /// Single color
    Solid,
    /// Color by value area
    ByValueArea,
    /// Color by initial balance
    ByInitialBalance,
}

/// Session classification for splitting TPO profiles.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionType {
    /// Regular Trading Hours
    RTH,
    /// Extended Trading Hours
    ETH,
    /// Full session (RTH + ETH combined)
    Full,
}

impl TPOProfile {
    /// Create a new empty TPO profile
    pub fn new(session_date: DateTime<Utc>) -> Self {
        Self {
            session_date,
            letters: Vec::new(),
            price_tpo_count: HashMap::new(),
            poc_price: 0.0,
            value_area_high: 0.0,
            value_area_low: 0.0,
            initial_balance_high: 0.0,
            initial_balance_low: 0.0,
            profile_high: f64::MIN,
            profile_low: f64::MAX,
            opening_price: 0.0,
            single_prints: Vec::new(),
            has_poor_high: false,
            has_poor_low: false,
        }
    }

    /// Get the TPO count at a specific price level
    pub fn tpo_count_at(&self, price: f64, tick_size: f64) -> usize {
        let price_key = price_to_key(price, tick_size);
        *self.price_tpo_count.get(&price_key).unwrap_or(&0)
    }

    /// Get all unique price levels with TPOs
    pub fn price_levels(&self, tick_size: f64) -> Vec<f64> {
        let mut levels: Vec<f64> = self
            .price_tpo_count
            .keys()
            .map(|&k| key_to_price(k, tick_size))
            .collect();
        levels.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
        levels
    }

    /// Check if a price is in the value area
    pub fn is_in_value_area(&self, price: f64) -> bool {
        price >= self.value_area_low && price <= self.value_area_high
    }

    /// Check if a price is in the initial balance
    pub fn is_in_initial_balance(&self, price: f64) -> bool {
        price >= self.initial_balance_low && price <= self.initial_balance_high
    }

    /// Get profile width (number of periods)
    pub fn width(&self) -> usize {
        self.letters.iter().map(|l| l.period_idx).max().unwrap_or(0) + 1
    }

    /// Get the letters at a specific price level
    pub fn letters_at(&self, price: f64, tick_size: f64) -> Vec<&TPOLetter> {
        let price_key = price_to_key(price, tick_size);
        self.letters
            .iter()
            .filter(|l| price_to_key(l.price, tick_size) == price_key)
            .collect()
    }
}

/// Derive a sensible TPO row size (price-bin height) from a price range and a
/// target number of rows.
///
/// Market Profile groups price into equal bins; the bin height controls how
/// many letters stack at each level. Rather than hard-coding a tick size that
/// only suits one instrument, this picks a "nice" step (1, 2, 2.5 or 5 times a
/// power of ten) so the profile lands close to `target_rows` regardless of
/// whether the series trades near 1.0 or 50_000.0. Degenerate inputs (zero or
/// non-finite range, non-positive target) fall back to `1.0`.
pub fn derive_tick_size(price_range: f64, target_rows: usize) -> f64 {
    if !price_range.is_finite() || price_range <= 0.0 || target_rows == 0 {
        return 1.0;
    }
    let raw = price_range / target_rows as f64;
    let magnitude = 10f64.powf(raw.log10().floor());
    let normalized = raw / magnitude;
    let nice = if normalized <= 1.0 {
        1.0
    } else if normalized <= 2.0 {
        2.0
    } else if normalized <= 2.5 {
        2.5
    } else if normalized <= 5.0 {
        5.0
    } else {
        10.0
    };
    let tick = nice * magnitude;
    if tick.is_finite() && tick > 0.0 {
        tick
    } else {
        1.0
    }
}

/// Convert price to integer key for HashMap
fn price_to_key(price: f64, tick_size: f64) -> i64 {
    (price / tick_size).round() as i64
}

/// Convert integer key back to price
fn key_to_price(key: i64, tick_size: f64) -> f64 {
    key as f64 * tick_size
}

/// Transform bars into TPO profiles
///
/// # Arguments
/// * `bars` - Source OHLCV bars
/// * `config` - TPO configuration
///
/// # Returns
/// Vector of TPO profiles, one per session
pub fn to_tpo_profiles(bars: &[Bar], config: &TPOConfig) -> Vec<TPOProfile> {
    if bars.is_empty() {
        return Vec::new();
    }

    let letters = config
        .custom_letters
        .as_deref()
        .unwrap_or(TPO_LETTERS)
        .chars()
        .collect::<Vec<_>>();

    let mut profiles: Vec<TPOProfile> = Vec::new();
    let mut current_profile: Option<TPOProfile> = None;
    let mut current_session_start: Option<DateTime<Utc>> = None;
    let mut period_idx = 0;
    let mut last_period_start: Option<DateTime<Utc>> = None;

    for bar in bars {
        // Check if we need to start a new session (new day)
        let session_date = bar.time.date_naive();
        let is_new_session = current_session_start
            .map(|s| s.date_naive() != session_date)
            .unwrap_or(true);

        if is_new_session {
            // Save the current profile
            if let Some(mut profile) = current_profile.take() {
                calculate_profile_stats(&mut profile, config);
                profiles.push(profile);
            }

            // Start new profile
            current_profile = Some(TPOProfile::new(bar.time));
            current_session_start = Some(bar.time);
            period_idx = 0;
            last_period_start = Some(bar.time);

            // Set opening price
            if let Some(ref mut profile) = current_profile {
                profile.opening_price = bar.open;
            }
        }

        // Check if we need to advance to a new period
        if let Some(period_start) = last_period_start {
            let elapsed_minutes = (bar.time - period_start).num_minutes();
            if elapsed_minutes >= config.period_minutes as i64 {
                period_idx += 1;
                last_period_start = Some(bar.time);
            }
        }

        // Get the letter for this period
        let letter = letters[period_idx % letters.len()];

        // Add TPO letters for all price levels touched by this bar
        if let Some(ref mut profile) = current_profile {
            add_tpo_letters(profile, bar, letter, period_idx, config.tick_size);

            // Update profile high/low
            profile.profile_high = profile.profile_high.max(bar.high);
            profile.profile_low = profile.profile_low.min(bar.low);

            // Update initial balance (first N minutes)
            if let Some(session_start) = current_session_start {
                let elapsed = (bar.time - session_start).num_minutes();
                if elapsed < config.initial_balance_minutes as i64 {
                    if profile.initial_balance_high == 0.0 {
                        profile.initial_balance_high = bar.high;
                        profile.initial_balance_low = bar.low;
                    } else {
                        profile.initial_balance_high = profile.initial_balance_high.max(bar.high);
                        profile.initial_balance_low = profile.initial_balance_low.min(bar.low);
                    }
                }
            }
        }
    }

    // Don't forget the last profile
    if let Some(mut profile) = current_profile.take() {
        calculate_profile_stats(&mut profile, config);
        profiles.push(profile);
    }

    profiles
}

/// Add TPO letters for a bar
fn add_tpo_letters(
    profile: &mut TPOProfile,
    bar: &Bar,
    letter: char,
    period_idx: usize,
    tick_size: f64,
) {
    let low_key = price_to_key(bar.low, tick_size);
    let high_key = price_to_key(bar.high, tick_size);

    for key in low_key..=high_key {
        let price = key_to_price(key, tick_size);

        profile.letters.push(TPOLetter {
            price,
            letter,
            period_idx,
            ts: bar.time,
        });

        *profile.price_tpo_count.entry(key).or_insert(0) += 1;
    }
}

/// Calculate profile statistics (POC, Value Area, etc.)
fn calculate_profile_stats(profile: &mut TPOProfile, config: &TPOConfig) {
    if profile.price_tpo_count.is_empty() {
        return;
    }

    // Find POC (price with most TPOs)
    let (poc_key, _) = profile
        .price_tpo_count
        .iter()
        .max_by_key(|(_, count)| *count)
        .unwrap();
    profile.poc_price = key_to_price(*poc_key, config.tick_size);

    // Calculate total TPOs
    let total_tpos: usize = profile.price_tpo_count.values().sum();

    // Calculate the Value Area (default 70% of TPOs) around the POC.
    //
    // This follows the canonical Market Profile expansion rule: start at the
    // POC, then repeatedly extend the contiguous range one row at a time,
    // always toward whichever adjacent side (the two rows immediately above
    // the current high, or the two rows immediately below the current low)
    // holds more TPOs. Counts are accumulated until the running total reaches
    // `value_area_pct` of all TPOs. The result is guaranteed contiguous and
    // centered on the POC, matching how CQG/Bloomberg/TradingView draw it.
    let target_tpos = (total_tpos as f64 * config.value_area_pct).ceil() as usize;
    let count_at = |key: i64| -> usize { *profile.price_tpo_count.get(&key).unwrap_or(&0) };

    let &min_key = profile.price_tpo_count.keys().min().unwrap();
    let &max_key = profile.price_tpo_count.keys().max().unwrap();

    let mut accumulated_tpos = count_at(*poc_key);
    let mut va_low_key = *poc_key;
    let mut va_high_key = *poc_key;

    // Two-rows-per-step look: pair the next row above the high with the row
    // beyond it, likewise below the low, then advance toward the heavier pair.
    while accumulated_tpos < target_tpos && (va_low_key > min_key || va_high_key < max_key) {
        let up_avail = va_high_key < max_key;
        let down_avail = va_low_key > min_key;

        let up_pair = if up_avail {
            count_at(va_high_key + 1) + count_at(va_high_key + 2)
        } else {
            0
        };
        let down_pair = if down_avail {
            count_at(va_low_key - 1) + count_at(va_low_key - 2)
        } else {
            0
        };

        // Prefer the heavier side; when only one side remains, take it.
        let go_up = match (up_avail, down_avail) {
            (true, true) => up_pair >= down_pair,
            (true, false) => true,
            (false, true) => false,
            (false, false) => break,
        };

        if go_up {
            va_high_key += 1;
            accumulated_tpos += count_at(va_high_key);
            if accumulated_tpos < target_tpos && va_high_key < max_key {
                va_high_key += 1;
                accumulated_tpos += count_at(va_high_key);
            }
        } else {
            va_low_key -= 1;
            accumulated_tpos += count_at(va_low_key);
            if accumulated_tpos < target_tpos && va_low_key > min_key {
                va_low_key -= 1;
                accumulated_tpos += count_at(va_low_key);
            }
        }
    }

    profile.value_area_low = key_to_price(va_low_key, config.tick_size);
    profile.value_area_high = key_to_price(va_high_key, config.tick_size);

    // Find single prints (price levels with only 1 TPO)
    profile.single_prints = profile
        .price_tpo_count
        .iter()
        .filter(|(_, count)| **count == 1)
        .map(|(&key, _)| key_to_price(key, config.tick_size))
        .collect();

    // Check for poor high/low (multiple TPOs at extreme)
    let high_key = price_to_key(profile.profile_high, config.tick_size);
    let low_key = price_to_key(profile.profile_low, config.tick_size);

    profile.has_poor_high = profile.price_tpo_count.get(&high_key).unwrap_or(&0) > &1;
    profile.has_poor_low = profile.price_tpo_count.get(&low_key).unwrap_or(&0) > &1;
}

/// Market profile shape classification.
///
/// The shape of a TPO profile conveys the type of market activity during
/// that session. Use [`TPOProfile::classify_shape`] to determine it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProfileShape {
    /// Normal distribution - balanced market
    Normal,
    /// Double distribution - potential breakout
    Double,
    /// P-shape - strong buying
    P,
    /// b-shape - strong selling
    B,
    /// D-shape - trending market
    D,
}

impl TPOProfile {
    /// Classify the profile shape
    pub fn classify_shape(&self, tick_size: f64) -> ProfileShape {
        let levels = self.price_levels(tick_size);
        if levels.len() < 3 {
            return ProfileShape::Normal;
        }

        let total_tpos: usize = self.price_tpo_count.values().sum();
        let upper_third = &levels[..levels.len() / 3];
        let lower_third = &levels[levels.len() * 2 / 3..];

        let upper_tpos: usize = upper_third
            .iter()
            .map(|p| self.tpo_count_at(*p, tick_size))
            .sum();
        let lower_tpos: usize = lower_third
            .iter()
            .map(|p| self.tpo_count_at(*p, tick_size))
            .sum();

        let upper_pct = upper_tpos as f64 / total_tpos as f64;
        let lower_pct = lower_tpos as f64 / total_tpos as f64;

        if upper_pct > 0.5 && lower_pct < 0.2 {
            ProfileShape::P
        } else if lower_pct > 0.5 && upper_pct < 0.2 {
            ProfileShape::B
        } else if upper_pct > 0.35 && lower_pct > 0.35 {
            ProfileShape::Double
        } else if (upper_pct - lower_pct).abs() < 0.1 {
            ProfileShape::Normal
        } else {
            ProfileShape::D
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Duration;

    fn create_test_bars() -> Vec<Bar> {
        let start = Utc::now();
        vec![
            Bar {
                time: start,
                open: 100.0,
                high: 102.0,
                low: 99.0,
                close: 101.0,
                volume: 1000.0,
            },
            Bar {
                time: start + Duration::minutes(30),
                open: 101.0,
                high: 103.0,
                low: 100.0,
                close: 102.0,
                volume: 1000.0,
            },
            Bar {
                time: start + Duration::minutes(60),
                open: 102.0,
                high: 104.0,
                low: 101.0,
                close: 103.0,
                volume: 1000.0,
            },
            Bar {
                time: start + Duration::minutes(90),
                open: 103.0,
                high: 104.0,
                low: 102.0,
                close: 103.5,
                volume: 1000.0,
            },
        ]
    }

    #[test]
    fn test_tpo_profile_creation() {
        let bars = create_test_bars();
        let config = TPOConfig {
            tick_size: 1.0,
            ..Default::default()
        };
        let profiles = to_tpo_profiles(&bars, &config);

        assert_eq!(profiles.len(), 1);
        let profile = &profiles[0];

        // Should have letters at multiple price levels
        assert!(!profile.letters.is_empty());
        assert!(!profile.price_tpo_count.is_empty());
    }

    #[test]
    fn test_tpo_poc_calculation() {
        let bars = create_test_bars();
        let config = TPOConfig {
            tick_size: 1.0,
            ..Default::default()
        };
        let profiles = to_tpo_profiles(&bars, &config);

        assert_eq!(profiles.len(), 1);
        let profile = &profiles[0];

        // POC should be within the price range
        assert!(profile.poc_price >= 99.0 && profile.poc_price <= 104.0);
    }

    #[test]
    fn test_tpo_value_area() {
        let bars = create_test_bars();
        let config = TPOConfig {
            tick_size: 1.0,
            value_area_pct: 0.70,
            ..Default::default()
        };
        let profiles = to_tpo_profiles(&bars, &config);

        let profile = &profiles[0];

        // Value area should contain POC
        assert!(profile.is_in_value_area(profile.poc_price));
        // Value area high should be >= POC
        assert!(profile.value_area_high >= profile.poc_price);
        // Value area low should be <= POC
        assert!(profile.value_area_low <= profile.poc_price);
    }

    #[test]
    fn test_tpo_initial_balance() {
        let bars = create_test_bars();
        let config = TPOConfig {
            tick_size: 1.0,
            initial_balance_minutes: 60,
            ..Default::default()
        };
        let profiles = to_tpo_profiles(&bars, &config);

        let profile = &profiles[0];

        // Initial balance should be set from first 60 minutes
        assert!(profile.initial_balance_high > 0.0);
        assert!(profile.initial_balance_low > 0.0);
        assert!(profile.initial_balance_high >= profile.initial_balance_low);
    }

    #[test]
    fn test_price_key_conversion() {
        let price = 100.5;
        let tick_size = 0.25;

        let key = price_to_key(price, tick_size);
        let back = key_to_price(key, tick_size);

        assert!((back - 100.5).abs() < 0.01);
    }

    /// Build a single bar at a chosen offset from a fixed session start.
    fn bar_at(start: DateTime<Utc>, mins: i64, o: f64, h: f64, l: f64, c: f64) -> Bar {
        Bar {
            time: start + Duration::minutes(mins),
            open: o,
            high: h,
            low: l,
            close: c,
            volume: 1.0,
        }
    }

    #[test]
    fn bin_assignment_covers_every_touched_tick() {
        // A single 30-minute bar spanning 100..=104 with tick 1.0 must register
        // exactly one TPO at each of the five integer price levels it touches.
        let start = Utc::now();
        let bars = vec![bar_at(start, 0, 100.0, 104.0, 100.0, 103.0)];
        let config = TPOConfig {
            tick_size: 1.0,
            ..Default::default()
        };
        let profile = &to_tpo_profiles(&bars, &config)[0];

        for level in [100.0, 101.0, 102.0, 103.0, 104.0] {
            assert_eq!(
                profile.tpo_count_at(level, 1.0),
                1,
                "level {level} should have exactly one TPO"
            );
        }
        // Nothing outside the bar's range.
        assert_eq!(profile.tpo_count_at(99.0, 1.0), 0);
        assert_eq!(profile.tpo_count_at(105.0, 1.0), 0);
    }

    #[test]
    fn poc_is_the_highest_count_bin() {
        // Three periods all overlap 102.0, fewer overlap the extremes, so 102.0
        // must accumulate the most TPOs and become the POC.
        let start = Utc::now();
        let bars = vec![
            bar_at(start, 0, 100.0, 102.0, 100.0, 101.0),
            bar_at(start, 30, 101.0, 103.0, 101.0, 102.0),
            bar_at(start, 60, 102.0, 104.0, 102.0, 103.0),
        ];
        let config = TPOConfig {
            tick_size: 1.0,
            ..Default::default()
        };
        let profile = &to_tpo_profiles(&bars, &config)[0];

        // Independently find the heaviest level and confirm POC matches it.
        let heaviest = profile
            .price_tpo_count
            .iter()
            .max_by_key(|(_, c)| **c)
            .map(|(&k, _)| key_to_price(k, 1.0))
            .unwrap();
        assert_eq!(profile.poc_price, heaviest);
        assert_eq!(profile.poc_price, 102.0);
    }

    #[test]
    fn value_area_is_contiguous_and_covers_about_seventy_percent() {
        // Stack many overlapping periods to build a tall, well-populated profile.
        let start = Utc::now();
        let mut bars = Vec::new();
        for p in 0..10 {
            // Each period drifts upward by one tick, all sharing the mid-band.
            let base = 100.0 + p as f64;
            bars.push(bar_at(start, p * 30, base, base + 5.0, base, base + 2.0));
        }
        let config = TPOConfig {
            tick_size: 1.0,
            value_area_pct: 0.70,
            ..Default::default()
        };
        let profile = &to_tpo_profiles(&bars, &config)[0];

        // POC sits inside the value area.
        assert!(profile.is_in_value_area(profile.poc_price));
        assert!(profile.value_area_low <= profile.poc_price);
        assert!(profile.value_area_high >= profile.poc_price);

        // The value area is a contiguous price band: every level between VAL and
        // VAH (inclusive) that exists in the profile is part of one run.
        let total: usize = profile.price_tpo_count.values().sum();
        let va_lo = price_to_key(profile.value_area_low, 1.0);
        let va_hi = price_to_key(profile.value_area_high, 1.0);
        assert!(va_lo <= va_hi);

        let va_tpos: usize = profile
            .price_tpo_count
            .iter()
            .filter(|&(&k, _)| k >= va_lo && k <= va_hi)
            .map(|(_, &c)| c)
            .sum();

        // Covers at least the 70% target, and isn't the whole profile (the tails
        // outside the value area must hold something).
        let target = (total as f64 * 0.70).ceil() as usize;
        assert!(
            va_tpos >= target,
            "value area {va_tpos} should reach target {target} of {total}"
        );
        assert!(
            va_tpos < total,
            "value area should exclude the thin tails (got all {total})"
        );
    }

    #[test]
    fn derive_tick_size_picks_nice_steps_and_handles_degenerate_input() {
        // ~40 rows over a 200-wide range → a 5.0 step (40 rows * 5 = 200).
        assert_eq!(derive_tick_size(200.0, 40), 5.0);
        // Tiny range scales down to a sub-unit nice step.
        assert!(derive_tick_size(0.4, 40) > 0.0);
        // Degenerate inputs fall back to 1.0 rather than producing NaN/zero ticks.
        assert_eq!(derive_tick_size(0.0, 40), 1.0);
        assert_eq!(derive_tick_size(-5.0, 40), 1.0);
        assert_eq!(derive_tick_size(100.0, 0), 1.0);
        assert_eq!(derive_tick_size(f64::NAN, 40), 1.0);
    }

    #[test]
    fn single_bar_zero_range_does_not_panic_and_yields_a_poc() {
        // A flat bar (high == low) is the degenerate edge: one price level, one
        // TPO. The profile must still build with that level as the POC.
        let start = Utc::now();
        let bars = vec![bar_at(start, 0, 100.0, 100.0, 100.0, 100.0)];
        let config = TPOConfig {
            tick_size: 1.0,
            ..Default::default()
        };
        let profiles = to_tpo_profiles(&bars, &config);
        assert_eq!(profiles.len(), 1);
        let profile = &profiles[0];
        assert_eq!(profile.poc_price, 100.0);
        assert_eq!(profile.value_area_low, 100.0);
        assert_eq!(profile.value_area_high, 100.0);
    }
}