Skip to main content

flowsurface_data/chart/kline/
series.rs

1use super::viewport::{BarUnitWindow, TickIndex, XAxis};
2use crate::aggr::{
3    ticks::{TickAccumulation, TickAggr},
4    time::TimeSeries,
5};
6use crate::chart::kline::IndicatorUpdateScope;
7use crate::chart::kline::bar::KlineDataPoint;
8use crate::chart::kline::footprint::{ClusterKind, KlineTrades};
9use crate::chart::kline::indicators::OpenInterestSeries;
10use crate::chart::kline::poc::{NPoc, NakedPocIndex, PocFillAt, PocLineTracker, PointOfControl};
11pub use crate::chart::kline::studies::big_trades::BigTradeEntry;
12use crate::chart::kline::studies::big_trades::BigTradesCache;
13use crate::chart::kline::studies::volume_profile::{
14    AnchoredProfileDistribution, ProfileAccumulator, ProfileDistribution, ProfileLevel,
15};
16use crate::chart::kline::{LineStrokeStyle, MAX_LINE_WIDTH, MIN_LINE_WIDTH};
17use crate::chart::{Basis, PlotData};
18use crate::config::theme::{ThemeAccent, ThemeColor, ThemeColorRole, ThemeColorShade};
19use exchange::{
20    Kline, TickerInfo, Timeframe, Trade, UnixMs,
21    unit::{Price, PriceStep, qty::Qty},
22};
23use indicator_engine::{ChartFrame, IndicatorFrame, InstrumentContext, TimeOrTick, TradeAggregate};
24use rustc_hash::FxHashMap;
25use serde::{Deserialize, Serialize};
26use std::collections::{BTreeMap, BTreeSet};
27use std::ops::Range;
28use std::sync::Arc;
29
30const NAKED_POC_LINE_END: UnixMs = UnixMs::new(u64::MAX);
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum KlineIndexChange {
34    None,
35    Prefix(usize),
36    Explicit(Vec<Option<usize>>),
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct KlineUpdate {
41    pub scope: IndicatorUpdateScope,
42    pub index_change: KlineIndexChange,
43}
44
45struct KlineIndexSnapshot {
46    count: usize,
47    pure_prefix: bool,
48    pure_tail: bool,
49    previous_times: Option<Vec<UnixMs>>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
53pub enum SeriesColor {
54    Theme(ThemeColor),
55    Custom(iced_core::Color),
56}
57
58impl SeriesColor {
59    pub const fn theme(accent: Option<ThemeAccent>, shade: ThemeColorShade) -> Self {
60        Self::Theme(ThemeColor::with_accent(
61            ThemeColorRole::Neutral,
62            accent,
63            shade,
64        ))
65    }
66
67    pub const fn with_accent(accent: ThemeAccent) -> Self {
68        Self::theme(Some(accent), ThemeColorShade::Base)
69    }
70
71    pub fn theme_color(self) -> Option<ThemeColor> {
72        match self {
73            Self::Theme(color) => Some(color),
74            Self::Custom(_) => None,
75        }
76    }
77
78    pub fn accent_value(self) -> Option<ThemeAccent> {
79        self.theme_color().and_then(|color| color.accent)
80    }
81
82    pub fn is_default(self) -> bool {
83        self == Self::default()
84    }
85}
86
87impl Default for SeriesColor {
88    fn default() -> Self {
89        Self::with_accent(ThemeAccent::Four)
90    }
91}
92
93#[derive(Debug, Clone, Copy)]
94pub struct FootprintPocLine {
95    pub start: UnixMs,
96    pub poc: PointOfControl,
97}
98
99struct RecurringProfileCache {
100    interval: Timeframe,
101    exact_profiles: BTreeMap<UnixMs, FxHashMap<Price, (Qty, Qty)>>,
102    projections: BTreeMap<PriceStep, RecurringProfileProjection>,
103}
104
105impl RecurringProfileCache {
106    fn new(interval: Timeframe) -> Self {
107        Self {
108            interval,
109            exact_profiles: BTreeMap::new(),
110            projections: BTreeMap::new(),
111        }
112    }
113
114    fn insert_trades(&mut self, trades: &[Trade]) {
115        for trade in trades {
116            let row = self
117                .exact_profiles
118                .entry(trade.time.floor_to(self.interval))
119                .or_default()
120                .entry(trade.price)
121                .or_default();
122            if trade.is_sell {
123                row.1 += trade.qty;
124            } else {
125                row.0 += trade.qty;
126            }
127        }
128        for projection in self.projections.values_mut() {
129            projection.insert_trades(trades);
130        }
131    }
132
133    fn configure_steps(&mut self, steps: &BTreeSet<PriceStep>) {
134        self.projections.retain(|step, _| steps.contains(step));
135        for &step in steps {
136            if self.projections.contains_key(&step) {
137                continue;
138            }
139            let mut projection = RecurringProfileProjection::new(self.interval, step);
140            for (&start, rows) in &self.exact_profiles {
141                let mut profile = ProfileAccumulator::new(step);
142                for (&price, &(buy, sell)) in rows {
143                    profile.add_volume(price.round_to_step(step), buy, sell);
144                }
145                projection.profiles.insert(start, profile);
146            }
147            projection.rebuild_poc_statuses();
148            self.projections.insert(step, projection);
149        }
150    }
151}
152
153struct RecurringProfileProjection {
154    interval: Timeframe,
155    tick_size: PriceStep,
156    profiles: BTreeMap<UnixMs, ProfileAccumulator>,
157    poc_lines: PocLineTracker,
158}
159
160impl RecurringProfileProjection {
161    fn new(interval: Timeframe, tick_size: PriceStep) -> Self {
162        Self {
163            interval,
164            tick_size,
165            profiles: BTreeMap::new(),
166            poc_lines: PocLineTracker::default(),
167        }
168    }
169
170    fn add_trade(&mut self, trade: &Trade) -> UnixMs {
171        let start = trade.time.floor_to(self.interval);
172        let price = trade.price.round_to_step(self.tick_size);
173        let (buy_qty, sell_qty) = if trade.is_sell {
174            (Qty::ZERO, trade.qty)
175        } else {
176            (trade.qty, Qty::ZERO)
177        };
178        self.profiles
179            .entry(start)
180            .or_insert_with(|| ProfileAccumulator::new(self.tick_size))
181            .add_volume(price, buy_qty, sell_qty);
182        start
183    }
184
185    fn insert_trades(&mut self, trades: &[Trade]) {
186        let mut affected_starts = BTreeSet::new();
187        for trade in trades {
188            affected_starts.insert(self.add_trade(trade));
189        }
190
191        if affected_starts.len() == 1
192            && self.profiles.keys().next_back().copied() == affected_starts.first().copied()
193        {
194            let start = affected_starts
195                .first()
196                .copied()
197                .expect("affected profile is present");
198            self.poc_lines.process_profile(&mut self.profiles, start);
199        } else if !affected_starts.is_empty() {
200            self.rebuild_poc_statuses();
201        }
202    }
203
204    fn rebuild_poc_statuses(&mut self) {
205        self.poc_lines.rebuild(&mut self.profiles);
206    }
207
208    fn visible_profiles(
209        &self,
210        earliest: UnixMs,
211        latest: UnixMs,
212    ) -> Vec<AnchoredProfileDistribution> {
213        if earliest > latest {
214            return Vec::new();
215        }
216
217        let visible_starts =
218            self.poc_lines
219                .visible_starts(self.interval, earliest, latest, &self.profiles);
220
221        visible_starts
222            .into_iter()
223            .filter_map(|start| {
224                self.profiles
225                    .get(&start)
226                    .map(|profile| AnchoredProfileDistribution {
227                        start,
228                        end: start.saturating_add(self.interval.to_milliseconds()),
229                        distribution: profile.distribution(),
230                    })
231            })
232            .collect()
233    }
234}
235
236#[derive(Default)]
237struct VisibleTradeProfileRow {
238    buy_qty: Qty,
239    sell_qty: Qty,
240    footprint_rows: usize,
241}
242
243struct VisibleTradeProfileCache {
244    earliest: UnixMs,
245    latest: UnixMs,
246    price_step: PriceStep,
247    rows: BTreeMap<Price, VisibleTradeProfileRow>,
248    snapshot: Option<Arc<ProfileDistribution>>,
249}
250
251impl VisibleTradeProfileCache {
252    fn new(earliest: UnixMs, latest: UnixMs, price_step: PriceStep) -> Self {
253        Self {
254            earliest,
255            latest,
256            price_step,
257            rows: BTreeMap::new(),
258            snapshot: None,
259        }
260    }
261
262    fn adjust_volume(&mut self, price: Price, buy: Qty, sell: Qty, footprint_rows: isize) {
263        if buy.is_zero() && sell.is_zero() && footprint_rows == 0 {
264            return;
265        }
266        let row = self.rows.entry(price).or_default();
267        row.buy_qty += buy;
268        row.sell_qty += sell;
269        // Count footprint entries so zero-volume rows retain full-rebuild semantics.
270        row.footprint_rows = row
271            .footprint_rows
272            .checked_add_signed(footprint_rows)
273            .expect("visible profile footprint count underflowed");
274        if row.footprint_rows == 0 {
275            assert!(row.buy_qty.is_zero() && row.sell_qty.is_zero());
276            self.rows.remove(&price);
277        }
278        self.snapshot = None;
279    }
280
281    fn apply_footprint(&mut self, footprint: &KlineTrades, direction: isize) {
282        for (&price, group) in &footprint.trades {
283            let (buy, sell) = if direction > 0 {
284                (group.buy_qty, group.sell_qty)
285            } else {
286                (Qty::ZERO - group.buy_qty, Qty::ZERO - group.sell_qty)
287            };
288            self.adjust_volume(price.floor_to_step(self.price_step), buy, sell, direction);
289        }
290    }
291
292    fn apply_range(
293        &mut self,
294        series: &TimeSeries<KlineDataPoint>,
295        earliest: UnixMs,
296        latest: UnixMs,
297        direction: isize,
298    ) {
299        if earliest <= latest {
300            for (_, point) in series.datapoints.range(earliest..=latest) {
301                self.apply_footprint(&point.footprint, direction);
302            }
303        }
304    }
305
306    fn move_range(
307        &mut self,
308        series: &TimeSeries<KlineDataPoint>,
309        earliest: UnixMs,
310        latest: UnixMs,
311    ) {
312        if earliest > self.earliest {
313            self.apply_range(
314                series,
315                self.earliest,
316                self.latest.min(earliest.saturating_sub(1)),
317                -1,
318            );
319        }
320        if latest < self.latest {
321            self.apply_range(
322                series,
323                self.earliest.max(latest.saturating_add(1)),
324                self.latest,
325                -1,
326            );
327        }
328        if earliest < self.earliest {
329            self.apply_range(
330                series,
331                earliest,
332                latest.min(self.earliest.saturating_sub(1)),
333                1,
334            );
335        }
336        if latest > self.latest {
337            self.apply_range(
338                series,
339                earliest.max(self.latest.saturating_add(1)),
340                latest,
341                1,
342            );
343        }
344        self.earliest = earliest;
345        self.latest = latest;
346    }
347
348    fn insert_trades(&mut self, series: &TimeSeries<KlineDataPoint>, trades: &[Trade]) {
349        let mut new_rows = BTreeSet::new();
350        for run in trades.chunk_by(|left, right| {
351            left.time.floor_to(series.interval) == right.time.floor_to(series.interval)
352        }) {
353            let time = run[0].time.floor_to(series.interval);
354            if time < self.earliest || time > self.latest {
355                continue;
356            }
357            let Some(point) = series.datapoints.get(&time) else {
358                continue;
359            };
360            for trade in run {
361                let price = trade.price.round_to_step(series.tick_size);
362                let new_row =
363                    !point.footprint.trades.contains_key(&price) && new_rows.insert((time, price));
364                let (buy, sell) = if trade.is_sell {
365                    (Qty::ZERO, trade.qty)
366                } else {
367                    (trade.qty, Qty::ZERO)
368                };
369                self.adjust_volume(
370                    price.floor_to_step(self.price_step),
371                    buy,
372                    sell,
373                    isize::from(new_row),
374                );
375            }
376        }
377    }
378
379    fn insert_buckets(&mut self, series: &TimeSeries<KlineDataPoint>, times: &[UnixMs]) {
380        for time in times {
381            if *time >= self.earliest && *time <= self.latest {
382                self.apply_footprint(&series.datapoints[time].footprint, 1);
383            }
384        }
385    }
386
387    fn distribution(&mut self) -> Option<Arc<ProfileDistribution>> {
388        if self.rows.is_empty() {
389            return None;
390        }
391        if self.snapshot.is_none() {
392            let mut distribution = ProfileDistribution {
393                levels: Vec::with_capacity(self.rows.len()),
394                price_step: self.price_step,
395                ..ProfileDistribution::default()
396            };
397            for (&price, row) in &self.rows {
398                let volume = row.buy_qty + row.sell_qty;
399                distribution.max_qty = distribution.max_qty.max(volume);
400                if distribution.poc.is_none_or(|poc| {
401                    volume > poc.volume || (volume == poc.volume && price > poc.price)
402                }) {
403                    distribution.poc = Some(PointOfControl {
404                        price,
405                        volume,
406                        status: NPoc::None,
407                    });
408                }
409                distribution.levels.push(ProfileLevel {
410                    price,
411                    positive_qty: row.buy_qty,
412                    negative_qty: row.sell_qty,
413                });
414            }
415            self.snapshot = Some(Arc::new(distribution));
416        }
417        self.snapshot.clone()
418    }
419}
420
421#[derive(Clone, Copy)]
422pub struct KlineSeriesPoint<'a> {
423    pub index: usize,
424    pub kline: &'a Kline,
425    pub footprint: &'a KlineTrades,
426}
427
428pub enum KlineSeriesPoints<'a> {
429    Time(std::iter::Enumerate<std::collections::btree_map::Values<'a, UnixMs, KlineDataPoint>>),
430    Tick(std::iter::Enumerate<std::slice::Iter<'a, TickAccumulation>>),
431}
432
433impl<'a> Iterator for KlineSeriesPoints<'a> {
434    type Item = KlineSeriesPoint<'a>;
435
436    fn next(&mut self) -> Option<Self::Item> {
437        match self {
438            Self::Time(iter) => iter.next().map(|(index, point)| KlineSeriesPoint {
439                index,
440                kline: &point.kline,
441                footprint: &point.footprint,
442            }),
443            Self::Tick(iter) => iter.next().map(|(index, point)| KlineSeriesPoint {
444                index,
445                kline: &point.kline,
446                footprint: &point.footprint,
447            }),
448        }
449    }
450
451    fn size_hint(&self) -> (usize, Option<usize>) {
452        match self {
453            Self::Time(iter) => iter.size_hint(),
454            Self::Tick(iter) => iter.size_hint(),
455        }
456    }
457}
458
459impl<'a> DoubleEndedIterator for KlineSeriesPoints<'a> {
460    fn next_back(&mut self) -> Option<Self::Item> {
461        match self {
462            Self::Time(iter) => iter.next_back().map(|(index, point)| KlineSeriesPoint {
463                index,
464                kline: &point.kline,
465                footprint: &point.footprint,
466            }),
467            Self::Tick(iter) => iter.next_back().map(|(index, point)| KlineSeriesPoint {
468                index,
469                kline: &point.kline,
470                footprint: &point.footprint,
471            }),
472        }
473    }
474}
475
476impl<'a> ExactSizeIterator for KlineSeriesPoints<'a> {
477    fn len(&self) -> usize {
478        match self {
479            Self::Time(iter) => iter.len(),
480            Self::Tick(iter) => iter.len(),
481        }
482    }
483}
484
485pub struct KlineSeries {
486    pub ticker_info: TickerInfo,
487    data: PlotData<KlineDataPoint>,
488    time_index: Vec<UnixMs>,
489    naked_pocs: NakedPocIndex<usize>,
490    color: SeriesColor,
491    line_width: u8,
492    stroke_style: LineStrokeStyle,
493    raw_trades: Vec<Trade>,
494    raw_trade_runs: Vec<Range<usize>>,
495    trade_sequence_id: uuid::Uuid,
496    open_interest: Option<OpenInterestSeries>,
497    trade_time_range: Option<(UnixMs, UnixMs)>,
498    recurring_profile_caches: BTreeMap<Timeframe, RecurringProfileCache>,
499    visible_profile_cache: Option<VisibleTradeProfileCache>,
500    big_trades_cache: Option<BigTradesCache>,
501}
502
503impl KlineSeries {
504    pub fn bar_unit_window(
505        &self,
506        x_axis: XAxis,
507        min_x_unit: i64,
508        max_x_unit: i64,
509    ) -> BarUnitWindow {
510        let (start_index, end_index) = match x_axis {
511            XAxis::Time { .. } => {
512                let Some((earliest, latest)) = x_axis
513                    .bucket_start(min_x_unit)
514                    .zip(x_axis.bucket_end(max_x_unit))
515                else {
516                    return BarUnitWindow::from_units(Vec::new(), 0);
517                };
518                let range = self.time_index_range(earliest, latest);
519                (range.start, range.end)
520            }
521            XAxis::Tick { .. } => {
522                let point_count = self.point_count();
523                let point_count_i64 = i64::try_from(point_count).unwrap_or(i64::MAX);
524                let start = min_x_unit
525                    .saturating_add(point_count_i64.saturating_sub(1))
526                    .max(0);
527                let end = max_x_unit
528                    .saturating_add(point_count_i64)
529                    .min(point_count_i64);
530                (
531                    usize::try_from(start)
532                        .unwrap_or(point_count)
533                        .min(point_count),
534                    usize::try_from(end).unwrap_or(point_count).min(point_count),
535                )
536            }
537        };
538
539        let values = (start_index..end_index)
540            .filter_map(|index| match x_axis {
541                XAxis::Time { .. } => self
542                    .time_at_index(index)
543                    .map(|time| x_axis.unit_from_time(time)),
544                XAxis::Tick { .. } => {
545                    let point_count = self.point_count();
546                    let from_latest = point_count.saturating_sub(1).saturating_sub(index) as u64;
547                    Some(x_axis.unit_from_tick(TickIndex::from_u64(from_latest)))
548                }
549            })
550            .collect();
551
552        BarUnitWindow::from_units(values, start_index)
553    }
554
555    pub fn bar_at_or_before_unit(&self, x_axis: XAxis, target_unit: i64) -> Option<&Kline> {
556        match x_axis {
557            XAxis::Time { .. } => {
558                let bucket_end = x_axis.bucket_end(target_unit)?;
559                self.bar_at_or_before_time(bucket_end)
560            }
561            XAxis::Tick { .. } => {
562                let point_count = self.point_count();
563                let from_latest = x_axis
564                    .tick_from_unit(target_unit)
565                    .map(|index| index.as_u64())
566                    .and_then(|index| usize::try_from(index).ok())
567                    .unwrap_or_default();
568                let index = point_count.checked_sub(from_latest.checked_add(1)?)?;
569                self.point_at(index).map(|point| point.kline)
570            }
571        }
572    }
573
574    pub fn bar_unit_at_index(&self, axis: XAxis, point_index: usize) -> Option<i64> {
575        if point_index >= self.point_count() {
576            return None;
577        }
578        match axis {
579            XAxis::Time { .. } => self
580                .time_at_index(point_index)
581                .map(|time| axis.unit_from_time(time)),
582            XAxis::Tick { .. } => Some(
583                axis.unit_from_tick(TickIndex::from_u64(
584                    self.point_count()
585                        .saturating_sub(1)
586                        .saturating_sub(point_index) as u64,
587                )),
588            ),
589        }
590    }
591
592    pub fn bar_index_at_or_before_unit(&self, axis: XAxis, target_unit: i64) -> Option<usize> {
593        match axis {
594            XAxis::Time { .. } => self.bar_index_at_or_before_time(axis.bucket_end(target_unit)?),
595            XAxis::Tick { .. } => {
596                let from_latest = axis
597                    .tick_from_unit(target_unit)
598                    .and_then(|index| usize::try_from(index.as_u64()).ok())
599                    .unwrap_or_default();
600                self.point_count().checked_sub(from_latest.checked_add(1)?)
601            }
602        }
603    }
604
605    pub fn new_with_basis(
606        ticker_info: TickerInfo,
607        color: SeriesColor,
608        basis: Basis,
609        tick_size: PriceStep,
610    ) -> Self {
611        Self {
612            ticker_info,
613            data: empty_plot_data_with_trades(basis, tick_size, &[]),
614            time_index: Vec::new(),
615            naked_pocs: NakedPocIndex::default(),
616            color,
617            line_width: MIN_LINE_WIDTH,
618            stroke_style: LineStrokeStyle::Solid,
619            raw_trades: Vec::new(),
620            raw_trade_runs: Vec::new(),
621            trade_sequence_id: uuid::Uuid::new_v4(),
622            open_interest: None,
623            trade_time_range: None,
624            recurring_profile_caches: BTreeMap::new(),
625            visible_profile_cache: None,
626            big_trades_cache: None,
627        }
628    }
629
630    pub fn color(&self) -> SeriesColor {
631        self.color
632    }
633
634    pub fn basis(&self) -> Basis {
635        self.data.basis()
636    }
637
638    pub fn tick_size(&self) -> PriceStep {
639        self.data.tick_size()
640    }
641
642    pub fn point_count(&self) -> usize {
643        self.data.len()
644    }
645
646    pub fn time_index_range(&self, earliest: UnixMs, latest: UnixMs) -> Range<usize> {
647        if earliest > latest {
648            return 0..0;
649        }
650
651        self.time_index.partition_point(|time| *time < earliest)
652            ..self.time_index.partition_point(|time| *time <= latest)
653    }
654
655    pub fn klines_in_range(&self, start: UnixMs, end: UnixMs) -> Vec<Kline> {
656        if start >= end {
657            return Vec::new();
658        }
659        let latest = end.saturating_sub(1);
660        let indices = self.time_index_range(start, latest);
661        let mut bars = Vec::with_capacity(indices.len());
662        self.for_each_point_in_range(indices, Some((start, latest)), |point| {
663            bars.push(*point.kline);
664        });
665        bars
666    }
667
668    pub fn points(&self) -> KlineSeriesPoints<'_> {
669        match &self.data {
670            PlotData::TimeBased(series) => {
671                KlineSeriesPoints::Time(series.datapoints.values().enumerate())
672            }
673            PlotData::TickBased(series) => {
674                KlineSeriesPoints::Tick(series.datapoints.iter().enumerate())
675            }
676        }
677    }
678
679    fn bar_at_or_before_time(&self, time: UnixMs) -> Option<&Kline> {
680        let PlotData::TimeBased(series) = &self.data else {
681            return None;
682        };
683        series
684            .datapoints
685            .range(..=time)
686            .next_back()
687            .map(|(_, point)| &point.kline)
688    }
689
690    fn bar_index_at_or_before_time(&self, time: UnixMs) -> Option<usize> {
691        matches!(&self.data, PlotData::TimeBased(_))
692            .then(|| {
693                self.time_index
694                    .partition_point(|candidate| *candidate <= time)
695            })
696            .and_then(|index| index.checked_sub(1))
697    }
698
699    pub fn point_at(&self, index: usize) -> Option<KlineSeriesPoint<'_>> {
700        match &self.data {
701            PlotData::TimeBased(series) => self
702                .time_index
703                .get(index)
704                .and_then(|time| series.datapoints.get(time))
705                .map(|point| KlineSeriesPoint {
706                    index,
707                    kline: &point.kline,
708                    footprint: &point.footprint,
709                }),
710            PlotData::TickBased(series) => {
711                series.datapoints.get(index).map(|point| KlineSeriesPoint {
712                    index,
713                    kline: &point.kline,
714                    footprint: &point.footprint,
715                })
716            }
717        }
718    }
719
720    pub fn for_each_point_in_range<F>(
721        &self,
722        point_range: Range<usize>,
723        time_range: Option<(UnixMs, UnixMs)>,
724        mut f: F,
725    ) where
726        F: FnMut(KlineSeriesPoint<'_>),
727    {
728        let end = point_range.end.min(self.point_count());
729        if point_range.start >= end {
730            return;
731        }
732
733        match &self.data {
734            PlotData::TimeBased(series) => {
735                let Some((start_time, end_time)) = time_range else {
736                    return;
737                };
738                for (index, (_, point)) in
739                    (point_range.start..end).zip(series.datapoints.range(start_time..=end_time))
740                {
741                    f(KlineSeriesPoint {
742                        index,
743                        kline: &point.kline,
744                        footprint: &point.footprint,
745                    });
746                }
747            }
748            PlotData::TickBased(series) => {
749                for (offset, point) in series.datapoints[point_range.start..end].iter().enumerate()
750                {
751                    f(KlineSeriesPoint {
752                        index: point_range.start + offset,
753                        kline: &point.kline,
754                        footprint: &point.footprint,
755                    });
756                }
757            }
758        }
759    }
760
761    pub fn first_point(&self) -> Option<KlineSeriesPoint<'_>> {
762        self.points().next()
763    }
764
765    pub fn latest_point(&self) -> Option<KlineSeriesPoint<'_>> {
766        self.points().next_back()
767    }
768
769    pub fn committed_points(&self) -> impl Iterator<Item = KlineSeriesPoint<'_>> {
770        self.points().take(self.point_count().saturating_sub(1))
771    }
772
773    pub fn is_empty(&self) -> bool {
774        self.data.is_empty()
775    }
776
777    pub fn max_cluster_qty_in_point_range(
778        &self,
779        cluster_kind: ClusterKind,
780        point_range: Range<usize>,
781        lowest: Price,
782        highest: Price,
783    ) -> Qty {
784        if lowest > highest {
785            return Qty::ZERO;
786        }
787
788        let point_count = self.point_count();
789        let start = point_range.start.min(point_count);
790        let end = point_range.end.min(point_count);
791        if start >= end {
792            return Qty::ZERO;
793        }
794
795        match &self.data {
796            PlotData::TimeBased(series) => {
797                let Some(first_time) = self.time_index.get(start).copied() else {
798                    return Qty::ZERO;
799                };
800                let Some(last_time) = self.time_index.get(end - 1).copied() else {
801                    return Qty::ZERO;
802                };
803                series.max_qty_ts_range(cluster_kind, first_time, last_time, highest, lowest)
804            }
805            PlotData::TickBased(series) => {
806                let reverse_start = point_count - end;
807                let reverse_end = point_count - start;
808                series.max_qty_idx_range(
809                    cluster_kind,
810                    reverse_start,
811                    reverse_end - 1,
812                    highest,
813                    lowest,
814                )
815            }
816        }
817    }
818
819    pub fn trade_sequence_id(&self) -> uuid::Uuid {
820        self.trade_sequence_id
821    }
822
823    pub fn set_aggregation_basis(&mut self, basis: Basis, preserve_raw_trades: bool) {
824        let preserve_raw_trades = preserve_raw_trades && self.basis().is_time() == basis.is_time();
825        let raw_trades = if preserve_raw_trades {
826            self.raw_trades.clone()
827        } else {
828            Vec::new()
829        };
830        self.data = empty_plot_data_with_trades(basis, self.tick_size(), &raw_trades);
831        if !preserve_raw_trades {
832            self.raw_trades.clear();
833            self.raw_trade_runs.clear();
834            self.trade_sequence_id = uuid::Uuid::new_v4();
835            self.reset_big_trades_cache();
836        }
837        self.time_index.clear();
838        self.clear_profile_caches();
839        self.trade_time_range = calculate_trade_time_range(&self.raw_trades);
840        self.open_interest = None;
841        self.rebuild_footprint_pocs();
842    }
843
844    pub fn has_directional_volume(&self, trade_data_enabled: bool) -> bool {
845        self.points().any(|point| {
846            point.kline.volume.is_directional()
847                || (trade_data_enabled && point.footprint.trade_summary().is_some())
848        })
849    }
850
851    fn indicator_chart_frame(
852        &self,
853        index: usize,
854        time: UnixMs,
855        trade_data_enabled: bool,
856    ) -> ChartFrame {
857        let point = match self.basis() {
858            Basis::Time(_) => match &self.data {
859                PlotData::TimeBased(series) => series
860                    .datapoints
861                    .get(&time)
862                    .map(|point| (&point.kline, &point.footprint)),
863                PlotData::TickBased(_) => None,
864            },
865            Basis::Tick(_) => self
866                .point_at(index)
867                .map(|point| (point.kline, point.footprint)),
868        };
869        let footprint = point.as_ref().map(|(_, footprint)| *footprint);
870        let trade = trade_data_enabled
871            .then(|| self.trade_totals(footprint))
872            .flatten();
873        let trade_buy_qty = trade.map_or(0.0, |trade| trade.buy_qty);
874        let trade_sell_qty = trade.map_or(0.0, |trade| trade.sell_qty);
875        let (buy_qty, sell_qty, directional_volume_available) = point
876            .as_ref()
877            .and_then(|(kline, _)| kline.volume.buy_sell())
878            .map(|(buy, sell)| (buy.to_f64(), sell.to_f64(), true))
879            .unwrap_or((trade_buy_qty, trade_sell_qty, trade.is_some()));
880        ChartFrame {
881            open: point.as_ref().map(|(kline, _)| kline.open.to_f64()),
882            high: point.as_ref().map(|(kline, _)| kline.high.to_f64()),
883            low: point.as_ref().map(|(kline, _)| kline.low.to_f64()),
884            close: point.as_ref().map(|(kline, _)| kline.close.to_f64()),
885            volume: point
886                .as_ref()
887                .map(|(kline, _)| kline.volume.total().to_f64()),
888            directional_volume_available,
889            buy_qty,
890            sell_qty,
891            trade,
892        }
893    }
894
895    fn indicator_frame_basis(&self) -> TimeOrTick {
896        match self.basis() {
897            Basis::Time(_) => TimeOrTick::Time,
898            Basis::Tick(_) => TimeOrTick::Tick,
899        }
900    }
901
902    fn trade_totals(&self, footprint: Option<&KlineTrades>) -> Option<TradeAggregate> {
903        let summary = footprint?.trade_summary()?;
904        Some(TradeAggregate {
905            buy_qty: summary.buy_qty.to_f64(),
906            sell_qty: summary.sell_qty.to_f64(),
907            buy_count: summary.buy_count,
908            sell_count: summary.sell_count,
909        })
910    }
911
912    pub fn indicator_frame(
913        &self,
914        index: usize,
915        time: UnixMs,
916        open_interest: Option<f64>,
917        trade_data_enabled: bool,
918    ) -> IndicatorFrame {
919        IndicatorFrame {
920            index,
921            time: time.as_u64() as i64,
922            basis: self.indicator_frame_basis(),
923            instrument: InstrumentContext {
924                min_tick: Some(self.ticker_info.min_ticksize.as_f64()),
925            },
926            chart: self.indicator_chart_frame(index, time, trade_data_enabled),
927            open_interest,
928            series_inputs: Vec::new().into(),
929        }
930    }
931
932    pub fn set_color(&mut self, color: iced_core::Color) -> bool {
933        self.set_series_color(SeriesColor::Custom(color))
934    }
935
936    pub fn set_series_color(&mut self, color: SeriesColor) -> bool {
937        if self.color == color {
938            return false;
939        }
940
941        self.color = color;
942        true
943    }
944
945    pub fn line_width(&self) -> u8 {
946        self.line_width
947    }
948
949    pub fn stroke_style(&self) -> LineStrokeStyle {
950        self.stroke_style
951    }
952
953    pub fn set_line_width(&mut self, width: u8) -> bool {
954        let width = width.clamp(MIN_LINE_WIDTH, MAX_LINE_WIDTH);
955        if self.line_width == width {
956            return false;
957        }
958        self.line_width = width;
959        true
960    }
961
962    pub fn set_stroke_style(&mut self, stroke_style: LineStrokeStyle) -> bool {
963        if self.stroke_style == stroke_style {
964            return false;
965        }
966        self.stroke_style = stroke_style;
967        true
968    }
969
970    pub fn time_at_index(&self, index: usize) -> Option<UnixMs> {
971        match self.basis() {
972            Basis::Time(_) => self.time_index.get(index).copied(),
973            Basis::Tick(_) => self.point_at(index).map(|point| point.kline.time),
974        }
975    }
976
977    fn index_snapshot(&self, klines: &[Kline]) -> KlineIndexSnapshot {
978        let count = self.point_count();
979        let first = self.first_point().map(|point| point.kline.time);
980        let last = self.latest_point().map(|point| point.kline.time);
981        let pure_prefix = first.is_some_and(|first| {
982            klines.iter().any(|kline| kline.time < first)
983                && klines
984                    .iter()
985                    .all(|kline| kline.time < first || self.contains_time(kline.time))
986        });
987        let pure_tail = last.is_some_and(|last| {
988            klines.iter().any(|kline| kline.time > last)
989                && klines
990                    .iter()
991                    .all(|kline| kline.time > last || self.contains_time(kline.time))
992        });
993        let all_existing =
994            !pure_prefix && !pure_tail && klines.iter().all(|kline| self.contains_time(kline.time));
995        let previous_times = (count > 0 && !pure_prefix && !pure_tail && !all_existing)
996            .then(|| self.points().map(|point| point.kline.time).collect());
997
998        KlineIndexSnapshot {
999            count,
1000            pure_prefix,
1001            pure_tail,
1002            previous_times,
1003        }
1004    }
1005
1006    fn index_change_since(&self, snapshot: KlineIndexSnapshot) -> KlineIndexChange {
1007        let count = self.point_count();
1008        if count <= snapshot.count || snapshot.count == 0 {
1009            return KlineIndexChange::None;
1010        }
1011
1012        let inserted = count - snapshot.count;
1013        if snapshot.pure_prefix {
1014            return KlineIndexChange::Prefix(inserted);
1015        }
1016        if snapshot.pure_tail {
1017            return KlineIndexChange::None;
1018        }
1019
1020        let Some(previous_times) = snapshot.previous_times else {
1021            return KlineIndexChange::None;
1022        };
1023        let mut previous_index = 0;
1024        let current_indices = self
1025            .points()
1026            .map(|point| point.kline.time)
1027            .map(|time| {
1028                while previous_index < previous_times.len() && previous_times[previous_index] < time
1029                {
1030                    previous_index += 1;
1031                }
1032                if previous_index < previous_times.len() && previous_times[previous_index] == time {
1033                    Some(previous_index)
1034                } else {
1035                    None
1036                }
1037            })
1038            .collect();
1039
1040        KlineIndexChange::Explicit(current_indices)
1041    }
1042
1043    fn contains_time(&self, time: UnixMs) -> bool {
1044        match &self.data {
1045            PlotData::TimeBased(series) => series.datapoints.contains_key(&time),
1046            PlotData::TickBased(_) => false,
1047        }
1048    }
1049
1050    pub fn insert_klines(&mut self, klines: &[Kline], basis: Basis) -> Option<KlineUpdate> {
1051        if klines.is_empty() {
1052            return None;
1053        }
1054        if self.basis() != basis {
1055            self.set_aggregation_basis(basis, false);
1056        }
1057        let index_snapshot = self.index_snapshot(klines);
1058
1059        let report = match &mut self.data {
1060            PlotData::TimeBased(series) => series.insert_klines_report(klines),
1061            PlotData::TickBased(_) => {
1062                return Some(KlineUpdate {
1063                    scope: IndicatorUpdateScope::Full,
1064                    index_change: KlineIndexChange::None,
1065                });
1066            }
1067        }?;
1068
1069        self.insert_time_index_entries(&report.inserted_timestamps);
1070
1071        self.backfill_new_buckets(&report.inserted_timestamps);
1072
1073        let scope = match report.ordered_change {
1074            crate::aggr::time::OrderedChange::Tail { from } => {
1075                let start = self.time_index(from).unwrap_or(0);
1076                self.update_footprint_pocs(start..self.point_count());
1077                IndicatorUpdateScope::From(start.saturating_sub(1))
1078            }
1079            crate::aggr::time::OrderedChange::Rebuild => {
1080                self.rebuild_footprint_pocs();
1081                IndicatorUpdateScope::Full
1082            }
1083        };
1084
1085        Some(KlineUpdate {
1086            scope,
1087            index_change: self.index_change_since(index_snapshot),
1088        })
1089    }
1090
1091    /// Missing candle intervals within an aligned, half-open time range.
1092    pub fn missing_kline_ranges(&self, start: UnixMs, end: UnixMs) -> Vec<(UnixMs, UnixMs)> {
1093        let Basis::Time(timeframe) = self.basis() else {
1094            return Vec::new();
1095        };
1096        if start >= end {
1097            return Vec::new();
1098        }
1099
1100        let indices = self.time_index_range(start, end.saturating_sub(1));
1101        let mut ranges = Vec::new();
1102        let mut cursor = start;
1103        for &time in &self.time_index[indices] {
1104            if cursor < time {
1105                ranges.push((cursor, time));
1106            }
1107            cursor = time.offset_by_timeframe(timeframe, 1).min(end);
1108        }
1109        if cursor < end {
1110            ranges.push((cursor, end));
1111        }
1112        ranges
1113    }
1114
1115    pub fn missing_kline_range(
1116        &self,
1117        earliest: UnixMs,
1118        latest: UnixMs,
1119    ) -> Option<(UnixMs, UnixMs)> {
1120        match &self.data {
1121            PlotData::TimeBased(series) => series.missing_kline_range(earliest, latest),
1122            PlotData::TickBased(_) => None,
1123        }
1124    }
1125
1126    pub fn update_latest_kline(&mut self, kline: &Kline, basis: Basis) -> Option<KlineUpdate> {
1127        if self.basis() != basis {
1128            self.set_aggregation_basis(basis, false);
1129        }
1130        let index_snapshot = self.index_snapshot(std::slice::from_ref(kline));
1131        let previous_kline = self.latest_point().map(|point| *point.kline);
1132        let previous_latest = previous_kline.map(|kline| kline.time);
1133        let previous_len = self.point_count();
1134        let mut inserted_new_bucket = false;
1135
1136        let scope = match (&mut self.data, previous_latest) {
1137            (PlotData::TimeBased(series), None) => {
1138                let report = series.insert_klines_report(std::slice::from_ref(kline));
1139                inserted_new_bucket = report
1140                    .as_ref()
1141                    .is_some_and(|report| report.inserted_timestamps.contains(&kline.time));
1142                Some(IndicatorUpdateScope::From(0))
1143            }
1144            (PlotData::TimeBased(series), Some(latest)) if kline.time >= latest => {
1145                let report = series.insert_klines_report(std::slice::from_ref(kline));
1146                inserted_new_bucket = report
1147                    .as_ref()
1148                    .is_some_and(|report| report.inserted_timestamps.contains(&kline.time));
1149                Some(IndicatorUpdateScope::From(if kline.time > latest {
1150                    previous_len
1151                } else {
1152                    previous_len.saturating_sub(1)
1153                }))
1154            }
1155            (PlotData::TimeBased(series), Some(_)) => {
1156                let report = series.insert_klines_report(std::slice::from_ref(kline));
1157                inserted_new_bucket = report
1158                    .as_ref()
1159                    .is_some_and(|report| report.inserted_timestamps.contains(&kline.time));
1160                Some(IndicatorUpdateScope::Full)
1161            }
1162            (PlotData::TickBased(_), _) => None,
1163        }?;
1164
1165        if inserted_new_bucket {
1166            self.insert_time_index_entries(std::slice::from_ref(&kline.time));
1167        }
1168
1169        if inserted_new_bucket {
1170            self.backfill_new_buckets(std::slice::from_ref(&kline.time));
1171        }
1172
1173        match previous_kline {
1174            Some(previous) if kline.time < previous.time => self.rebuild_footprint_pocs(),
1175            Some(previous) if kline.time == previous.time => {
1176                let step = self.tick_size();
1177                let previous_low = previous.low.round_to_step(step);
1178                let previous_high = previous.high.round_to_step(step);
1179                let low = kline.low.round_to_step(step);
1180                let high = kline.high.round_to_step(step);
1181                if low > previous_low || high < previous_high {
1182                    self.rebuild_footprint_pocs();
1183                } else if (low, high) != (previous_low, previous_high) {
1184                    self.update_footprint_pocs(previous_len.saturating_sub(1)..self.point_count());
1185                }
1186            }
1187            _ => self.update_footprint_pocs(previous_len..self.point_count()),
1188        }
1189
1190        Some(KlineUpdate {
1191            scope,
1192            index_change: self.index_change_since(index_snapshot),
1193        })
1194    }
1195
1196    pub fn indicator_source_index_for_visual(&self, visual_index: usize) -> Option<usize> {
1197        (visual_index < self.point_count()).then_some(visual_index)
1198    }
1199
1200    pub fn insert_tick_trades(&mut self, trades: &[Trade]) -> IndicatorUpdateScope {
1201        if !matches!(self.data, PlotData::TickBased(_)) {
1202            return IndicatorUpdateScope::From(self.point_count());
1203        }
1204        if trades.is_empty() {
1205            return IndicatorUpdateScope::From(self.point_count());
1206        }
1207        merge_trade_time_range(&mut self.trade_time_range, trades);
1208        self.append_raw_trades(trades);
1209        self.fold_big_trades_cache();
1210        self.recurring_profile_caches.clear();
1211        let PlotData::TickBased(tick_aggr) = &mut self.data else {
1212            unreachable!("tick basis checked above");
1213        };
1214        let first_updated = tick_aggr
1215            .insert_trades(trades)
1216            .unwrap_or(self.point_count());
1217        self.update_footprint_pocs(first_updated..self.point_count());
1218        IndicatorUpdateScope::From(first_updated)
1219    }
1220
1221    pub fn insert_time_trades(&mut self, trades: &[Trade]) -> IndicatorUpdateScope {
1222        if !matches!(self.data, PlotData::TimeBased(_)) {
1223            return IndicatorUpdateScope::From(self.point_count());
1224        }
1225        if trades.is_empty() {
1226            return IndicatorUpdateScope::From(self.point_count());
1227        }
1228        merge_trade_time_range(&mut self.trade_time_range, trades);
1229        self.append_raw_trades(trades);
1230        self.fold_big_trades_cache();
1231        let PlotData::TimeBased(series) = &mut self.data else {
1232            unreachable!("time basis checked above");
1233        };
1234        if let Some(cache) = &mut self.visible_profile_cache {
1235            cache.insert_trades(series, trades);
1236        }
1237        let report = series.insert_trades_existing_buckets_report(trades);
1238        if let Some(report) = &report {
1239            self.update_changed_footprint_pocs(&report.previous_poc_prices);
1240        }
1241        self.update_recurring_profile_caches(trades);
1242        IndicatorUpdateScope::From(
1243            report
1244                .map(|report| self.first_bar_at_or_after(report.earliest_updated))
1245                .unwrap_or_else(|| self.point_count()),
1246        )
1247    }
1248
1249    pub fn set_tick_size(&mut self, tick_size: PriceStep) {
1250        if self.tick_size() == tick_size {
1251            return;
1252        }
1253        match &mut self.data {
1254            PlotData::TimeBased(series) => series.change_tick_size(tick_size),
1255            PlotData::TickBased(series) => series.change_tick_size(tick_size),
1256        }
1257        self.rebuild_footprint_pocs();
1258        self.visible_profile_cache = None;
1259    }
1260
1261    pub fn configure_recurring_profile_intervals(&mut self, intervals: &[(Timeframe, PriceStep)]) {
1262        let mut desired = BTreeMap::<Timeframe, BTreeSet<PriceStep>>::new();
1263        for &(interval, step) in intervals {
1264            desired.entry(interval).or_default().insert(step);
1265        }
1266        self.recurring_profile_caches
1267            .retain(|interval, _| desired.contains_key(interval));
1268        for (interval, steps) in desired {
1269            if !self.recurring_profile_caches.contains_key(&interval) {
1270                let cache = self.build_recurring_profile_cache(interval);
1271                self.recurring_profile_caches.insert(interval, cache);
1272            }
1273            self.recurring_profile_caches
1274                .get_mut(&interval)
1275                .expect("recurring source initialized")
1276                .configure_steps(&steps);
1277        }
1278    }
1279
1280    pub fn footprint_poc_lines(&self, earliest: UnixMs, latest: UnixMs) -> Vec<FootprintPocLine> {
1281        if earliest > latest {
1282            return Vec::new();
1283        }
1284        let tick_basis = matches!(self.basis(), Basis::Tick(_));
1285        self.points()
1286            .filter_map(|point| {
1287                let start = if tick_basis {
1288                    UnixMs::new(point.index as u64)
1289                } else {
1290                    point.kline.time
1291                };
1292                let poc = point.footprint.poc?;
1293                let end = match poc.status {
1294                    NPoc::None | NPoc::Naked => NAKED_POC_LINE_END,
1295                    NPoc::Filled {
1296                        at: PocFillAt::Time(time),
1297                    } => time,
1298                    NPoc::Filled {
1299                        at: PocFillAt::TickBar(index),
1300                    } => UnixMs::new(index as u64),
1301                };
1302                (start <= latest && end >= earliest).then_some(FootprintPocLine { start, poc })
1303            })
1304            .collect()
1305    }
1306
1307    fn footprint_mut(&mut self, index: usize) -> &mut KlineTrades {
1308        match &mut self.data {
1309            PlotData::TimeBased(series) => {
1310                &mut series
1311                    .datapoints
1312                    .get_mut(&self.time_index[index])
1313                    .expect("time index refers to an existing candle")
1314                    .footprint
1315            }
1316            PlotData::TickBased(series) => &mut series.datapoints[index].footprint,
1317        }
1318    }
1319
1320    fn register_footprint_poc(&mut self, index: usize) {
1321        let Some(poc) = self.footprint_mut(index).poc.as_mut() else {
1322            return;
1323        };
1324        if poc.status != NPoc::None {
1325            return;
1326        }
1327        poc.status = NPoc::Naked;
1328        let price = poc.price;
1329        self.naked_pocs.insert(price, index);
1330    }
1331
1332    fn update_changed_footprint_pocs(&mut self, previous_prices: &BTreeMap<UnixMs, Option<Price>>) {
1333        let Some((&first, _)) = previous_prices.first_key_value() else {
1334            return;
1335        };
1336        let start = self.time_index(first).expect("changed POC candle exists");
1337        let mut end = start;
1338        // Keep unrelated POCs out of the historical scan, including those born later.
1339        let mut unaffected = std::mem::take(&mut self.naked_pocs);
1340        for (&time, &previous_price) in previous_prices {
1341            let index = self.time_index(time).expect("changed POC candle exists");
1342            end = index + 1;
1343            if let Some(price) = previous_price {
1344                unaffected.remove(price, &index);
1345            }
1346        }
1347        self.update_footprint_pocs(start..end);
1348        unaffected.extend(std::mem::take(&mut self.naked_pocs));
1349        self.naked_pocs = unaffected;
1350    }
1351
1352    fn update_footprint_pocs(&mut self, activation_range: Range<usize>) {
1353        let count = self.point_count();
1354        if activation_range.start >= count {
1355            return;
1356        }
1357        let step = self.tick_size();
1358        if let Some(previous) = activation_range.start.checked_sub(1) {
1359            self.register_footprint_poc(previous);
1360        }
1361        for index in activation_range.start..count {
1362            let bar = *self.point_at(index).expect("POC candle exists").kline;
1363            let at = match self.basis() {
1364                Basis::Time(_) => PocFillAt::Time(bar.time),
1365                Basis::Tick(_) => PocFillAt::TickBar(index),
1366            };
1367            for touched in self
1368                .naked_pocs
1369                .take_touched(bar.low.round_to_step(step), bar.high.round_to_step(step))
1370            {
1371                self.footprint_mut(touched)
1372                    .set_poc_status(NPoc::Filled { at });
1373            }
1374            // Only a subsequent candle can touch this candle's POC.
1375            if index < activation_range.end && index + 1 < count {
1376                self.register_footprint_poc(index);
1377            }
1378            if index + 1 >= activation_range.end && self.naked_pocs.is_empty() {
1379                break;
1380            }
1381        }
1382    }
1383
1384    fn rebuild_footprint_pocs(&mut self) {
1385        self.naked_pocs.clear();
1386        match &mut self.data {
1387            PlotData::TimeBased(series) => {
1388                for point in series.datapoints.values_mut() {
1389                    point.set_poc_status(NPoc::None);
1390                }
1391            }
1392            PlotData::TickBased(series) => {
1393                for point in &mut series.datapoints {
1394                    point.set_poc_status(NPoc::None);
1395                }
1396            }
1397        }
1398        self.update_footprint_pocs(0..self.point_count());
1399    }
1400
1401    fn clear_profile_caches(&mut self) {
1402        self.recurring_profile_caches.clear();
1403        self.visible_profile_cache = None;
1404    }
1405
1406    fn insert_time_index_entries(&mut self, timestamps: &[UnixMs]) {
1407        let Some(&first_timestamp) = timestamps.first() else {
1408            return;
1409        };
1410        if self
1411            .time_index
1412            .last()
1413            .is_none_or(|last| first_timestamp > *last)
1414        {
1415            self.time_index.extend_from_slice(timestamps);
1416            return;
1417        }
1418
1419        let existing = std::mem::take(&mut self.time_index);
1420        self.time_index = merge_sorted_time_index(&existing, timestamps);
1421    }
1422
1423    fn update_recurring_profile_caches(&mut self, trades: &[Trade]) {
1424        if trades.is_empty() {
1425            return;
1426        }
1427
1428        for cache in self.recurring_profile_caches.values_mut() {
1429            cache.insert_trades(trades);
1430        }
1431    }
1432
1433    fn build_recurring_profile_cache(&self, interval: Timeframe) -> RecurringProfileCache {
1434        let mut cache = RecurringProfileCache::new(interval);
1435        cache.insert_trades(&self.raw_trades);
1436        cache
1437    }
1438
1439    pub fn trade_time_range(&self) -> Option<(UnixMs, UnixMs)> {
1440        self.trade_time_range
1441    }
1442
1443    fn append_raw_trades(&mut self, trades: &[Trade]) {
1444        let start = self.raw_trades.len();
1445        self.raw_trades.extend_from_slice(trades);
1446        if matches!(self.data, PlotData::TickBased(_)) {
1447            return;
1448        }
1449        for index in start..self.raw_trades.len() {
1450            if let Some(run) = self.raw_trade_runs.last_mut()
1451                && self.raw_trades[index].time >= self.raw_trades[index - 1].time
1452            {
1453                run.end = index + 1;
1454            } else {
1455                self.raw_trade_runs.push(index..index + 1);
1456            }
1457        }
1458    }
1459
1460    fn backfill_new_buckets(&mut self, newborn: &[UnixMs]) {
1461        let Some((&earliest, &latest)) = newborn.iter().min().zip(newborn.iter().max()) else {
1462            return;
1463        };
1464        let PlotData::TimeBased(series) = &mut self.data else {
1465            return;
1466        };
1467        let interval = series.interval;
1468        let trades = self.raw_trade_runs.iter().flat_map(|run| {
1469            let trades = &self.raw_trades[run.clone()];
1470            let start = trades.partition_point(|trade| trade.time.floor_to(interval) < earliest);
1471            let end = trades.partition_point(|trade| trade.time.floor_to(interval) <= latest);
1472            trades[start..end].iter()
1473        });
1474        series.backfill_new_buckets(trades, newborn);
1475        if let Some(cache) = &mut self.visible_profile_cache {
1476            cache.insert_buckets(series, newborn);
1477        }
1478    }
1479
1480    /// Rebuilds the big-trade cache for `threshold`, or drops it when no study needs it.
1481    /// Linear in `raw_trades`, which is the cost the caller opts into off the live path.
1482    pub fn configure_big_trades_threshold(&mut self, threshold: Option<u64>) {
1483        let Some(threshold) = threshold else {
1484            self.big_trades_cache = None;
1485            return;
1486        };
1487        if self
1488            .big_trades_cache
1489            .as_ref()
1490            .is_some_and(|cache| cache.threshold() == threshold)
1491        {
1492            return;
1493        }
1494        self.big_trades_cache = Some(BigTradesCache::new(
1495            threshold,
1496            &self.raw_trades,
1497            &self.ticker_info,
1498            self.basis(),
1499        ));
1500    }
1501
1502    /// Requires a time-based series; tick-series entries follow trade arrival order.
1503    pub fn big_trades_in_time_range(&self, start: UnixMs, end: UnixMs) -> &[BigTradeEntry] {
1504        if start > end {
1505            return &[];
1506        }
1507        self.big_trades_cache
1508            .as_ref()
1509            .map_or(&[], |cache| cache.in_time_range(start, end))
1510    }
1511
1512    pub fn big_trades_in_index_range(&self, lo: usize, hi: usize) -> &[BigTradeEntry] {
1513        if lo >= hi {
1514            return &[];
1515        }
1516        self.big_trades_cache
1517            .as_ref()
1518            .map_or(&[], |cache| cache.in_index_range(lo, hi))
1519    }
1520
1521    fn fold_big_trades_cache(&mut self) {
1522        let basis = self.basis();
1523        let Self {
1524            raw_trades,
1525            ticker_info,
1526            big_trades_cache,
1527            ..
1528        } = self;
1529        if let Some(cache) = big_trades_cache {
1530            cache.fold(raw_trades, ticker_info, basis);
1531        }
1532    }
1533
1534    fn reset_big_trades_cache(&mut self) {
1535        if let Some(cache) = self.big_trades_cache.as_mut() {
1536            cache.clear();
1537        }
1538    }
1539
1540    /// Retained trades in arrival order; appending history preserves existing indices.
1541    pub fn raw_trades(&self) -> &[Trade] {
1542        &self.raw_trades
1543    }
1544
1545    pub fn suggest_trade_fetch_range(
1546        &self,
1547        visible_earliest: UnixMs,
1548        visible_latest: UnixMs,
1549    ) -> Option<(UnixMs, UnixMs)> {
1550        match &self.data {
1551            PlotData::TimeBased(series) => {
1552                series.suggest_trade_fetch_range(visible_earliest, visible_latest)
1553            }
1554            PlotData::TickBased(_) => None,
1555        }
1556    }
1557
1558    pub fn prepare_trade_price_distribution(
1559        &mut self,
1560        earliest: UnixMs,
1561        latest: UnixMs,
1562        tick_size: PriceStep,
1563    ) {
1564        if earliest > latest {
1565            self.visible_profile_cache = None;
1566            return;
1567        }
1568        if let PlotData::TimeBased(series) = &self.data {
1569            let cached = &mut self.visible_profile_cache;
1570            if cached
1571                .as_ref()
1572                .is_none_or(|cache| cache.price_step != tick_size)
1573            {
1574                let mut cache = VisibleTradeProfileCache::new(earliest, latest, tick_size);
1575                cache.apply_range(series, earliest, latest, 1);
1576                *cached = Some(cache);
1577            }
1578            let cache = cached.as_mut().expect("visible profile cache initialized");
1579            cache.move_range(series, earliest, latest);
1580            cache.distribution();
1581        }
1582    }
1583
1584    pub fn trade_price_distribution(
1585        &self,
1586        earliest: UnixMs,
1587        latest: UnixMs,
1588        tick_size: PriceStep,
1589    ) -> Option<Arc<ProfileDistribution>> {
1590        if earliest > latest {
1591            return None;
1592        }
1593        if let Some(cache) = &self.visible_profile_cache
1594            && (cache.earliest, cache.latest, cache.price_step) == (earliest, latest, tick_size)
1595            && (cache.snapshot.is_some() || cache.rows.is_empty())
1596        {
1597            return cache.snapshot.clone();
1598        }
1599
1600        self.calculate_trade_price_distribution(earliest, latest, tick_size)
1601            .map(Arc::new)
1602    }
1603
1604    fn calculate_trade_price_distribution(
1605        &self,
1606        earliest: UnixMs,
1607        latest: UnixMs,
1608        tick_size: PriceStep,
1609    ) -> Option<ProfileDistribution> {
1610        let mut profile = ProfileAccumulator::new(tick_size);
1611
1612        let tick_basis = matches!(self.basis(), Basis::Tick(_));
1613        for point in self.points() {
1614            let in_range = if tick_basis {
1615                let index = point.index as u64;
1616                index >= earliest.as_u64() && index <= latest.as_u64()
1617            } else {
1618                point.kline.time >= earliest && point.kline.time <= latest
1619            };
1620            if in_range {
1621                for (&price, group) in &point.footprint.trades {
1622                    profile.add_volume(
1623                        price.floor_to_step(tick_size),
1624                        group.buy_qty,
1625                        group.sell_qty,
1626                    );
1627                }
1628            }
1629        }
1630
1631        profile.finish()
1632    }
1633
1634    pub fn recurring_trade_price_distributions(
1635        &self,
1636        interval: Timeframe,
1637        tick_size: PriceStep,
1638        earliest: UnixMs,
1639        latest: UnixMs,
1640    ) -> Vec<AnchoredProfileDistribution> {
1641        self.recurring_profile_caches
1642            .get(&interval)
1643            .and_then(|cache| cache.projections.get(&tick_size))
1644            .map(|projection| projection.visible_profiles(earliest, latest))
1645            .unwrap_or_default()
1646    }
1647
1648    pub fn insert_open_interest(&mut self, series: &OpenInterestSeries) {
1649        self.open_interest
1650            .get_or_insert_with(OpenInterestSeries::default)
1651            .merge(series);
1652    }
1653
1654    pub fn open_interest_range(&self) -> Option<(UnixMs, UnixMs)> {
1655        self.open_interest.as_ref()?.covered_range()
1656    }
1657
1658    pub fn open_interest(&self) -> Option<&OpenInterestSeries> {
1659        self.open_interest.as_ref()
1660    }
1661
1662    fn first_bar_at_or_after(&self, time: UnixMs) -> usize {
1663        self.time_index
1664            .partition_point(|candidate| *candidate < time)
1665    }
1666
1667    fn time_index(&self, time: UnixMs) -> Option<usize> {
1668        self.time_index.binary_search(&time).ok()
1669    }
1670}
1671
1672fn merge_sorted_time_index(existing: &[UnixMs], timestamps: &[UnixMs]) -> Vec<UnixMs> {
1673    let mut merged = Vec::with_capacity(existing.len().saturating_add(timestamps.len()));
1674    let mut existing_index = 0;
1675    let mut timestamp_index = 0;
1676
1677    while existing_index < existing.len() || timestamp_index < timestamps.len() {
1678        let next = match (
1679            existing.get(existing_index),
1680            timestamps.get(timestamp_index),
1681        ) {
1682            (Some(existing), Some(timestamp)) if existing <= timestamp => {
1683                existing_index += 1;
1684                *existing
1685            }
1686            (Some(_), Some(timestamp)) => {
1687                timestamp_index += 1;
1688                *timestamp
1689            }
1690            (Some(existing), None) => {
1691                existing_index += 1;
1692                *existing
1693            }
1694            (None, Some(timestamp)) => {
1695                timestamp_index += 1;
1696                *timestamp
1697            }
1698            (None, None) => break,
1699        };
1700        if merged.last().copied() != Some(next) {
1701            merged.push(next);
1702        }
1703    }
1704
1705    merged
1706}
1707
1708fn empty_plot_data_with_trades(
1709    basis: Basis,
1710    tick_size: PriceStep,
1711    raw_trades: &[Trade],
1712) -> PlotData<KlineDataPoint> {
1713    match basis {
1714        Basis::Time(interval) => {
1715            PlotData::TimeBased(TimeSeries::<KlineDataPoint>::new(interval, tick_size, &[]))
1716        }
1717        Basis::Tick(interval) => {
1718            PlotData::TickBased(TickAggr::new(interval, tick_size, raw_trades))
1719        }
1720    }
1721}
1722
1723fn calculate_trade_time_range(trades: &[Trade]) -> Option<(UnixMs, UnixMs)> {
1724    trades
1725        .iter()
1726        .map(|trade| trade.time)
1727        .fold(None, |range, time| {
1728            Some(match range {
1729                Some((min, max)) => (min.min(time), max.max(time)),
1730                None => (time, time),
1731            })
1732        })
1733}
1734
1735fn merge_trade_time_range(range: &mut Option<(UnixMs, UnixMs)>, trades: &[Trade]) {
1736    let Some((batch_min, batch_max)) =
1737        trades
1738            .iter()
1739            .map(|trade| trade.time)
1740            .fold(None::<(UnixMs, UnixMs)>, |range, time| {
1741                Some(match range {
1742                    Some((min, max)) => (min.min(time), max.max(time)),
1743                    None => (time, time),
1744                })
1745            })
1746    else {
1747        return;
1748    };
1749    *range = Some(match *range {
1750        Some((min, max)) => (min.min(batch_min), max.max(batch_max)),
1751        None => (batch_min, batch_max),
1752    });
1753}
1754
1755pub struct KlineWindows<'a> {
1756    pub series: &'a [KlineSeries],
1757    pub windows: &'a [BarUnitWindow],
1758    pub axis: XAxis,
1759}
1760
1761impl KlineWindows<'_> {
1762    pub fn for_each_point(
1763        &self,
1764        min_x_unit: i64,
1765        max_x_unit: i64,
1766        mut f: impl FnMut(usize, usize, &KlineSeries, i64, KlineSeriesPoint<'_>),
1767    ) {
1768        for (series_index, series) in self.series.iter().enumerate() {
1769            let Some(units) = self.windows.get(series_index) else {
1770                continue;
1771            };
1772            let range = units.index_range(min_x_unit, max_x_unit);
1773            let (start, end) = (range.start, range.end);
1774            let end = end.min(series.point_count().saturating_sub(units.start_index()));
1775            if start >= end {
1776                continue;
1777            }
1778
1779            let time_range = match self.axis {
1780                XAxis::Time { .. } => self
1781                    .axis
1782                    .bucket_start(units.values()[start])
1783                    .zip(self.axis.bucket_end(units.values()[end - 1])),
1784                XAxis::Tick { .. } => None,
1785            };
1786            let global_range = units.global_range(start..end);
1787            series.for_each_point_in_range(global_range, time_range, |point| {
1788                let Some(local_index) = point.index.checked_sub(units.start_index()) else {
1789                    return;
1790                };
1791                if (start..end).contains(&local_index) {
1792                    f(
1793                        series_index,
1794                        point.index,
1795                        series,
1796                        units.values()[local_index],
1797                        point,
1798                    );
1799                }
1800            });
1801        }
1802    }
1803
1804    pub fn unit_at_index(&self, series_index: usize, point_index: usize) -> Option<i64> {
1805        let series = self.series.get(series_index)?;
1806        if point_index >= series.point_count() {
1807            return None;
1808        }
1809        self.windows
1810            .get(series_index)
1811            .and_then(|units| units.unit_at_global_index(point_index))
1812            .or_else(|| series.bar_unit_at_index(self.axis, point_index))
1813    }
1814
1815    pub fn for_each_output(
1816        &self,
1817        min: i64,
1818        max: i64,
1819        offset: i64,
1820        mut f: impl FnMut(&KlineSeries, usize, i64),
1821    ) {
1822        let Some(series) = self.series.first() else {
1823            return;
1824        };
1825        let Some(units) = self.windows.first() else {
1826            return;
1827        };
1828        let end = series
1829            .point_count()
1830            .saturating_sub(units.start_index())
1831            .min(units.values().len());
1832        for local_index in units.output_range(self.axis, min, max, offset, end) {
1833            let Some(index) = units.global_index(local_index) else {
1834                continue;
1835            };
1836            f(
1837                series,
1838                index,
1839                self.axis.output_unit(units.values()[local_index], offset),
1840            );
1841        }
1842    }
1843}