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 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 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 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 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 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 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 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}