Skip to main content

flowsurface_data/aggr/
time.rs

1use std::collections::BTreeMap;
2
3use rustc_hash::FxHashSet;
4
5use crate::chart::Basis;
6use crate::chart::heatmap::HeatmapDataPoint;
7use crate::chart::kline::bar::KlineDataPoint;
8use crate::chart::kline::footprint::{ClusterKind, KlineTrades};
9
10use exchange::unit::{Price, PriceStep, Qty};
11use exchange::{Kline, Timeframe, Trade, UnixMs};
12
13pub trait DataPoint {
14    fn add_trade(&mut self, trade: &Trade, step: PriceStep);
15
16    fn clear_trades(&mut self);
17}
18
19pub struct TimeSeries<D: DataPoint> {
20    pub datapoints: BTreeMap<UnixMs, D>,
21    pub interval: Timeframe,
22    pub tick_size: PriceStep,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub(crate) enum OrderedChange {
27    Tail { from: UnixMs },
28    Rebuild,
29}
30
31#[derive(Debug, Clone)]
32pub(crate) struct KlineInsertReport {
33    pub inserted_timestamps: Vec<UnixMs>,
34    pub ordered_change: OrderedChange,
35}
36
37pub(crate) struct TradeInsertReport {
38    pub earliest_updated: UnixMs,
39    pub previous_poc_prices: BTreeMap<UnixMs, Option<Price>>,
40}
41
42impl<D: DataPoint> TimeSeries<D> {
43    pub fn timerange(&self) -> (UnixMs, UnixMs) {
44        let earliest = self
45            .datapoints
46            .keys()
47            .next()
48            .copied()
49            .unwrap_or(UnixMs::ZERO);
50        let latest = self
51            .datapoints
52            .keys()
53            .last()
54            .copied()
55            .unwrap_or(UnixMs::ZERO);
56
57        (earliest, latest)
58    }
59
60    /// Ensures a datapoint bucket exists at `rounded_t` and ingests all trades into it.
61    pub fn ingest_trades_bucket(&mut self, rounded_t: UnixMs, trades: &[Trade], step: PriceStep)
62    where
63        D: Default,
64    {
65        let bucket = self.datapoints.entry(rounded_t).or_default();
66
67        for trade in trades {
68            bucket.add_trade(trade, step);
69        }
70    }
71
72    pub fn clear_trades(&mut self) {
73        for data_point in self.datapoints.values_mut() {
74            data_point.clear_trades();
75        }
76    }
77
78    fn align_down_to_phase(time: UnixMs, phase: UnixMs, interval: u64) -> UnixMs {
79        if time >= phase {
80            let t = time.as_u64();
81            let p = phase.as_u64();
82            UnixMs::new(t.saturating_sub((t - p) % interval))
83        } else {
84            phase
85        }
86    }
87}
88
89impl TimeSeries<KlineDataPoint> {
90    pub fn new(interval: Timeframe, tick_size: PriceStep, klines: &[Kline]) -> Self {
91        let mut timeseries = Self {
92            datapoints: BTreeMap::new(),
93            interval,
94            tick_size,
95        };
96
97        timeseries.insert_klines(klines);
98        timeseries
99    }
100
101    pub fn insert_klines(&mut self, klines: &[Kline]) {
102        let _ = self.insert_klines_report(klines);
103    }
104
105    pub(crate) fn insert_klines_report(&mut self, klines: &[Kline]) -> Option<KlineInsertReport> {
106        if klines.is_empty() {
107            return None;
108        }
109
110        let previous_latest = self.datapoints.keys().next_back().copied();
111        let mut inserted_timestamps = Vec::new();
112        let mut changed_existing = false;
113
114        for kline in klines {
115            let entry = self.datapoints.entry(kline.time);
116            match entry {
117                std::collections::btree_map::Entry::Vacant(entry) => {
118                    entry.insert(KlineDataPoint {
119                        kline: *kline,
120                        footprint: KlineTrades::new(),
121                    });
122                    inserted_timestamps.push(kline.time);
123                }
124                std::collections::btree_map::Entry::Occupied(mut entry) => {
125                    entry.get_mut().kline = *kline;
126                    changed_existing = true;
127                }
128            }
129        }
130
131        inserted_timestamps.sort_unstable();
132
133        let ordered_change = if !changed_existing
134            && inserted_timestamps
135                .iter()
136                .all(|timestamp| previous_latest.is_none_or(|latest| *timestamp > latest))
137        {
138            OrderedChange::Tail {
139                from: inserted_timestamps
140                    .iter()
141                    .copied()
142                    .min()
143                    .unwrap_or_else(|| previous_latest.unwrap_or(UnixMs::ZERO)),
144            }
145        } else {
146            OrderedChange::Rebuild
147        };
148
149        Some(KlineInsertReport {
150            inserted_timestamps,
151            ordered_change,
152        })
153    }
154
155    pub(crate) fn missing_kline_range(
156        &self,
157        earliest: UnixMs,
158        latest: UnixMs,
159    ) -> Option<(UnixMs, UnixMs)> {
160        if self.datapoints.is_empty() {
161            return None;
162        }
163
164        let interval = self.interval.to_milliseconds();
165        if interval == 0 {
166            return None;
167        }
168
169        let (series_earliest, series_latest) = self.timerange();
170        let phase = UnixMs::new(series_earliest.as_u64() % interval);
171        let check_earliest =
172            Self::align_down_to_phase(earliest.max(series_earliest), phase, interval)
173                .max(series_earliest);
174        let check_latest = Self::align_down_to_phase(latest.min(series_latest), phase, interval)
175            .min(series_latest);
176
177        if check_earliest >= check_latest {
178            return None;
179        }
180
181        let mut first_missing = None;
182        let mut last_missing = None;
183        let mut time = check_earliest;
184        const MAX_INTEGRITY_SCAN: u64 = 1_000_000;
185
186        for _ in 0..MAX_INTEGRITY_SCAN {
187            if time >= check_latest {
188                break;
189            }
190            if !self.datapoints.contains_key(&time) {
191                first_missing.get_or_insert(time);
192                last_missing = Some(time);
193            }
194            time = time.saturating_add(interval);
195        }
196
197        first_missing.zip(last_missing)
198    }
199
200    pub(crate) fn insert_trades_existing_buckets_report(
201        &mut self,
202        buffer: &[Trade],
203    ) -> Option<TradeInsertReport> {
204        if buffer.is_empty() {
205            return None;
206        }
207        let mut updated_times = FxHashSet::default();
208
209        for run in buffer.chunk_by(|left, right| {
210            left.time.floor_to(self.interval) == right.time.floor_to(self.interval)
211        }) {
212            let rounded_time = run[0].time.floor_to(self.interval);
213            if let Some(entry) = self.datapoints.get_mut(&rounded_time) {
214                updated_times.insert(rounded_time);
215                for trade in run {
216                    entry.add_trade(trade, self.tick_size);
217                }
218            }
219        }
220
221        let earliest_updated = updated_times.iter().copied().min()?;
222        let mut previous_poc_prices = BTreeMap::new();
223        for time in updated_times {
224            if let Some(data_point) = self.datapoints.get_mut(&time) {
225                let previous = data_point.footprint.poc;
226                data_point.calculate_poc();
227                let previous_price = previous.map(|poc| poc.price);
228                if previous_price == data_point.poc_price() {
229                    if let Some(previous) = previous {
230                        data_point.set_poc_status(previous.status);
231                    }
232                } else {
233                    previous_poc_prices.insert(time, previous_price);
234                }
235            }
236        }
237        Some(TradeInsertReport {
238            earliest_updated,
239            previous_poc_prices,
240        })
241    }
242
243    pub fn backfill_new_buckets<'a>(
244        &mut self,
245        trades: impl IntoIterator<Item = &'a Trade>,
246        newborn: &[UnixMs],
247    ) {
248        if newborn.is_empty() {
249            return;
250        }
251
252        let newborn_times = newborn.iter().copied().collect::<FxHashSet<UnixMs>>();
253        let mut updated_times = FxHashSet::default();
254
255        for trade in trades {
256            let rounded_time = trade.time.floor_to(self.interval);
257            if newborn_times.contains(&rounded_time)
258                && let Some(entry) = self.datapoints.get_mut(&rounded_time)
259            {
260                updated_times.insert(rounded_time);
261                entry.add_trade(trade, self.tick_size);
262            }
263        }
264
265        for time in updated_times {
266            if let Some(data_point) = self.datapoints.get_mut(&time) {
267                data_point.calculate_poc();
268            }
269        }
270    }
271
272    pub fn change_tick_size(&mut self, tick_size: PriceStep) {
273        self.tick_size = tick_size;
274        for point in self.datapoints.values_mut() {
275            point.footprint.regroup(tick_size);
276        }
277    }
278
279    pub fn suggest_trade_fetch_range(
280        &self,
281        visible_earliest: UnixMs,
282        visible_latest: UnixMs,
283    ) -> Option<(UnixMs, UnixMs)> {
284        if self.datapoints.is_empty() {
285            return None;
286        }
287
288        self.find_trade_gap()
289            .and_then(|(last_t_before_gap, first_t_after_gap)| {
290                if last_t_before_gap.is_none() && first_t_after_gap.is_none() {
291                    return None;
292                }
293                let (data_earliest, data_latest) = self.timerange();
294
295                let gap_start = last_t_before_gap.map_or(data_earliest, |t| t.saturating_add(1));
296
297                let fetch_from = gap_start
298                    .max(visible_earliest)
299                    .floor_to(self.interval)
300                    .max(gap_start);
301
302                let fetch_to = match first_t_after_gap {
303                    Some(t) => t.saturating_sub(1),
304                    None => data_latest.min(visible_latest),
305                };
306
307                if fetch_from < fetch_to {
308                    if gap_start < visible_earliest {
309                        let interval_ms = self.interval.to_milliseconds();
310                        if fetch_to.as_u64().saturating_sub(fetch_from.as_u64()) < interval_ms {
311                            return None;
312                        }
313                    }
314                    Some((fetch_from, fetch_to))
315                } else {
316                    None
317                }
318            })
319    }
320
321    fn find_trade_gap(&self) -> Option<(Option<UnixMs>, Option<UnixMs>)> {
322        let empty_kline_time = self
323            .datapoints
324            .iter()
325            .rev()
326            .find(|(_, dp)| dp.footprint.trades.is_empty())
327            .map(|(&time, _)| time);
328
329        if let Some(target_time) = empty_kline_time {
330            let last_t_before_gap = self
331                .datapoints
332                .range(..target_time)
333                .rev()
334                .find_map(|(_, dp)| dp.last_trade_time());
335
336            let first_t_after_gap = self
337                .datapoints
338                .range(target_time.saturating_add(1)..)
339                .find_map(|(_, dp)| dp.first_trade_time());
340
341            Some((last_t_before_gap, first_t_after_gap))
342        } else {
343            None
344        }
345    }
346
347    pub fn max_qty_ts_range(
348        &self,
349        cluster_kind: ClusterKind,
350        earliest: UnixMs,
351        latest: UnixMs,
352        highest: Price,
353        lowest: Price,
354    ) -> Qty {
355        let mut max_cluster_qty: Qty = Qty::default();
356
357        self.datapoints
358            .range(earliest..=latest)
359            .for_each(|(_, dp)| {
360                max_cluster_qty =
361                    max_cluster_qty.max(dp.max_cluster_qty(cluster_kind, highest, lowest));
362            });
363
364        max_cluster_qty
365    }
366}
367
368impl TimeSeries<HeatmapDataPoint> {
369    pub fn new(basis: Basis, tick_size: PriceStep) -> Self {
370        let timeframe = match basis {
371            Basis::Time(interval) => interval,
372            Basis::Tick(_) => unimplemented!(),
373        };
374
375        Self {
376            datapoints: BTreeMap::new(),
377            interval: timeframe,
378            tick_size,
379        }
380    }
381}