Skip to main content

flowsurface_data/chart/kline/studies/
tpo.rs

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                // Preserve requested-grid flooring when the row limit coarsens the output grid.
696                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}