1use 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
22const TPO_LETTERS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
24
25#[derive(Debug, Clone)]
27pub struct TPOLetter {
28 pub price: f64,
30 pub letter: char,
32 pub period_idx: usize,
34 pub ts: DateTime<Utc>,
36}
37
38#[derive(Debug, Clone)]
43pub struct TPOProfile {
44 pub session_date: DateTime<Utc>,
46 pub tick_size: f64,
51 pub instrument_tick: f64,
54 pub row_ticks: u64,
58 pub letters: Vec<TPOLetter>,
60 pub price_tpo_count: HashMap<i64, usize>,
62 pub poc_price: f64,
65 pub value_area_high: f64,
69 pub value_area_low: f64,
71 pub initial_balance_high: f64,
74 pub initial_balance_low: f64,
76 pub profile_high: f64,
78 pub profile_low: f64,
80 pub opening_price: f64,
82 pub single_prints: Vec<f64>,
84 pub has_poor_high: bool,
86 pub has_poor_low: bool,
88}
89
90#[derive(Debug, Clone)]
92pub struct TPOConfig {
93 pub tick_size: f64,
97 pub period_minutes: u32,
100 pub initial_balance_minutes: u32,
102 pub value_area_pct: f64,
104 pub display_mode: TPODisplayMode,
106 pub color_mode: TPOColorMode,
108 pub show_poc: bool,
110 pub show_value_area: bool,
112 pub show_initial_balance: bool,
114 pub show_initial_balance_extensions: bool,
116 pub show_poor_highs_lows: bool,
118 pub show_single_prints: bool,
120 pub show_opening_range: bool,
122 pub split_sessions: bool,
125 pub custom_letters: Option<String>,
127 pub composites: Vec<RangeInclusive<DateTime<Utc>>>,
131 pub latest_session_only: bool,
133 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum TPODisplayMode {
167 Letters,
169 Blocks,
171 Both,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum TPOColorMode {
178 ByPeriod,
180 Solid,
182 ByValueArea,
184 ByInitialBalance,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum SessionType {
191 RTH,
193 ETH,
195 Full,
197}
198
199impl TPOProfile {
200 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 fn key(&self, price: f64) -> i64 {
225 level_key(price, self.instrument_tick, self.row_ticks)
226 }
227
228 pub fn row_price(&self, price: f64) -> f64 {
230 key_to_price(self.key(price), self.tick_size)
231 }
232
233 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 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 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 pub fn is_in_value_area(&self, price: f64) -> bool {
259 price >= self.value_area_low && price <= self.value_area_high
260 }
261
262 pub fn is_in_initial_balance(&self, price: f64) -> bool {
264 price >= self.initial_balance_low && price <= self.initial_balance_high
265 }
266
267 pub fn width(&self) -> usize {
269 self.letters.iter().map(|l| l.period_idx).max().unwrap_or(0) + 1
270 }
271
272 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 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
293pub 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
328const EXACT_TICKS: f64 = (1u64 << 50) as f64;
330
331pub(crate) fn whole_ticks(height: f64, tick: f64) -> f64 {
334 (height / tick * (1.0 - 1e-9)).ceil().max(1.0)
335}
336
337fn 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
348fn key_to_price(key: i64, tick_size: f64) -> f64 {
350 key as f64 * tick_size
351}
352
353pub(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
383pub 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 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 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 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 profiles.extend(current_profile.take());
494
495 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 profile.profile_high = profile.profile_high.max(bar.high);
515 profile.profile_low = profile.profile_low.min(bar.low);
516
517 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 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
562fn 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
590fn calculate_profile_stats(profile: &mut TPOProfile, config: &TPOConfig) {
592 if profile.price_tpo_count.is_empty() {
593 return;
594 }
595
596 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 let total_tpos: usize = profile.price_tpo_count.values().sum();
606
607 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
701pub enum ProfileShape {
702 Normal,
704 Double,
706 P,
708 B,
710 D,
712}
713
714impl TPOProfile {
715 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 type Case<'a> = (i64, &'a [(f64, f64)], (f64, f64));
757 let cases: [Case; 3] = [
758 (30, &[(0.0, -1.0), (-0.25, -0.5)], (0.0, -1.0)),
760 (1, &[(-1.0, -2.0), (0.0, -1.5), (-0.5, -0.75)], (0.0, -2.0)),
762 (
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 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 type Case = (
812 &'static [(i64, f64, f64)],
813 &'static [(usize, usize, char, i64, RangeInclusive<i64>)],
814 );
815 let cases: [Case; 7] = [
816 (&[(0, 100.0, 104.0)], &[(0, 0, 'A', 0, 100..=104)]),
818 (
820 &[(0, 100.0, 102.0), (1, 101.0, 104.0)],
821 &[(0, 0, 'A', 0, 100..=104)],
822 ),
823 (
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 (
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 (
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 (
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 (
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 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 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 let start = Utc.with_ymd_and_hms(2024, 3, 5, 13, 0, 0).unwrap();
919 let both_days = start..=start + Duration::days(1);
920 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 &[(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 (
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 (
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 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 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 (
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 (
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 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 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 type Case<'a> = (&'a str, Vec<(f64, f64)>, (f64, f64, f64), Vec<f64>);
1158 let cases: [Case; 2] = [
1159 (
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 (
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 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 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 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 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 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 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 assert_eq!(derive_tick_size(200.0, 40), 5.0);
1263 assert!(derive_tick_size(0.4, 40) > 0.0);
1265 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 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 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 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 (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 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}