use crate::model::Bar;
use chrono::{DateTime, Utc};
use std::collections::HashMap;
const TPO_LETTERS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
#[derive(Debug, Clone)]
pub struct TPOLetter {
pub price: f64,
pub letter: char,
pub period_idx: usize,
pub ts: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct TPOProfile {
pub session_date: DateTime<Utc>,
pub letters: Vec<TPOLetter>,
pub price_tpo_count: HashMap<i64, usize>,
pub poc_price: f64,
pub value_area_high: f64,
pub value_area_low: f64,
pub initial_balance_high: f64,
pub initial_balance_low: f64,
pub profile_high: f64,
pub profile_low: f64,
pub opening_price: f64,
pub single_prints: Vec<f64>,
pub has_poor_high: bool,
pub has_poor_low: bool,
}
#[derive(Debug, Clone)]
pub struct TPOConfig {
pub tick_size: f64,
pub period_minutes: u32,
pub initial_balance_minutes: u32,
pub value_area_pct: f64,
pub display_mode: TPODisplayMode,
pub color_mode: TPOColorMode,
pub show_poc: bool,
pub show_value_area: bool,
pub show_initial_balance: bool,
pub show_single_prints: bool,
pub show_opening_range: bool,
pub split_sessions: bool,
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,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TPODisplayMode {
Letters,
Blocks,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TPOColorMode {
ByPeriod,
Solid,
ByValueArea,
ByInitialBalance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionType {
RTH,
ETH,
Full,
}
impl TPOProfile {
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,
}
}
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)
}
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
}
pub fn is_in_value_area(&self, price: f64) -> bool {
price >= self.value_area_low && price <= self.value_area_high
}
pub fn is_in_initial_balance(&self, price: f64) -> bool {
price >= self.initial_balance_low && price <= self.initial_balance_high
}
pub fn width(&self) -> usize {
self.letters.iter().map(|l| l.period_idx).max().unwrap_or(0) + 1
}
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()
}
}
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
}
}
fn price_to_key(price: f64, tick_size: f64) -> i64 {
(price / tick_size).round() as i64
}
fn key_to_price(key: i64, tick_size: f64) -> f64 {
key as f64 * tick_size
}
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 {
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 {
if let Some(mut profile) = current_profile.take() {
calculate_profile_stats(&mut profile, config);
profiles.push(profile);
}
current_profile = Some(TPOProfile::new(bar.time));
current_session_start = Some(bar.time);
period_idx = 0;
last_period_start = Some(bar.time);
if let Some(ref mut profile) = current_profile {
profile.opening_price = bar.open;
}
}
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);
}
}
let letter = letters[period_idx % letters.len()];
if let Some(ref mut profile) = current_profile {
add_tpo_letters(profile, bar, letter, period_idx, config.tick_size);
profile.profile_high = profile.profile_high.max(bar.high);
profile.profile_low = profile.profile_low.min(bar.low);
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);
}
}
}
}
}
if let Some(mut profile) = current_profile.take() {
calculate_profile_stats(&mut profile, config);
profiles.push(profile);
}
profiles
}
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;
}
}
fn calculate_profile_stats(profile: &mut TPOProfile, config: &TPOConfig) {
if profile.price_tpo_count.is_empty() {
return;
}
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);
let total_tpos: usize = profile.price_tpo_count.values().sum();
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;
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
};
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);
profile.single_prints = profile
.price_tpo_count
.iter()
.filter(|(_, count)| **count == 1)
.map(|(&key, _)| key_to_price(key, config.tick_size))
.collect();
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;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProfileShape {
Normal,
Double,
P,
B,
D,
}
impl TPOProfile {
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];
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];
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];
assert!(profile.is_in_value_area(profile.poc_price));
assert!(profile.value_area_high >= profile.poc_price);
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];
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);
}
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() {
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"
);
}
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() {
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];
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() {
let start = Utc::now();
let mut bars = Vec::new();
for p in 0..10 {
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];
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);
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();
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() {
assert_eq!(derive_tick_size(200.0, 40), 5.0);
assert!(derive_tick_size(0.4, 40) > 0.0);
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() {
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);
}
}