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 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 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 consecutive: u32,
197 },
198 Fading {
199 direction: Direction,
200 start: Price,
201 end: Price,
202 start_consecutive: u32,
204 fade_steps: u32,
206 },
207}
208
209#[derive(Debug, Default)]
210pub struct ChaseTracker {
211 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 (
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 (ChaseProgress::Idle, true, _, _) | (ChaseProgress::Fading { .. }, true, _, _) => {
277 ChaseProgress::Chasing {
278 direction,
279 start: last,
280 end: current,
281 consecutive: 1,
282 }
283 }
284 (
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, start_consecutive: *consecutive,
300 fade_steps: 0,
301 },
302 (
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, start_consecutive: *consecutive,
318 fade_steps: 0,
319 },
320 (
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 (
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, start_consecutive: *start_consecutive,
356 fade_steps: fade_steps.saturating_add(1),
357 },
358 (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 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}