Skip to main content

flowsurface_data/aggr/
time.rs

1use std::collections::BTreeMap;
2
3use crate::chart::Basis;
4use crate::chart::heatmap::HeatmapDataPoint;
5use crate::chart::kline::{ClusterKind, KlineDataPoint, KlineTrades, NPoc};
6
7use exchange::unit::{Price, PriceStep, Qty};
8use exchange::{Kline, Timeframe, Trade, UnixMs, Volume};
9
10pub trait DataPoint {
11    fn add_trade(&mut self, trade: &Trade, step: PriceStep);
12
13    fn clear_trades(&mut self);
14
15    fn last_trade_time(&self) -> Option<UnixMs>;
16
17    fn first_trade_time(&self) -> Option<UnixMs>;
18
19    fn last_price(&self) -> Price;
20
21    fn kline(&self) -> Option<&Kline>;
22
23    fn value_high(&self) -> Price;
24
25    fn value_low(&self) -> Price;
26}
27
28pub struct TimeSeries<D: DataPoint> {
29    pub datapoints: BTreeMap<UnixMs, D>,
30    pub interval: Timeframe,
31    pub tick_size: PriceStep,
32}
33
34impl<D: DataPoint> TimeSeries<D> {
35    pub fn base_price(&self) -> Price {
36        self.datapoints
37            .values()
38            .last()
39            .map_or(Price::from_f32(0.0), DataPoint::last_price)
40    }
41
42    pub fn latest_timestamp(&self) -> Option<UnixMs> {
43        self.datapoints.keys().last().copied()
44    }
45
46    pub fn latest_kline(&self) -> Option<&Kline> {
47        self.datapoints.values().last().and_then(|dp| dp.kline())
48    }
49
50    pub fn price_scale(&self, lookback: usize) -> (Price, Price) {
51        let mut iter = self.datapoints.iter().rev().take(lookback);
52
53        if let Some((_, first)) = iter.next() {
54            let mut high = first.value_high();
55            let mut low = first.value_low();
56
57            for (_, dp) in iter {
58                let value_high = dp.value_high();
59                let value_low = dp.value_low();
60                if value_high > high {
61                    high = value_high;
62                }
63                if value_low < low {
64                    low = value_low;
65                }
66            }
67
68            (high, low)
69        } else {
70            (Price::from_f32(0.0), Price::from_f32(0.0))
71        }
72    }
73
74    pub fn volume_data<'a>(&'a self) -> BTreeMap<UnixMs, exchange::Volume>
75    where
76        BTreeMap<UnixMs, exchange::Volume>: From<&'a TimeSeries<D>>,
77    {
78        self.into()
79    }
80
81    pub fn timerange(&self) -> (UnixMs, UnixMs) {
82        let earliest = self
83            .datapoints
84            .keys()
85            .next()
86            .copied()
87            .unwrap_or(UnixMs::ZERO);
88        let latest = self
89            .datapoints
90            .keys()
91            .last()
92            .copied()
93            .unwrap_or(UnixMs::ZERO);
94
95        (earliest, latest)
96    }
97
98    pub fn min_max_price_in_range_prices(
99        &self,
100        earliest: UnixMs,
101        latest: UnixMs,
102    ) -> Option<(Price, Price)> {
103        let mut it = self.datapoints.range(earliest..=latest);
104
105        let (_, first) = it.next()?;
106        let mut min_price = first.value_low();
107        let mut max_price = first.value_high();
108
109        for (_, dp) in it {
110            let low = dp.value_low();
111            let high = dp.value_high();
112            if low < min_price {
113                min_price = low;
114            }
115            if high > max_price {
116                max_price = high;
117            }
118        }
119
120        Some((min_price, max_price))
121    }
122
123    pub fn min_max_price_in_range(&self, earliest: UnixMs, latest: UnixMs) -> Option<(f32, f32)> {
124        self.min_max_price_in_range_prices(earliest, latest)
125            .map(|(min_p, max_p)| (min_p.to_f32_lossy(), max_p.to_f32_lossy()))
126    }
127
128    /// Ensures a datapoint bucket exists at `rounded_t` and ingests all trades into it.
129    pub fn ingest_trades_bucket(&mut self, rounded_t: UnixMs, trades: &[Trade], step: PriceStep)
130    where
131        D: Default,
132    {
133        let bucket = self.datapoints.entry(rounded_t).or_default();
134
135        for trade in trades {
136            bucket.add_trade(trade, step);
137        }
138    }
139
140    pub fn clear_trades(&mut self) {
141        for data_point in self.datapoints.values_mut() {
142            data_point.clear_trades();
143        }
144    }
145
146    fn align_down_to_phase(time: UnixMs, phase: UnixMs, interval: u64) -> UnixMs {
147        if time >= phase {
148            let t = time.as_u64();
149            let p = phase.as_u64();
150            UnixMs::new(t.saturating_sub((t - p) % interval))
151        } else {
152            phase
153        }
154    }
155
156    fn check_kline_integrity_range(
157        &self,
158        earliest: UnixMs,
159        latest: UnixMs,
160        interval: u64,
161    ) -> Option<Vec<UnixMs>> {
162        const MAX_INTEGRITY_SCAN: u64 = 1_000_000;
163        let mut time = earliest;
164        let mut missing_count = 0;
165
166        for _ in 0..MAX_INTEGRITY_SCAN {
167            if time >= latest {
168                break;
169            }
170            if !self.datapoints.contains_key(&time) {
171                missing_count += 1;
172                break;
173            }
174            time = time.saturating_add(interval);
175        }
176
177        if missing_count > 0 {
178            let mut missing_keys = Vec::new();
179            let mut time = earliest;
180
181            for _ in 0..MAX_INTEGRITY_SCAN {
182                if time >= latest {
183                    break;
184                }
185                if !self.datapoints.contains_key(&time) {
186                    missing_keys.push(time);
187                }
188                time = time.saturating_add(interval);
189            }
190
191            log::debug!(
192                "Integrity check failed: missing {} klines",
193                missing_keys.len()
194            );
195            return Some(missing_keys);
196        }
197
198        None
199    }
200
201    pub fn check_kline_integrity(&self, earliest: UnixMs, latest: UnixMs) -> Option<Vec<UnixMs>> {
202        if self.datapoints.is_empty() {
203            return None;
204        }
205
206        let interval = self.interval.to_milliseconds();
207        if interval == 0 {
208            return None;
209        }
210
211        let (series_earliest, series_latest) = self.timerange();
212        let phase = UnixMs::new(series_earliest.as_u64() % interval);
213
214        let check_earliest =
215            Self::align_down_to_phase(earliest.max(series_earliest), phase, interval)
216                .max(series_earliest);
217        let check_latest = Self::align_down_to_phase(latest.min(series_latest), phase, interval)
218            .min(series_latest);
219
220        if check_earliest < check_latest {
221            self.check_kline_integrity_range(check_earliest, check_latest, interval)
222        } else {
223            None
224        }
225    }
226}
227
228impl TimeSeries<KlineDataPoint> {
229    pub fn new(interval: Timeframe, tick_size: PriceStep, klines: &[Kline]) -> Self {
230        let mut timeseries = Self {
231            datapoints: BTreeMap::new(),
232            interval,
233            tick_size,
234        };
235
236        timeseries.insert_klines(klines);
237        timeseries
238    }
239
240    pub fn with_trades(&self, trades: &[Trade]) -> TimeSeries<KlineDataPoint> {
241        let mut new_series = Self {
242            datapoints: self.datapoints.clone(),
243            interval: self.interval,
244            tick_size: self.tick_size,
245        };
246
247        new_series.insert_trades_or_create_bucket(trades);
248        new_series
249    }
250
251    pub fn insert_klines(&mut self, klines: &[Kline]) {
252        for kline in klines {
253            let entry = self
254                .datapoints
255                .entry(kline.time)
256                .or_insert_with(|| KlineDataPoint {
257                    kline: *kline,
258                    footprint: KlineTrades::new(),
259                });
260
261            entry.kline = *kline;
262        }
263
264        self.update_poc_status();
265    }
266
267    pub fn insert_trades_or_create_bucket(&mut self, buffer: &[Trade]) {
268        if buffer.is_empty() {
269            return;
270        }
271        let mut updated_times = Vec::new();
272
273        buffer.iter().for_each(|trade| {
274            let rounded_time = trade.time.floor_to(self.interval);
275
276            if !updated_times.contains(&rounded_time) {
277                updated_times.push(rounded_time);
278            }
279
280            let entry = self
281                .datapoints
282                .entry(rounded_time)
283                .or_insert_with(|| KlineDataPoint {
284                    kline: Kline {
285                        time: rounded_time,
286                        open: trade.price,
287                        high: trade.price,
288                        low: trade.price,
289                        close: trade.price,
290                        volume: Volume::empty_buy_sell(),
291                    },
292                    footprint: KlineTrades::new(),
293                });
294
295            entry.add_trade(trade, self.tick_size);
296        });
297
298        for time in updated_times {
299            if let Some(data_point) = self.datapoints.get_mut(&time) {
300                data_point.calculate_poc();
301            }
302        }
303    }
304
305    pub fn insert_trades_existing_buckets(&mut self, buffer: &[Trade]) {
306        if buffer.is_empty() {
307            return;
308        }
309        let mut updated_times: Vec<UnixMs> = Vec::new();
310
311        for trade in buffer {
312            let rounded_time = trade.time.floor_to(self.interval);
313
314            if let Some(entry) = self.datapoints.get_mut(&rounded_time) {
315                if !updated_times.contains(&rounded_time) {
316                    updated_times.push(rounded_time);
317                }
318                entry.add_trade(trade, self.tick_size);
319            }
320        }
321
322        for time in updated_times {
323            if let Some(data_point) = self.datapoints.get_mut(&time) {
324                data_point.calculate_poc();
325            }
326        }
327    }
328
329    pub fn change_tick_size(&mut self, tick_size: PriceStep, raw_trades: &[Trade]) {
330        self.tick_size = tick_size;
331
332        self.clear_trades();
333
334        if !raw_trades.is_empty() {
335            self.insert_trades_existing_buckets(raw_trades);
336        }
337    }
338
339    pub fn update_poc_status(&mut self) {
340        let updates = self
341            .datapoints
342            .iter()
343            .filter_map(|(&time, dp)| dp.poc_price().map(|price| (time, price)))
344            .collect::<Vec<_>>();
345
346        for (current_time, poc_price) in updates {
347            let mut npoc = NPoc::default();
348
349            for (&next_time, next_dp) in self.datapoints.range(current_time.saturating_add(1)..) {
350                let next_dp_low = next_dp.kline.low.round_to_side_step(true, self.tick_size);
351                let next_dp_high = next_dp.kline.high.round_to_side_step(false, self.tick_size);
352
353                if next_dp_low <= poc_price && next_dp_high >= poc_price {
354                    npoc.filled(next_time.as_u64());
355                    break;
356                } else {
357                    npoc.unfilled();
358                }
359            }
360
361            if let Some(data_point) = self.datapoints.get_mut(&current_time) {
362                data_point.set_poc_status(npoc);
363            }
364        }
365    }
366
367    pub fn min_max_footprint_price_in_range(
368        &self,
369        earliest: UnixMs,
370        latest: UnixMs,
371    ) -> Option<(Price, Price)> {
372        if latest < earliest {
373            return None;
374        }
375
376        let mut min_price: Option<Price> = None;
377        let mut max_price: Option<Price> = None;
378
379        let mut track_price = |price: Price| {
380            min_price = Some(match min_price {
381                Some(current) => current.min(price),
382                None => price,
383            });
384            max_price = Some(match max_price {
385                Some(current) => current.max(price),
386                None => price,
387            });
388        };
389
390        self.datapoints
391            .range(earliest..=latest)
392            .for_each(|(_, dp)| {
393                track_price(dp.kline.low);
394                track_price(dp.kline.high);
395
396                for price in dp.footprint.trades.keys() {
397                    track_price(*price);
398                }
399            });
400
401        match (min_price, max_price) {
402            (Some(low), Some(high)) => Some((low, high)),
403            _ => None,
404        }
405    }
406
407    pub fn suggest_trade_fetch_range(
408        &self,
409        visible_earliest: UnixMs,
410        visible_latest: UnixMs,
411    ) -> Option<(UnixMs, UnixMs)> {
412        if self.datapoints.is_empty() {
413            return None;
414        }
415
416        self.find_trade_gap()
417            .and_then(|(last_t_before_gap, first_t_after_gap)| {
418                if last_t_before_gap.is_none() && first_t_after_gap.is_none() {
419                    return None;
420                }
421                let (data_earliest, data_latest) = self.timerange();
422
423                let gap_start = last_t_before_gap.map_or(data_earliest, |t| t.saturating_add(1));
424
425                // Round down to the nearest kline boundary so the first
426                // bucket is fully covered.
427                let fetch_from = gap_start
428                    .max(visible_earliest)
429                    .floor_to(self.interval)
430                    .max(gap_start);
431
432                // When we know where the next trade sits, fetch the entire
433                // gap in one shot instead of truncating at `visible_latest`
434                // - otherwise fast scrolling leaves a trailing gap that
435                // must be back-filled on the next scroll.  When there is no
436                // trade after the gap (`None`) we still stop at the visible
437                // edge to avoid an unbounded fetch.
438                let fetch_to = match first_t_after_gap {
439                    Some(t) => t.saturating_sub(1),
440                    None => data_latest.min(visible_latest),
441                };
442
443                if fetch_from < fetch_to {
444                    // When the gap originates before the visible window,
445                    // clamping `fetch_from` to `visible_earliest` can
446                    // collapse the range into a sub-interval sliver (e.g.
447                    // the first few seconds of a kline bucket that already
448                    // has trades).  Skip fetches that cover less than one
449                    // full interval.
450                    if gap_start < visible_earliest {
451                        let interval_ms = self.interval.to_milliseconds();
452                        if fetch_to.as_u64().saturating_sub(fetch_from.as_u64()) < interval_ms {
453                            return None;
454                        }
455                    }
456                    Some((fetch_from, fetch_to))
457                } else {
458                    None
459                }
460            })
461    }
462
463    fn find_trade_gap(&self) -> Option<(Option<UnixMs>, Option<UnixMs>)> {
464        let empty_kline_time = self
465            .datapoints
466            .iter()
467            .rev()
468            .find(|(_, dp)| dp.footprint.trades.is_empty())
469            .map(|(&time, _)| time);
470
471        if let Some(target_time) = empty_kline_time {
472            let last_t_before_gap = self
473                .datapoints
474                .range(..target_time)
475                .rev()
476                .find_map(|(_, dp)| dp.last_trade_time());
477
478            let first_t_after_gap = self
479                .datapoints
480                .range(target_time.saturating_add(1)..)
481                .find_map(|(_, dp)| dp.first_trade_time());
482
483            Some((last_t_before_gap, first_t_after_gap))
484        } else {
485            None
486        }
487    }
488
489    pub fn max_qty_ts_range(
490        &self,
491        cluster_kind: ClusterKind,
492        earliest: UnixMs,
493        latest: UnixMs,
494        highest: Price,
495        lowest: Price,
496    ) -> Qty {
497        let mut max_cluster_qty: Qty = Qty::default();
498
499        self.datapoints
500            .range(earliest..=latest)
501            .for_each(|(_, dp)| {
502                max_cluster_qty =
503                    max_cluster_qty.max(dp.max_cluster_qty(cluster_kind, highest, lowest));
504            });
505
506        max_cluster_qty
507    }
508}
509
510impl TimeSeries<HeatmapDataPoint> {
511    pub fn new(basis: Basis, tick_size: PriceStep) -> Self {
512        let timeframe = match basis {
513            Basis::Time(interval) => interval,
514            Basis::Tick(_) => unimplemented!(),
515        };
516
517        Self {
518            datapoints: BTreeMap::new(),
519            interval: timeframe,
520            tick_size,
521        }
522    }
523
524    pub fn max_trade_qty_and_aggr_volume(&self, earliest: UnixMs, latest: UnixMs) -> (Qty, Qty) {
525        let mut max_trade_qty = Qty::ZERO;
526        let mut max_aggr_volume = Qty::ZERO;
527
528        self.datapoints
529            .range(earliest..=latest)
530            .for_each(|(_, dp)| {
531                let (mut buy_volume, mut sell_volume) = (Qty::ZERO, Qty::ZERO);
532
533                dp.grouped_trades.iter().for_each(|trade| {
534                    let trade_qty = trade.qty;
535                    max_trade_qty = max_trade_qty.max(trade_qty);
536
537                    if trade.is_sell {
538                        sell_volume += trade_qty;
539                    } else {
540                        buy_volume += trade_qty;
541                    }
542                });
543
544                max_aggr_volume = max_aggr_volume.max(buy_volume + sell_volume);
545            });
546
547        (max_trade_qty, max_aggr_volume)
548    }
549
550    pub fn max_trade_qty_in_range(
551        &self,
552        earliest: UnixMs,
553        latest: UnixMs,
554        highest: Price,
555        lowest: Price,
556    ) -> Qty {
557        let mut max_trade_qty = Qty::default();
558
559        self.datapoints
560            .range(earliest..=latest)
561            .for_each(|(_, dp)| {
562                dp.grouped_trades.iter().for_each(|trade| {
563                    if trade.price >= lowest && trade.price <= highest {
564                        max_trade_qty = max_trade_qty.max(trade.qty);
565                    }
566                });
567            });
568
569        max_trade_qty
570    }
571}
572
573impl From<&TimeSeries<KlineDataPoint>> for BTreeMap<UnixMs, exchange::Volume> {
574    /// Converts datapoints into a map of timestamps and volume data
575    fn from(timeseries: &TimeSeries<KlineDataPoint>) -> Self {
576        timeseries
577            .datapoints
578            .iter()
579            .map(|(time, dp)| (*time, dp.kline.volume))
580            .collect()
581    }
582}