Skip to main content

flowsurface_data/panel/
timesales.rs

1use std::{collections::VecDeque, time::Duration};
2
3use exchange::{
4    SizeUnit, TickerInfo, Trade, UnixMs,
5    unit::{Qty, qty::volume_size_unit},
6};
7use serde::{Deserialize, Serialize};
8
9use crate::util::ok_or_default;
10
11const TRADE_RETENTION_MS: u64 = 120_000;
12
13#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Config {
15    #[serde(deserialize_with = "ok_or_default", default)]
16    pub ticker_display: TickerDisplay,
17    #[serde(deserialize_with = "ok_or_default", default)]
18    pub time_format: TimeFormat,
19    pub trade_size_filter: f32,
20    #[serde(default = "default_buffer_filter")]
21    pub trade_retention: Duration,
22    #[serde(deserialize_with = "ok_or_default", default)]
23    pub stacked_bar: Option<StackedBar>,
24}
25
26impl Default for Config {
27    fn default() -> Self {
28        Config {
29            ticker_display: TickerDisplay::default(),
30            time_format: TimeFormat::default(),
31            trade_size_filter: 0.0,
32            trade_retention: Duration::from_millis(TRADE_RETENTION_MS),
33            stacked_bar: StackedBar::Compact(StackedBarRatio::default()).into(),
34        }
35    }
36}
37
38#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39pub enum TickerDisplay {
40    #[default]
41    Icon,
42    IconAndTicker,
43}
44
45#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub enum TimeFormat {
47    #[default]
48    MinutesSecondsMillis,
49    HoursMinutesSecondsMillis,
50    HoursMinutesSeconds,
51}
52
53impl TimeFormat {
54    pub const ALL: [Self; 3] = [
55        Self::MinutesSecondsMillis,
56        Self::HoursMinutesSecondsMillis,
57        Self::HoursMinutesSeconds,
58    ];
59
60    pub fn pattern(self) -> &'static str {
61        match self {
62            Self::MinutesSecondsMillis => "%M:%S.%3f",
63            Self::HoursMinutesSecondsMillis => "%H:%M:%S.%3f",
64            Self::HoursMinutesSeconds => "%H:%M:%S",
65        }
66    }
67}
68
69impl std::fmt::Display for TimeFormat {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(match self {
72            Self::MinutesSecondsMillis => "mm:ss.ms",
73            Self::HoursMinutesSecondsMillis => "hh:mm:ss.ms",
74            Self::HoursMinutesSeconds => "hh:mm:ss",
75        })
76    }
77}
78
79fn default_buffer_filter() -> Duration {
80    Duration::from_millis(TRADE_RETENTION_MS)
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Copy)]
84pub enum StackedBar {
85    Compact(StackedBarRatio),
86    Full(StackedBarRatio),
87}
88
89impl StackedBar {
90    pub fn ratio(self) -> StackedBarRatio {
91        match self {
92            StackedBar::Compact(r) | StackedBar::Full(r) => r,
93        }
94    }
95
96    pub fn with_ratio(self, r: StackedBarRatio) -> Self {
97        match self {
98            StackedBar::Compact(_) => StackedBar::Compact(r),
99            StackedBar::Full(_) => StackedBar::Full(r),
100        }
101    }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default, Copy)]
105pub enum StackedBarRatio {
106    Count,
107    #[default]
108    Volume,
109    AverageSize,
110}
111
112impl std::fmt::Display for StackedBarRatio {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            StackedBarRatio::Count => write!(f, "Count"),
116            StackedBarRatio::AverageSize => write!(f, "Average trade size"),
117            StackedBarRatio::Volume => write!(f, "Volume"),
118        }
119    }
120}
121
122impl StackedBarRatio {
123    pub const ALL: [StackedBarRatio; 3] = [
124        StackedBarRatio::Count,
125        StackedBarRatio::Volume,
126        StackedBarRatio::AverageSize,
127    ];
128}
129
130#[derive(Clone, Copy)]
131pub struct TapeTrade {
132    trade: Trade,
133    ticker_info: TickerInfo,
134    notional: Qty,
135}
136
137impl std::ops::Deref for TapeTrade {
138    type Target = Trade;
139
140    fn deref(&self) -> &Trade {
141        &self.trade
142    }
143}
144
145impl TapeTrade {
146    pub fn new(ticker_info: TickerInfo, trade: Trade, size_unit: SizeUnit) -> Self {
147        let notional = Qty::from_f64(ticker_info.market_type().qty_in_quote_value(
148            trade.qty,
149            trade.price,
150            size_unit == SizeUnit::Quote,
151        ));
152        Self {
153            trade,
154            ticker_info,
155            notional,
156        }
157    }
158
159    pub fn ticker_info(&self) -> TickerInfo {
160        self.ticker_info
161    }
162
163    pub fn quantity_text(&self, multiple_tickers: bool) -> String {
164        format!(
165            "{}{}",
166            self.quantity_prefix(multiple_tickers, volume_size_unit()),
167            crate::util::abbr_large_numbers(self.quantity(multiple_tickers).to_f64())
168        )
169    }
170
171    pub fn quantity(&self, multiple_tickers: bool) -> Qty {
172        if multiple_tickers {
173            self.notional
174        } else {
175            self.qty
176        }
177    }
178
179    pub fn passes_filter(&self, threshold: f32) -> bool {
180        self.notional.to_f32_lossy() >= threshold
181    }
182
183    pub fn quantity_prefix(&self, multiple_tickers: bool, size_unit: SizeUnit) -> &'static str {
184        if multiple_tickers
185            || size_unit == SizeUnit::Quote
186            || self.ticker_info.market_type() == exchange::adapter::MarketKind::InversePerps
187        {
188            "$"
189        } else {
190            ""
191        }
192    }
193
194    pub fn merge_into(target: &mut VecDeque<Self>, mut incoming: Vec<Self>) {
195        if incoming.is_empty() {
196            return;
197        }
198        incoming.sort_by_key(|entry| entry.time);
199        if target
200            .back()
201            .is_none_or(|last| last.time <= incoming[0].time)
202        {
203            target.extend(incoming);
204            return;
205        }
206        let merge_start = target.partition_point(|entry| entry.time <= incoming[0].time);
207        let mut previous = target.split_off(merge_start);
208        for entry in incoming {
209            while previous.front().is_some_and(|old| old.time <= entry.time) {
210                target.push_back(previous.pop_front().unwrap());
211            }
212            target.push_back(entry);
213        }
214        target.append(&mut previous);
215    }
216}
217
218pub fn prune_trades(trades: &mut VecDeque<TapeTrade>, retention: Duration, now: UnixMs) -> bool {
219    let retention_ms = retention.as_millis() as u64;
220    let prune_slack_ms = retention_ms / 10;
221    let retention_cutoff = now.saturating_sub(retention_ms);
222    let slack_cutoff = now.saturating_sub(retention_ms.saturating_add(prune_slack_ms));
223
224    if trades
225        .front()
226        .is_none_or(|oldest| oldest.time >= slack_cutoff)
227    {
228        return false;
229    }
230
231    let previous_len = trades.len();
232    while trades
233        .front()
234        .is_some_and(|trade| trade.time < retention_cutoff)
235    {
236        trades.pop_front();
237    }
238    trades.len() != previous_len
239}
240
241#[derive(Debug, Clone, Copy)]
242pub enum HistAggValues {
243    Count { buy: u64, sell: u64 },
244    Qty { buy: Qty, sell: Qty },
245}
246
247#[derive(Default)]
248pub struct HistAgg {
249    buy_count: u64,
250    sell_count: u64,
251    buy_sum: Qty,
252    sell_sum: Qty,
253}
254
255impl HistAgg {
256    fn average_qty(sum: Qty, count: u64) -> Qty {
257        if count == 0 {
258            return Qty::ZERO;
259        }
260
261        let c = count.min(i64::MAX as u64) as i64;
262        let half = c / 2;
263        let rounded = if sum.units >= 0 {
264            sum.units.saturating_add(half).div_euclid(c)
265        } else {
266            sum.units.saturating_sub(half).div_euclid(c)
267        };
268
269        Qty::from_units(rounded)
270    }
271
272    pub fn add(&mut self, is_sell: bool, qty: Qty) {
273        if is_sell {
274            self.sell_count += 1;
275            self.sell_sum = Qty::from_units(self.sell_sum.units.saturating_add(qty.units));
276        } else {
277            self.buy_count += 1;
278            self.buy_sum = Qty::from_units(self.buy_sum.units.saturating_add(qty.units));
279        }
280    }
281
282    pub fn values_for(&self, ratio_kind: StackedBarRatio) -> Option<HistAggValues> {
283        match ratio_kind {
284            StackedBarRatio::Count => {
285                let buy = self.buy_count;
286                let sell = self.sell_count;
287                let total = buy.saturating_add(sell);
288
289                if total == 0 {
290                    return None;
291                }
292
293                Some(HistAggValues::Count { buy, sell })
294            }
295            StackedBarRatio::Volume => {
296                let buy = self.buy_sum.to_f32_lossy();
297                let sell = self.sell_sum.to_f32_lossy();
298                let total = buy + sell;
299
300                if total <= 0.0 {
301                    return None;
302                }
303
304                Some(HistAggValues::Qty {
305                    buy: self.buy_sum,
306                    sell: self.sell_sum,
307                })
308            }
309            StackedBarRatio::AverageSize => {
310                let buy_avg = Self::average_qty(self.buy_sum, self.buy_count);
311                let sell_avg = Self::average_qty(self.sell_sum, self.sell_count);
312
313                let denom = buy_avg.units.saturating_add(sell_avg.units);
314                if denom <= 0 {
315                    return None;
316                }
317
318                Some(HistAggValues::Qty {
319                    buy: buy_avg,
320                    sell: sell_avg,
321                })
322            }
323        }
324    }
325}