1use super::MAX_PROFILE_PRICE_ROWS;
2use crate::config::theme::{ThemeAccent, ThemeColor, ThemeColorRole, ThemeColorShade};
3use chrono::{Datelike, Months, NaiveDate};
4use exchange::unit::price::{Price, PriceStep};
5use exchange::{Kline, Timeframe, Trade, UnixMs};
6use rustc_hash::FxHashSet;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9
10pub const TPO_TRADE_COVERAGE_TIMEFRAME: Timeframe = Timeframe::M1;
11
12#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum TpoAnchor {
14 #[default]
15 Daily,
16 Weekly,
17 Monthly,
18}
19
20impl TpoAnchor {
21 pub const ALL: [Self; 3] = [Self::Daily, Self::Weekly, Self::Monthly];
22
23 pub fn floor(self, time: UnixMs) -> UnixMs {
24 match self {
25 Self::Daily => time.floor_to(Timeframe::D1),
26 Self::Weekly => {
27 let Some(date_time) = time.as_datetime_utc() else {
28 return time.floor_to(Timeframe::D1);
29 };
30 let days_from_monday = u64::from(date_time.weekday().num_days_from_monday());
31 UnixMs::new(
32 time.floor_to(Timeframe::D1)
33 .as_u64()
34 .saturating_sub(days_from_monday * Timeframe::D1.to_milliseconds()),
35 )
36 }
37 Self::Monthly => time
38 .as_datetime_utc()
39 .and_then(|date_time| {
40 let date = date_time.date_naive();
41 NaiveDate::from_ymd_opt(date.year(), date.month(), 1)
42 })
43 .and_then(Self::midnight)
44 .unwrap_or(time),
45 }
46 }
47
48 pub fn offset(self, time: UnixMs, periods: i64) -> UnixMs {
49 match self {
50 Self::Daily => time.offset_by_timeframe(Timeframe::D1, periods),
51 Self::Weekly => time.offset_by_timeframe(Timeframe::D1, periods.saturating_mul(7)),
52 Self::Monthly => {
53 let Some(date) = time
54 .as_datetime_utc()
55 .map(|date_time| date_time.date_naive())
56 else {
57 return time;
58 };
59 let Ok(months) = u32::try_from(periods.unsigned_abs()) else {
60 return time;
61 };
62 let months = Months::new(months);
63 let date = if periods < 0 {
64 date.checked_sub_months(months)
65 } else {
66 date.checked_add_months(months)
67 };
68 date.and_then(Self::midnight).unwrap_or(time)
69 }
70 }
71 }
72
73 fn midnight(date: NaiveDate) -> Option<UnixMs> {
74 u64::try_from(date.and_hms_opt(0, 0, 0)?.and_utc().timestamp_millis())
75 .ok()
76 .map(UnixMs::from_millis)
77 }
78}
79
80impl std::fmt::Display for TpoAnchor {
81 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 formatter.write_str(match self {
83 Self::Daily => "Daily",
84 Self::Weekly => "Weekly",
85 Self::Monthly => "Monthly",
86 })
87 }
88}
89
90pub const DEFAULT_TPO_LETTER_PERIOD: Timeframe = Timeframe::M30;
91pub const DEFAULT_TPO_VALUE_AREA_PERCENT: u8 = 70;
92pub const MIN_TPO_VALUE_AREA_PERCENT: u8 = 5;
93pub const MAX_TPO_VALUE_AREA_PERCENT: u8 = 100;
94
95pub(super) const fn default_tpo_letter_period() -> Timeframe {
96 DEFAULT_TPO_LETTER_PERIOD
97}
98
99pub(super) const fn default_tpo_anchor() -> TpoAnchor {
100 TpoAnchor::Daily
101}
102
103pub(super) const fn default_tpo_value_area_percent() -> u8 {
104 DEFAULT_TPO_VALUE_AREA_PERCENT
105}
106
107pub(super) const fn default_tpo_visible() -> bool {
108 true
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112pub struct TpoColorConfig {
113 pub letters: ThemeColor,
114 pub poc: ThemeColor,
115 pub value_area: ThemeColor,
116 pub initial_balance: ThemeColor,
117 pub single_print: ThemeColor,
118 pub poor_high: ThemeColor,
119 pub poor_low: ThemeColor,
120 #[serde(default)]
121 pub show_poc: bool,
122 #[serde(default)]
123 pub show_initial_balance: bool,
124 #[serde(default)]
125 pub show_single_print: bool,
126 #[serde(default)]
127 pub show_poor_high: bool,
128 #[serde(default)]
129 pub show_poor_low: bool,
130}
131
132impl Default for TpoColorConfig {
133 fn default() -> Self {
134 Self::defaults()
135 }
136}
137
138impl TpoColorConfig {
139 pub const fn defaults() -> Self {
140 Self {
141 letters: ThemeColor::new(ThemeColorRole::Neutral, ThemeColorShade::Soft),
142 poc: ThemeColor::new(ThemeColorRole::Warning, ThemeColorShade::Strong),
143 value_area: ThemeColor::new(ThemeColorRole::Secondary, ThemeColorShade::Strong),
144 initial_balance: ThemeColor::new(ThemeColorRole::Primary, ThemeColorShade::Base),
145 single_print: ThemeColor::with_accent(
146 ThemeColorRole::Neutral,
147 Some(ThemeAccent::Three),
148 ThemeColorShade::Base,
149 ),
150 poor_high: ThemeColor::with_accent(
151 ThemeColorRole::Primary,
152 Some(ThemeAccent::Seven),
153 ThemeColorShade::Base,
154 ),
155 poor_low: ThemeColor::with_accent(
156 ThemeColorRole::Primary,
157 Some(ThemeAccent::Seven),
158 ThemeColorShade::Base,
159 ),
160 show_poc: false,
161 show_initial_balance: false,
162 show_single_print: false,
163 show_poor_high: false,
164 show_poor_low: false,
165 }
166 }
167
168 pub const fn with_show_poc(self, show_poc: bool) -> Self {
169 Self { show_poc, ..self }
170 }
171
172 pub const fn with_show_initial_balance(self, show_initial_balance: bool) -> Self {
173 Self {
174 show_initial_balance,
175 ..self
176 }
177 }
178
179 pub const fn with_show_single_print(self, show_single_print: bool) -> Self {
180 Self {
181 show_single_print,
182 ..self
183 }
184 }
185
186 pub const fn with_show_poor_extremes(self, show: bool) -> Self {
187 Self {
188 show_poor_high: show,
189 show_poor_low: show,
190 ..self
191 }
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub struct TpoProfileSettings {
197 pub anchor: TpoAnchor,
198 pub letter_period: Timeframe,
199 pub price_step: PriceStep,
200 pub value_area_percent: u8,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
204pub struct TpoBoundaryEdits {
205 #[serde(default)]
206 pub added: Vec<UnixMs>,
207 #[serde(default)]
208 pub removed: Vec<UnixMs>,
209}
210
211impl TpoBoundaryEdits {
212 pub fn clear(&mut self) {
213 self.added.clear();
214 self.removed.clear();
215 }
216
217 pub fn has_boundary_in_range(&self, start: UnixMs, end: UnixMs) -> bool {
218 let (start, end) = (start.min(end), start.max(end));
219 self.added
220 .iter()
221 .chain(self.removed.iter())
222 .any(|boundary| *boundary >= start && *boundary <= end)
223 }
224
225 pub fn clear_range(&mut self, start: UnixMs, end: UnixMs, anchor: TpoAnchor) {
226 let (start, end) = (start.min(end), start.max(end));
227 let added = self
228 .added
229 .iter()
230 .copied()
231 .filter(|boundary| *boundary >= start && *boundary <= end)
232 .collect::<Vec<_>>();
233 let mut related_removed = BTreeSet::new();
234 for boundary in added {
235 if boundary == start {
236 let floor = anchor.floor(boundary);
237 let previous = if floor < boundary {
238 floor
239 } else {
240 anchor.offset(floor, -1)
241 };
242 self.add_removed_boundary_run(previous, -1, anchor, &mut related_removed);
243 }
244 if boundary == end {
245 let floor = anchor.floor(boundary);
246 let next = if floor > boundary {
247 floor
248 } else {
249 anchor.offset(floor, 1)
250 };
251 self.add_removed_boundary_run(next, 1, anchor, &mut related_removed);
252 }
253 }
254 self.added
255 .retain(|boundary| *boundary < start || *boundary > end);
256 self.removed.retain(|boundary| {
257 (*boundary < start || *boundary > end) && !related_removed.contains(boundary)
258 });
259 }
260
261 fn add_removed_boundary_run(
262 &self,
263 seed: UnixMs,
264 direction: i64,
265 anchor: TpoAnchor,
266 related_removed: &mut BTreeSet<UnixMs>,
267 ) {
268 if !self.removed.contains(&seed) {
269 return;
270 }
271 let mut boundary = seed;
272 loop {
273 if !self.removed.contains(&boundary) {
274 break;
275 }
276 related_removed.insert(boundary);
277 let next = anchor.offset(boundary, direction);
278 if (direction < 0 && next >= boundary) || (direction > 0 && next <= boundary) {
279 break;
280 }
281 boundary = next;
282 }
283 }
284
285 pub fn split_at(&mut self, boundary: UnixMs) {
286 self.removed.retain(|value| *value != boundary);
287 if !self.added.contains(&boundary) {
288 self.added.push(boundary);
289 self.added.sort_unstable();
290 }
291 }
292
293 pub fn merge_at(&mut self, boundary: UnixMs) {
294 self.added.retain(|value| *value != boundary);
295 if !self.removed.contains(&boundary) {
296 self.removed.push(boundary);
297 self.removed.sort_unstable();
298 }
299 }
300
301 pub fn move_boundary(&mut self, from: UnixMs, to: UnixMs, anchor: TpoAnchor) {
302 if from == to {
303 return;
304 }
305
306 let first = from.min(to);
307 let last = from.max(to);
308 self.added.retain(|value| *value != from);
309 self.added
310 .retain(|value| !(*value > first && *value < last));
311 self.removed.retain(|value| *value != from && *value != to);
312
313 if anchor.floor(from) == from {
314 self.removed.push(from);
315 }
316
317 let mut boundary = anchor.floor(first);
318 while boundary < last {
319 if boundary > first {
320 self.removed.push(boundary);
321 }
322 let next = anchor.offset(boundary, 1);
323 if next <= boundary {
324 break;
325 }
326 boundary = next;
327 }
328
329 if anchor.floor(to) != to {
330 self.added.push(to);
331 }
332
333 self.added.sort_unstable();
334 self.added.dedup();
335 self.removed.sort_unstable();
336 self.removed.dedup();
337 }
338
339 pub fn profile_ranges_overlapping(
340 &self,
341 start: UnixMs,
342 end: UnixMs,
343 anchor: TpoAnchor,
344 ) -> Vec<(UnixMs, UnixMs)> {
345 let (start, end) = (start.min(end), start.max(end));
346 if start >= end {
347 return Vec::new();
348 }
349
350 let mut first = anchor.floor(start);
351 while self.removed.contains(&first) {
352 let previous = anchor.offset(first, -1);
353 if previous >= first {
354 break;
355 }
356 first = previous;
357 }
358
359 let mut last = anchor.offset(anchor.floor(end), 1);
360 while self.removed.contains(&last) {
361 let next = anchor.offset(last, 1);
362 if next <= last {
363 break;
364 }
365 last = next;
366 }
367
368 self.boundaries_between(first, last, anchor)
369 .windows(2)
370 .filter_map(|window| {
371 let profile_start = window[0];
372 let profile_end = window[1];
373 (profile_start < end && profile_end > start).then_some((profile_start, profile_end))
374 })
375 .collect()
376 }
377
378 fn boundaries_between(&self, first: UnixMs, last: UnixMs, anchor: TpoAnchor) -> Vec<UnixMs> {
379 let mut boundaries = BTreeSet::new();
380 let mut boundary = first;
381 while boundary <= last {
382 boundaries.insert(boundary);
383 let next = anchor.offset(boundary, 1);
384 if next <= boundary {
385 break;
386 }
387 boundary = next;
388 }
389
390 boundaries.extend(
391 self.added
392 .iter()
393 .copied()
394 .filter(|value| *value > first && *value < last),
395 );
396 for removed in &self.removed {
397 boundaries.remove(removed);
398 }
399
400 boundaries.into_iter().collect()
401 }
402}
403
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub enum TpoProfileStatus {
406 Developing,
407 Complete,
408 Incomplete,
409}
410
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct TpoRow {
413 pub price: Price,
414 pub periods: Vec<u16>,
415}
416
417impl TpoRow {
418 pub fn count(&self) -> usize {
419 self.periods.len()
420 }
421}
422
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct TpoProfile {
425 pub start: UnixMs,
426 pub end: UnixMs,
427 pub period_count: usize,
428 pub rows: Vec<TpoRow>,
429 pub session_high: Option<Price>,
430 pub session_low: Option<Price>,
431 pub initial_balance_high: Option<Price>,
432 pub initial_balance_low: Option<Price>,
433 pub poc: Option<Price>,
434 pub vah: Option<Price>,
435 pub val: Option<Price>,
436 pub single_prints: Vec<Price>,
437 pub poor_highs: Vec<Price>,
438 pub poor_lows: Vec<Price>,
439 pub status: TpoProfileStatus,
440}
441
442impl TpoProfile {
443 pub fn letter_label(index: usize) -> String {
444 const LETTERS: &[u8; 52] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
445 let mut value = index;
446 let mut label = Vec::new();
447 loop {
448 label.push(char::from(LETTERS[value % LETTERS.len()]));
449 if value < LETTERS.len() {
450 break;
451 }
452 value = value / LETTERS.len() - 1;
453 }
454 label.into_iter().rev().collect()
455 }
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum TpoCalculationError {
460 InvalidLetterPeriod,
461 InvalidPriceStep,
462 TooManyRows {
463 start: UnixMs,
464 end: UnixMs,
465 rows: usize,
466 },
467}
468
469pub struct TradeTpoCache {
470 anchor: TpoAnchor,
471 letter_period: Timeframe,
472 edits: TpoBoundaryEdits,
473 trade_count: usize,
474 profiles: BTreeMap<UnixMs, TradeTpoProfileCache>,
475}
476
477struct TradeTpoProfileCache {
478 start: UnixMs,
479 end: UnixMs,
480 periods: Vec<TradeTpoPeriodCache>,
481 session_high: Option<Price>,
482 session_low: Option<Price>,
483 initial_balance_high: Option<Price>,
484 initial_balance_low: Option<Price>,
485}
486
487struct TradeTpoPeriodCache {
488 start: UnixMs,
489 end: UnixMs,
490 rows: FxHashSet<Price>,
491}
492
493impl TradeTpoCache {
494 pub fn new(
495 trades: &[Trade],
496 settings: TpoProfileSettings,
497 edits: TpoBoundaryEdits,
498 ) -> Result<Self, TpoCalculationError> {
499 validate_settings(settings)?;
500 let mut cache = Self {
501 anchor: settings.anchor,
502 letter_period: settings.letter_period,
503 edits,
504 trade_count: 0,
505 profiles: BTreeMap::new(),
506 };
507 if !cache.sync(trades) {
508 unreachable!("trade cache ranges cover all source trades");
509 }
510 Ok(cache)
511 }
512
513 pub fn sync(&mut self, trades: &[Trade]) -> bool {
514 if trades.len() < self.trade_count {
515 return false;
516 }
517 let new_trades = &trades[self.trade_count..];
518 if let Some((first, last)) = new_trades.iter().map(|trade| trade.time).fold(
519 None,
520 |range: Option<(UnixMs, UnixMs)>, time| {
521 Some(range.map_or((time, time), |(first, last)| {
522 (first.min(time), last.max(time))
523 }))
524 },
525 ) {
526 self.extend_windows(first, last.saturating_add(1));
527 }
528 for trade in new_trades {
529 self.profiles
530 .range_mut(..=trade.time)
531 .next_back()
532 .filter(|(_, profile)| trade.time < profile.end)
533 .expect("trade cache ranges cover all source trades")
534 .1
535 .add_trade(trade, self.letter_period);
536 }
537 self.trade_count = trades.len();
538 true
539 }
540
541 pub fn build_profile(
542 &self,
543 start: UnixMs,
544 settings: TpoProfileSettings,
545 now: UnixMs,
546 status: TpoProfileStatus,
547 ) -> Option<Result<TpoProfile, TpoCalculationError>> {
548 self.profiles.get(&start).map(|profile| {
549 validate_settings(settings)?;
550 profile.build(settings, now, status)
551 })
552 }
553
554 pub fn build_profiles(
555 &self,
556 settings: TpoProfileSettings,
557 now: UnixMs,
558 status: impl Fn(UnixMs, UnixMs) -> TpoProfileStatus,
559 ) -> Result<Vec<TpoProfile>, TpoCalculationError> {
560 validate_settings(settings)?;
561 self.profiles
562 .values()
563 .map(|profile| profile.build(settings, now, status(profile.start, profile.end)))
564 .collect()
565 }
566
567 pub fn matches_source(&self, settings: TpoProfileSettings, edits: &TpoBoundaryEdits) -> bool {
568 self.anchor == settings.anchor
569 && self.letter_period == settings.letter_period
570 && &self.edits == edits
571 }
572
573 fn extend_windows(&mut self, start: UnixMs, end: UnixMs) {
574 let anchor = self.anchor;
575
576 let start = anchor.floor(start);
577 let end = anchor.offset(anchor.floor(end.saturating_sub(1)), 1);
578
579 let (start, end) = match (
580 self.profiles.first_key_value(),
581 self.profiles.last_key_value(),
582 ) {
583 (Some((&first, _)), Some((_, last))) => {
584 if start >= first && end <= last.end {
585 return;
586 }
587 (start.min(first), end.max(last.end))
588 }
589 _ => (start, end),
590 };
591
592 for (start, end) in self
593 .edits
594 .profile_ranges_overlapping(start, end, self.anchor)
595 {
596 self.profiles
597 .entry(start)
598 .or_insert_with(|| TradeTpoProfileCache::new(start, end, self.letter_period));
599 }
600 }
601}
602
603impl TradeTpoProfileCache {
604 fn new(start: UnixMs, end: UnixMs, letter_period: Timeframe) -> Self {
605 let mut periods = Vec::new();
606 let mut period_start = start;
607 while period_start < end {
608 let period_end = period_start.offset_by_timeframe(letter_period, 1).min(end);
609 periods.push(TradeTpoPeriodCache {
610 start: period_start,
611 end: period_end,
612 rows: FxHashSet::default(),
613 });
614 let next = period_start.offset_by_timeframe(letter_period, 1);
615 if next <= period_start {
616 break;
617 }
618 period_start = next;
619 }
620 Self {
621 start,
622 end,
623 periods,
624 session_high: None,
625 session_low: None,
626 initial_balance_high: None,
627 initial_balance_low: None,
628 }
629 }
630
631 fn add_trade(&mut self, trade: &Trade, letter_period: Timeframe) {
632 if trade.time < self.start || trade.time >= self.end {
633 return;
634 }
635 self.session_high = Some(
636 self.session_high
637 .map_or(trade.price, |high| high.max(trade.price)),
638 );
639 self.session_low = Some(
640 self.session_low
641 .map_or(trade.price, |low| low.min(trade.price)),
642 );
643 let initial_balance_end = self.start.offset_by_timeframe(Timeframe::H1, 1);
644 if trade.time < initial_balance_end {
645 self.initial_balance_high = Some(
646 self.initial_balance_high
647 .map_or(trade.price, |high| high.max(trade.price)),
648 );
649 self.initial_balance_low = Some(
650 self.initial_balance_low
651 .map_or(trade.price, |low| low.min(trade.price)),
652 );
653 }
654 let period_index = usize::try_from(
655 trade.time.as_u64().saturating_sub(self.start.as_u64())
656 / letter_period.to_milliseconds().max(1),
657 )
658 .unwrap_or(usize::MAX);
659 let Some(period) = self.periods.get_mut(period_index) else {
660 return;
661 };
662 period.rows.insert(trade.price);
663 }
664
665 fn build(
666 &self,
667 settings: TpoProfileSettings,
668 now: UnixMs,
669 status: TpoProfileStatus,
670 ) -> Result<TpoProfile, TpoCalculationError> {
671 let requested_step = settings.price_step;
672 let Some(session_low_price) = self.session_low else {
673 return Ok(empty_profile_with_status(self.start, self.end, status));
674 };
675 let Some(session_high_price) = self.session_high else {
676 return Ok(empty_profile_with_status(self.start, self.end, status));
677 };
678 let (session_low, row_count, settings) = bounded_profile_settings(
679 session_low_price,
680 session_high_price,
681 settings,
682 self.start,
683 self.end,
684 )?;
685
686 let periods = self
687 .periods
688 .iter()
689 .map(|period| (period.start, period.end))
690 .collect::<Vec<_>>();
691 let mut row_periods = vec![Vec::<u16>::new(); row_count];
692 for (period_index, cached) in self.periods.iter().enumerate() {
693 let period_index = u16::try_from(period_index).unwrap_or(u16::MAX);
694 for price in &cached.rows {
695 let price = price
697 .floor_to_step(requested_step)
698 .floor_to_step(settings.price_step);
699 let Some(row_index) =
700 Price::steps_between_inclusive(session_low, price, settings.price_step)
701 .and_then(|count| count.checked_sub(1))
702 else {
703 continue;
704 };
705 let row_index = row_index.min(row_count.saturating_sub(1));
706 if row_periods[row_index].last().copied() != Some(period_index) {
707 row_periods[row_index].push(period_index);
708 }
709 }
710 }
711
712 finalize_profile(
713 self.start,
714 self.end,
715 periods,
716 row_periods,
717 session_high_price,
718 session_low_price,
719 self.initial_balance_high,
720 self.initial_balance_low,
721 status,
722 settings,
723 now,
724 )
725 }
726}
727
728pub fn build_tpo_profiles_with_source_timeframe(
729 bars: &[Kline],
730 settings: TpoProfileSettings,
731 edits: TpoBoundaryEdits,
732 source_timeframe: Timeframe,
733 now: UnixMs,
734) -> Result<Vec<TpoProfile>, TpoCalculationError> {
735 validate_settings(settings)?;
736 let Some(first_bar) = bars.iter().map(|bar| bar.time).min() else {
737 return Ok(Vec::new());
738 };
739 let Some(last_bar) = bars.iter().map(|bar| bar.time).max() else {
740 return Ok(Vec::new());
741 };
742
743 let first = settings.anchor.floor(first_bar);
744 let last = settings.anchor.offset(settings.anchor.floor(last_bar), 1);
745 let boundaries = edits.boundaries_between(first, last, settings.anchor);
746
747 boundaries
748 .windows(2)
749 .map(|window| {
750 build_tpo_profile_with_source_timeframe(
751 bars,
752 window[0],
753 window[1],
754 settings,
755 source_timeframe,
756 now,
757 )
758 })
759 .collect()
760}
761
762pub fn build_tpo_profile_with_source_timeframe(
763 bars: &[Kline],
764 start: UnixMs,
765 end: UnixMs,
766 settings: TpoProfileSettings,
767 source_timeframe: Timeframe,
768 now: UnixMs,
769) -> Result<TpoProfile, TpoCalculationError> {
770 validate_settings(settings)?;
771 let mut profile_bars: Vec<Kline> = bars
772 .iter()
773 .copied()
774 .filter(|bar| bar.time >= start && bar.time < end)
775 .collect();
776 profile_bars.sort_unstable_by_key(|bar| bar.time);
777
778 let session_high = profile_bars.iter().map(|bar| bar.high).max();
779 let session_low = profile_bars.iter().map(|bar| bar.low).min();
780 let initial_balance_end = start.offset_by_timeframe(Timeframe::H1, 1);
781 let initial_balance_high = profile_bars
782 .iter()
783 .filter(|bar| bar.time < initial_balance_end)
784 .map(|bar| bar.high)
785 .max();
786 let initial_balance_low = profile_bars
787 .iter()
788 .filter(|bar| bar.time < initial_balance_end)
789 .map(|bar| bar.low)
790 .min();
791
792 let Some(session_low_price) = session_low else {
793 return Ok(empty_profile(start, end, source_timeframe, now));
794 };
795 let Some(session_high_price) = session_high else {
796 return Ok(empty_profile(start, end, source_timeframe, now));
797 };
798 let (session_low, row_count, settings) =
799 bounded_profile_settings(session_low_price, session_high_price, settings, start, end)?;
800
801 let mut periods = Vec::new();
802 let mut period_start = start;
803 while period_start < end {
804 let period_end = period_start
805 .offset_by_timeframe(settings.letter_period, 1)
806 .min(end);
807 periods.push((period_start, period_end));
808 period_start = period_start.offset_by_timeframe(settings.letter_period, 1);
809 if period_start <= periods.last().expect("period was pushed").0 {
810 break;
811 }
812 }
813 let mut row_periods = vec![Vec::<u16>::new(); row_count];
814 let mut bars = profile_bars.iter().peekable();
815 for (period_index, (_, period_end)) in periods.iter().enumerate() {
816 let mut touched = BTreeSet::new();
817 while let Some(bar) = bars.next_if(|bar| bar.time < *period_end) {
818 let low = bar.low.floor_to_step(settings.price_step);
819 let high = bar.high.floor_to_step(settings.price_step);
820 let Some(low_index) =
821 Price::steps_between_inclusive(session_low, low, settings.price_step)
822 .and_then(|count| count.checked_sub(1))
823 else {
824 continue;
825 };
826 let Some(high_index) =
827 Price::steps_between_inclusive(session_low, high, settings.price_step)
828 .and_then(|count| count.checked_sub(1))
829 else {
830 continue;
831 };
832 let high_index = high_index.min(row_count.saturating_sub(1));
833 for row_index in low_index.min(high_index)..=high_index {
834 touched.insert(row_index);
835 }
836 }
837 for row_index in touched {
838 row_periods[row_index].push(u16::try_from(period_index).unwrap_or(u16::MAX));
839 }
840 }
841
842 finalize_profile(
843 start,
844 end,
845 periods,
846 row_periods,
847 session_high_price,
848 session_low_price,
849 initial_balance_high,
850 initial_balance_low,
851 profile_coverage(start, end, &profile_bars, source_timeframe, now),
852 settings,
853 now,
854 )
855}
856
857fn finalize_profile(
858 start: UnixMs,
859 end: UnixMs,
860 periods: Vec<(UnixMs, UnixMs)>,
861 row_periods: Vec<Vec<u16>>,
862 session_high: Price,
863 session_low: Price,
864 initial_balance_high: Option<Price>,
865 initial_balance_low: Option<Price>,
866 status: TpoProfileStatus,
867 settings: TpoProfileSettings,
868 now: UnixMs,
869) -> Result<TpoProfile, TpoCalculationError> {
870 let rows = row_periods
871 .into_iter()
872 .enumerate()
873 .map(|(index, periods)| TpoRow {
874 price: session_low
875 .floor_to_step(settings.price_step)
876 .checked_add_steps(index as i64, settings.price_step)
877 .expect("validated TPO row range"),
878 periods,
879 })
880 .collect::<Vec<_>>();
881 let (poc_index, vah_index, val_index) = value_area_indexes(
882 &rows,
883 settings
884 .value_area_percent
885 .clamp(MIN_TPO_VALUE_AREA_PERCENT, MAX_TPO_VALUE_AREA_PERCENT),
886 );
887 let single_prints = rows
888 .iter()
889 .filter(|row| row.periods.len() == 1)
890 .filter(|row| {
891 let Some(period_index) = row.periods.first().copied().map(usize::from) else {
892 return false;
893 };
894 status != TpoProfileStatus::Incomplete
895 && periods
896 .get(period_index)
897 .is_some_and(|(_, period_end)| *period_end <= now)
898 })
899 .map(|row| row.price)
900 .collect();
901 let poor_highs = repeated_extremes(&rows, true);
902 let poor_lows = repeated_extremes(&rows, false);
903 let poc = poc_index.and_then(|index| rows.get(index).map(|row| row.price));
904 let vah = vah_index.and_then(|index| rows.get(index).map(|row| row.price));
905 let val = val_index.and_then(|index| rows.get(index).map(|row| row.price));
906
907 Ok(TpoProfile {
908 start,
909 end,
910 period_count: periods.len(),
911 rows,
912 session_high: Some(session_high),
913 session_low: Some(session_low),
914 initial_balance_high,
915 initial_balance_low,
916 poc,
917 vah,
918 val,
919 single_prints,
920 poor_highs,
921 poor_lows,
922 status,
923 })
924}
925
926fn bounded_profile_settings(
927 session_low: Price,
928 session_high: Price,
929 mut settings: TpoProfileSettings,
930 start: UnixMs,
931 end: UnixMs,
932) -> Result<(Price, usize, TpoProfileSettings), TpoCalculationError> {
933 let Some(price_step) =
934 settings
935 .price_step
936 .coarsened_to_fit(session_low, session_high, MAX_PROFILE_PRICE_ROWS)
937 else {
938 return Err(TpoCalculationError::TooManyRows {
939 start,
940 end,
941 rows: usize::MAX,
942 });
943 };
944 settings.price_step = price_step;
945 let session_low = session_low.floor_to_step(price_step);
946 let session_high = session_high.floor_to_step(price_step);
947 let row_count =
948 Price::steps_between_inclusive(session_low, session_high, price_step).unwrap_or(usize::MAX);
949 if row_count == 0 || row_count > MAX_PROFILE_PRICE_ROWS {
950 return Err(TpoCalculationError::TooManyRows {
951 start,
952 end,
953 rows: row_count,
954 });
955 }
956 Ok((session_low, row_count, settings))
957}
958
959fn validate_settings(settings: TpoProfileSettings) -> Result<(), TpoCalculationError> {
960 if !matches!(
961 settings.letter_period,
962 Timeframe::M15 | Timeframe::M30 | Timeframe::H1 | Timeframe::H2 | Timeframe::H4
963 ) {
964 return Err(TpoCalculationError::InvalidLetterPeriod);
965 }
966 if settings.price_step.units <= 0 {
967 return Err(TpoCalculationError::InvalidPriceStep);
968 }
969 Ok(())
970}
971
972fn empty_profile(
973 start: UnixMs,
974 end: UnixMs,
975 source_timeframe: Timeframe,
976 now: UnixMs,
977) -> TpoProfile {
978 empty_profile_with_status(
979 start,
980 end,
981 profile_coverage(start, end, &[], source_timeframe, now),
982 )
983}
984
985fn empty_profile_with_status(start: UnixMs, end: UnixMs, status: TpoProfileStatus) -> TpoProfile {
986 TpoProfile {
987 start,
988 end,
989 period_count: 0,
990 rows: Vec::new(),
991 session_high: None,
992 session_low: None,
993 initial_balance_high: None,
994 initial_balance_low: None,
995 poc: None,
996 vah: None,
997 val: None,
998 single_prints: Vec::new(),
999 poor_highs: Vec::new(),
1000 poor_lows: Vec::new(),
1001 status,
1002 }
1003}
1004
1005fn profile_coverage(
1006 start: UnixMs,
1007 end: UnixMs,
1008 bars: &[Kline],
1009 source_timeframe: Timeframe,
1010 now: UnixMs,
1011) -> TpoProfileStatus {
1012 let expected_end = end.min(now.floor_to(source_timeframe)).max(start);
1013 let mut expected = start;
1014 let mut times = BTreeSet::new();
1015 times.extend(bars.iter().map(|bar| bar.time));
1016 while expected < expected_end {
1017 if !times.contains(&expected) {
1018 return TpoProfileStatus::Incomplete;
1019 }
1020 let next = expected.offset_by_timeframe(source_timeframe, 1);
1021 if next <= expected {
1022 break;
1023 }
1024 expected = next;
1025 }
1026 if end <= now {
1027 TpoProfileStatus::Complete
1028 } else {
1029 TpoProfileStatus::Developing
1030 }
1031}
1032
1033fn repeated_extremes(rows: &[TpoRow], highs: bool) -> Vec<Price> {
1034 let extreme = if highs {
1035 rows.iter().rev().find(|row| !row.periods.is_empty())
1036 } else {
1037 rows.iter().find(|row| !row.periods.is_empty())
1038 };
1039 extreme
1040 .filter(|row| row.periods.len() >= 2)
1041 .map(|row| vec![row.price])
1042 .unwrap_or_default()
1043}
1044
1045fn value_area_indexes(
1046 rows: &[TpoRow],
1047 value_area_percent: u8,
1048) -> (Option<usize>, Option<usize>, Option<usize>) {
1049 let Some(poc) = rows
1050 .iter()
1051 .enumerate()
1052 .filter(|(_, row)| row.count() > 0)
1053 .max_by(|(left_index, left), (right_index, right)| {
1054 left.count()
1055 .cmp(&right.count())
1056 .then_with(|| left_index.cmp(right_index))
1057 })
1058 .map(|(index, _)| index)
1059 else {
1060 return (None, None, None);
1061 };
1062
1063 let target = (rows.iter().map(TpoRow::count).sum::<usize>() * usize::from(value_area_percent))
1064 .div_ceil(100);
1065 let mut low = poc;
1066 let mut high = poc;
1067 let mut covered = rows[poc].count();
1068 while covered < target {
1069 let lower = (low > 0).then(|| rows[low - 1].count());
1070 let upper = (high + 1 < rows.len()).then(|| rows[high + 1].count());
1071 match (lower, upper) {
1072 (None, None) => break,
1073 (Some(lower), None) => {
1074 low -= 1;
1075 covered += lower;
1076 }
1077 (None, Some(upper)) => {
1078 high += 1;
1079 covered += upper;
1080 }
1081 (Some(lower), Some(upper)) if lower == upper => {
1082 let lower_distance = poc - (low - 1);
1083 let upper_distance = (high + 1) - poc;
1084 if upper_distance <= lower_distance {
1085 high += 1;
1086 covered += upper;
1087 } else {
1088 low -= 1;
1089 covered += lower;
1090 }
1091 }
1092 (Some(lower), Some(upper)) if lower > upper => {
1093 low -= 1;
1094 covered += lower;
1095 }
1096 (Some(_), Some(upper)) => {
1097 high += 1;
1098 covered += upper;
1099 }
1100 }
1101 }
1102 (Some(poc), Some(high), Some(low))
1103}