Skip to main content

flowsurface_data/panel/
ladder.rs

1use crate::chart::kline::KlineTrades;
2use crate::util::ok_or_default;
3use exchange::{
4    Trade,
5    unit::price::{Price, PriceStep},
6    unit::qty::Qty,
7};
8
9use serde::{Deserialize, Serialize};
10use std::{
11    collections::{BTreeMap, VecDeque},
12    time::Duration,
13};
14
15const TRADE_RETENTION_MS: u64 = 8 * 60_000;
16const CHASE_MIN_VISIBLE_OPACITY: f32 = 0.15;
17
18#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
19pub struct Config {
20    pub show_spread: bool,
21    #[serde(deserialize_with = "ok_or_default", default)]
22    pub show_chase_tracker: bool,
23    pub trade_retention: Duration,
24}
25
26impl Default for Config {
27    fn default() -> Self {
28        Self {
29            show_spread: false,
30            show_chase_tracker: true,
31            trade_retention: Duration::from_millis(TRADE_RETENTION_MS),
32        }
33    }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum Direction {
38    Up,
39    Down,
40}
41
42#[derive(Copy, Clone)]
43pub enum Side {
44    Bid,
45    Ask,
46}
47
48impl Side {
49    pub fn idx(self) -> usize {
50        match self {
51            Side::Bid => 0,
52            Side::Ask => 1,
53        }
54    }
55
56    pub fn is_bid(self) -> bool {
57        matches!(self, Side::Bid)
58    }
59}
60
61#[derive(Default)]
62pub struct GroupedDepth {
63    pub orders: BTreeMap<Price, Qty>,
64    pub chase: ChaseTracker,
65}
66
67impl GroupedDepth {
68    pub fn new() -> Self {
69        Self {
70            orders: BTreeMap::new(),
71            chase: ChaseTracker::default(),
72        }
73    }
74
75    pub fn regroup_from_raw(&mut self, levels: &BTreeMap<Price, Qty>, side: Side, step: PriceStep) {
76        self.orders.clear();
77        for (price, qty) in levels.iter() {
78            let grouped_price = price.round_to_side_step(side.is_bid(), step);
79            *self.orders.entry(grouped_price).or_default() += *qty;
80        }
81    }
82
83    pub fn best_price(&self, side: Side) -> Option<Price> {
84        match side {
85            Side::Bid => self.orders.last_key_value().map(|(p, _)| *p),
86            Side::Ask => self.orders.first_key_value().map(|(p, _)| *p),
87        }
88    }
89}
90
91#[derive(Debug)]
92pub struct TradeStore {
93    pub raw: VecDeque<Trade>,
94    pub grouped: KlineTrades,
95}
96
97impl Default for TradeStore {
98    fn default() -> Self {
99        Self {
100            raw: VecDeque::new(),
101            grouped: KlineTrades::new(),
102        }
103    }
104}
105
106impl TradeStore {
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    pub fn is_empty(&self) -> bool {
112        self.raw.is_empty()
113    }
114
115    pub fn insert_trades(&mut self, buffer: &[Trade], step: PriceStep) {
116        for trade in buffer {
117            self.grouped.add_trade_to_side_bin(trade, step);
118            self.raw.push_back(*trade);
119        }
120    }
121
122    pub fn rebuild_grouped(&mut self, step: PriceStep) {
123        self.grouped.clear();
124        for trade in &self.raw {
125            self.grouped.add_trade_to_side_bin(trade, step);
126        }
127    }
128
129    pub fn trade_qty_at(&self, price: Price) -> (Qty, Qty) {
130        if let Some(g) = self.grouped.trades.get(&price) {
131            (g.buy_qty, g.sell_qty)
132        } else {
133            (Qty::default(), Qty::default())
134        }
135    }
136
137    pub fn price_range(&self) -> Option<(Price, Price)> {
138        let mut min_p: Option<Price> = None;
139        let mut max_p: Option<Price> = None;
140        for &p in self.grouped.trades.keys() {
141            min_p = Some(min_p.map_or(p, |cur| cur.min(p)));
142            max_p = Some(max_p.map_or(p, |cur| cur.max(p)));
143        }
144        match (min_p, max_p) {
145            (Some(a), Some(b)) => Some((a, b)),
146            _ => None,
147        }
148    }
149
150    /// Returns true if it removed trades and regrouped.
151    pub fn maybe_cleanup(&mut self, now_ms: u64, retention: Duration, step: PriceStep) -> bool {
152        let Some(oldest) = self.raw.front() else {
153            return false;
154        };
155
156        let retention_ms = retention.as_millis() as u64;
157        if retention_ms == 0 {
158            return false;
159        }
160
161        // ~1/10th of retention, min 5s
162        let cleanup_step_ms = (retention_ms / 10).max(5_000);
163        let threshold_ms = retention_ms + cleanup_step_ms;
164        if now_ms.saturating_sub(oldest.time) < threshold_ms {
165            return false;
166        }
167
168        let keep_from_ms = now_ms.saturating_sub(retention_ms);
169        let mut removed = 0usize;
170        while let Some(trade) = self.raw.front() {
171            if trade.time < keep_from_ms {
172                self.raw.pop_front();
173                removed += 1;
174            } else {
175                break;
176            }
177        }
178
179        if removed > 0 {
180            self.rebuild_grouped(step);
181            return true;
182        }
183        false
184    }
185}
186
187#[derive(Debug, Clone, Copy, Default)]
188enum ChaseProgress {
189    #[default]
190    Idle,
191    Chasing {
192        direction: Direction,
193        start: Price,
194        end: Price,
195        /// Number of consecutive moves in the current direction
196        consecutive: u32,
197    },
198    Fading {
199        direction: Direction,
200        start: Price,
201        end: Price,
202        /// Consecutive count at the moment fading started
203        start_consecutive: u32,
204        /// How many unchanged updates we have been fading
205        fade_steps: u32,
206    },
207}
208
209#[derive(Debug, Default)]
210pub struct ChaseTracker {
211    /// Last known best price (raw ungrouped)
212    last_best: Option<Price>,
213    state: ChaseProgress,
214    last_update_ms: Option<u64>,
215}
216
217impl ChaseTracker {
218    pub fn update(
219        &mut self,
220        current_best: Option<Price>,
221        is_bid: bool,
222        now_ms: u64,
223        max_interval: Duration,
224    ) {
225        let max_ms = max_interval.as_millis() as u64;
226        if let Some(prev) = self.last_update_ms
227            && max_ms > 0
228            && now_ms.saturating_sub(prev) > max_ms
229        {
230            self.reset();
231        }
232
233        self.last_update_ms = Some(now_ms);
234
235        let Some(current) = current_best else {
236            self.reset();
237            return;
238        };
239
240        if let Some(last) = self.last_best {
241            let direction = if is_bid {
242                Direction::Up
243            } else {
244                Direction::Down
245            };
246
247            let is_continue = match direction {
248                Direction::Up => current > last,
249                Direction::Down => current < last,
250            };
251            let is_reverse = match direction {
252                Direction::Up => current < last,
253                Direction::Down => current > last,
254            };
255            let is_unchanged = current == last;
256
257            self.state = match (&self.state, is_continue, is_reverse, is_unchanged) {
258                // Continue in same direction while already chasing: extend chase
259                (
260                    ChaseProgress::Chasing {
261                        direction: sdir,
262                        start,
263                        consecutive,
264                        ..
265                    },
266                    true,
267                    _,
268                    _,
269                ) if *sdir == direction => ChaseProgress::Chasing {
270                    direction,
271                    start: *start,
272                    end: current,
273                    consecutive: consecutive.saturating_add(1),
274                },
275                // Start or restart a chase (from idle or from fading)
276                (ChaseProgress::Idle, true, _, _) | (ChaseProgress::Fading { .. }, true, _, _) => {
277                    ChaseProgress::Chasing {
278                        direction,
279                        start: last,
280                        end: current,
281                        consecutive: 1,
282                    }
283                }
284                // Reversal while chasing -> start fading from the last chase extreme (freeze end)
285                (
286                    ChaseProgress::Chasing {
287                        direction: sdir,
288                        start,
289                        end,
290                        consecutive,
291                    },
292                    _,
293                    true,
294                    _,
295                ) if *consecutive > 0 => ChaseProgress::Fading {
296                    direction: *sdir,
297                    start: *start,
298                    end: *end, // keep the extreme reached during the chase
299                    start_consecutive: *consecutive,
300                    fade_steps: 0,
301                },
302                // Unchanged while chasing -> start fading from the last chase extreme (freeze end)
303                (
304                    ChaseProgress::Chasing {
305                        direction: sdir,
306                        start,
307                        end,
308                        consecutive,
309                    },
310                    _,
311                    _,
312                    true,
313                ) if *consecutive > 0 => ChaseProgress::Fading {
314                    direction: *sdir,
315                    start: *start,
316                    end: *end, // keep the extreme reached during the chase
317                    start_consecutive: *consecutive,
318                    fade_steps: 0,
319                },
320                // Unchanged while fading -> keep fading (decay)
321                (
322                    ChaseProgress::Fading {
323                        direction: sdir,
324                        start,
325                        end,
326                        start_consecutive,
327                        fade_steps,
328                    },
329                    _,
330                    _,
331                    true,
332                ) => ChaseProgress::Fading {
333                    direction: *sdir,
334                    start: *start,
335                    end: *end,
336                    start_consecutive: *start_consecutive,
337                    fade_steps: fade_steps.saturating_add(1),
338                },
339                // Reversal while fading -> keep fading and decay
340                (
341                    ChaseProgress::Fading {
342                        direction: sdir,
343                        start,
344                        end,
345                        start_consecutive,
346                        fade_steps,
347                    },
348                    _,
349                    true,
350                    _,
351                ) => ChaseProgress::Fading {
352                    direction: *sdir,
353                    start: *start,
354                    end: *end, // freeze
355                    start_consecutive: *start_consecutive,
356                    fade_steps: fade_steps.saturating_add(1),
357                },
358                // Unchanged when idle -> no change
359                (ChaseProgress::Idle, _, _, true) => ChaseProgress::Idle,
360                _ => self.state,
361            };
362
363            if let ChaseProgress::Fading {
364                start_consecutive,
365                fade_steps,
366                ..
367            } = self.state
368            {
369                let base = Self::consecutive_to_alpha(start_consecutive);
370                let alpha = base / (1.0 + fade_steps as f32);
371                if alpha < CHASE_MIN_VISIBLE_OPACITY {
372                    self.state = ChaseProgress::Idle;
373                }
374            }
375        }
376
377        self.last_best = Some(current);
378    }
379
380    pub fn reset(&mut self) {
381        self.last_best = None;
382        self.state = ChaseProgress::Idle;
383        self.last_update_ms = None;
384    }
385
386    /// Maps consecutive steps n to [0,1): 1 - 1/(1+n)
387    fn consecutive_to_alpha(n: u32) -> f32 {
388        let nf = n as f32;
389        1.0 - 1.0 / (1.0 + nf)
390    }
391
392    pub fn segment(&self) -> Option<(Price, Price, f32)> {
393        match self.state {
394            ChaseProgress::Chasing {
395                start,
396                end,
397                consecutive,
398                ..
399            } => Some((start, end, Self::consecutive_to_alpha(consecutive))),
400            ChaseProgress::Fading {
401                start,
402                end,
403                start_consecutive,
404                fade_steps,
405                ..
406            } => {
407                let alpha = {
408                    let base = Self::consecutive_to_alpha(start_consecutive);
409                    base / (1.0 + fade_steps as f32)
410                };
411                Some((start, end, alpha))
412            }
413            _ => None,
414        }
415    }
416}