Skip to main content

flowsurface_data/chart/
kline.rs

1use crate::aggr::time::DataPoint;
2use exchange::{
3    Kline, Trade, UnixMs,
4    unit::price::{Price, PriceStep},
5    unit::qty::Qty,
6};
7
8use rustc_hash::FxHashMap;
9use serde::{Deserialize, Serialize};
10
11#[derive(Clone)]
12pub struct KlineDataPoint {
13    pub kline: Kline,
14    pub footprint: KlineTrades,
15}
16
17impl KlineDataPoint {
18    pub fn max_cluster_qty(&self, cluster_kind: ClusterKind, highest: Price, lowest: Price) -> Qty {
19        self.footprint
20            .max_cluster_qty(cluster_kind, highest, lowest)
21    }
22
23    pub fn add_trade(&mut self, trade: &Trade, step: PriceStep) {
24        self.footprint.add_trade_to_nearest_bin(trade, step);
25    }
26
27    pub fn poc_price(&self) -> Option<Price> {
28        self.footprint.poc_price()
29    }
30
31    pub fn set_poc_status(&mut self, status: NPoc) {
32        self.footprint.set_poc_status(status);
33    }
34
35    pub fn clear_trades(&mut self) {
36        self.footprint.clear();
37    }
38
39    pub fn calculate_poc(&mut self) {
40        self.footprint.calculate_poc();
41    }
42
43    pub fn last_trade_time(&self) -> Option<UnixMs> {
44        self.footprint.last_trade_t()
45    }
46
47    pub fn first_trade_time(&self) -> Option<UnixMs> {
48        self.footprint.first_trade_t()
49    }
50
51    pub fn volume_delta(&self) -> Qty {
52        if self.kline.volume.is_directional() {
53            self.kline.volume.delta()
54        } else if !self.footprint.trades.is_empty() {
55            self.footprint
56                .trades
57                .values()
58                .fold(Qty::ZERO, |acc, group| acc + group.delta_qty())
59        } else {
60            Qty::ZERO
61        }
62    }
63
64    /// Whether this datapoint has directional (buy vs sell) data.
65    pub fn is_directional(&self) -> bool {
66        !self.footprint.trades.is_empty() || self.kline.volume.is_directional()
67    }
68}
69
70impl DataPoint for KlineDataPoint {
71    fn add_trade(&mut self, trade: &Trade, step: PriceStep) {
72        self.add_trade(trade, step);
73    }
74
75    fn clear_trades(&mut self) {
76        self.clear_trades();
77    }
78
79    fn last_trade_time(&self) -> Option<UnixMs> {
80        self.last_trade_time()
81    }
82
83    fn first_trade_time(&self) -> Option<UnixMs> {
84        self.first_trade_time()
85    }
86
87    fn last_price(&self) -> Price {
88        self.kline.close
89    }
90
91    fn kline(&self) -> Option<&Kline> {
92        Some(&self.kline)
93    }
94
95    fn value_high(&self) -> Price {
96        self.kline.high
97    }
98
99    fn value_low(&self) -> Price {
100        self.kline.low
101    }
102}
103
104#[derive(Debug, Clone, Default)]
105pub struct GroupedTrades {
106    pub buy_qty: Qty,
107    pub sell_qty: Qty,
108    pub first_time: UnixMs,
109    pub last_time: UnixMs,
110    pub buy_count: usize,
111    pub sell_count: usize,
112}
113
114impl GroupedTrades {
115    fn new(trade: &Trade) -> Self {
116        Self {
117            buy_qty: if trade.is_sell {
118                Qty::default()
119            } else {
120                trade.qty
121            },
122            sell_qty: if trade.is_sell {
123                trade.qty
124            } else {
125                Qty::default()
126            },
127            first_time: trade.time,
128            last_time: trade.time,
129            buy_count: if trade.is_sell { 0 } else { 1 },
130            sell_count: if trade.is_sell { 1 } else { 0 },
131        }
132    }
133
134    fn add_trade(&mut self, trade: &Trade) {
135        if trade.is_sell {
136            self.sell_qty += trade.qty;
137            self.sell_count += 1;
138        } else {
139            self.buy_qty += trade.qty;
140            self.buy_count += 1;
141        }
142        self.last_time = trade.time;
143    }
144
145    pub fn total_qty(&self) -> Qty {
146        self.buy_qty + self.sell_qty
147    }
148
149    pub fn delta_qty(&self) -> Qty {
150        self.buy_qty - self.sell_qty
151    }
152
153    pub fn max_cluster_qty(&self, cluster_kind: ClusterKind) -> Qty {
154        match cluster_kind {
155            ClusterKind::BidAsk => self.buy_qty.max(self.sell_qty),
156            ClusterKind::DeltaProfile => self.buy_qty.abs_diff(self.sell_qty),
157            ClusterKind::VolumeProfile => self.total_qty(),
158        }
159    }
160}
161
162#[derive(Debug, Clone, Default)]
163pub struct KlineTrades {
164    pub trades: FxHashMap<Price, GroupedTrades>,
165    pub poc: Option<PointOfControl>,
166}
167
168impl KlineTrades {
169    pub fn new() -> Self {
170        Self {
171            trades: FxHashMap::default(),
172            poc: None,
173        }
174    }
175
176    pub fn first_trade_t(&self) -> Option<UnixMs> {
177        self.trades.values().map(|group| group.first_time).min()
178    }
179
180    pub fn last_trade_t(&self) -> Option<UnixMs> {
181        self.trades.values().map(|group| group.last_time).max()
182    }
183
184    /// Add trade to the bin at the step multiple computed with side-based rounding.
185    /// Intended for order-book ladder/quotes; Floor for sells, ceil for buys.
186    /// Introduces side bias at bin edges and should not be used for OHLC/footprint aggregation
187    pub fn add_trade_to_side_bin(&mut self, trade: &Trade, step: PriceStep) {
188        let price = trade.price.round_to_side_step(trade.is_sell, step);
189
190        self.trades
191            .entry(price)
192            .and_modify(|group| group.add_trade(trade))
193            .or_insert_with(|| GroupedTrades::new(trade));
194    }
195
196    /// Add trade to the bin at the nearest step multiple (side-agnostic).
197    /// Ties (exactly half a step) round up to the higher multiple.
198    /// Intended for footprint/OHLC trade aggregation
199    pub fn add_trade_to_nearest_bin(&mut self, trade: &Trade, step: PriceStep) {
200        let price = trade.price.round_to_step(step);
201
202        self.trades
203            .entry(price)
204            .and_modify(|group| group.add_trade(trade))
205            .or_insert_with(|| GroupedTrades::new(trade));
206    }
207
208    pub fn max_qty_by<F>(&self, highest: Price, lowest: Price, f: F) -> Qty
209    where
210        F: Fn(&GroupedTrades) -> Qty,
211    {
212        let mut max_qty = Qty::default();
213        for (price, group) in &self.trades {
214            if *price >= lowest && *price <= highest {
215                max_qty = max_qty.max(f(group));
216            }
217        }
218        max_qty
219    }
220
221    pub fn max_cluster_qty(&self, cluster_kind: ClusterKind, highest: Price, lowest: Price) -> Qty {
222        self.max_qty_by(highest, lowest, |group| group.max_cluster_qty(cluster_kind))
223    }
224
225    pub fn calculate_poc(&mut self) {
226        if self.trades.is_empty() {
227            return;
228        }
229
230        let mut max_volume = Qty::ZERO;
231        let mut poc_price = Price::from_f32(0.0);
232
233        for (price, group) in &self.trades {
234            let total_volume = group.total_qty();
235            if total_volume > max_volume {
236                max_volume = total_volume;
237                poc_price = *price;
238            }
239        }
240
241        self.poc = Some(PointOfControl {
242            price: poc_price,
243            volume: max_volume,
244            status: NPoc::default(),
245        });
246    }
247
248    pub fn set_poc_status(&mut self, status: NPoc) {
249        if let Some(poc) = &mut self.poc {
250            poc.status = status;
251        }
252    }
253
254    pub fn poc_price(&self) -> Option<Price> {
255        self.poc.map(|poc| poc.price)
256    }
257
258    pub fn clear(&mut self) {
259        self.trades.clear();
260        self.poc = None;
261    }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
265pub enum KlineChartKind {
266    #[default]
267    Candles,
268    Footprint {
269        clusters: ClusterKind,
270        #[serde(default)]
271        scaling: ClusterScaling,
272        studies: Vec<FootprintStudy>,
273    },
274}
275
276impl KlineChartKind {
277    pub fn min_scaling(&self) -> f32 {
278        match self {
279            KlineChartKind::Footprint { .. } => 0.4,
280            KlineChartKind::Candles => 0.6,
281        }
282    }
283
284    pub fn max_scaling(&self) -> f32 {
285        match self {
286            KlineChartKind::Footprint { .. } => 1.2,
287            KlineChartKind::Candles => 2.5,
288        }
289    }
290
291    pub fn max_cell_width(&self) -> f32 {
292        match self {
293            KlineChartKind::Footprint { .. } => 360.0,
294            KlineChartKind::Candles => 16.0,
295        }
296    }
297
298    pub fn min_cell_width(&self) -> f32 {
299        match self {
300            KlineChartKind::Footprint { .. } => 80.0,
301            KlineChartKind::Candles => 1.0,
302        }
303    }
304
305    pub fn max_cell_height(&self) -> f32 {
306        match self {
307            KlineChartKind::Footprint { .. } => 90.0,
308            KlineChartKind::Candles => 8.0,
309        }
310    }
311
312    pub fn min_cell_height(&self) -> f32 {
313        match self {
314            KlineChartKind::Footprint { .. } => 1.0,
315            KlineChartKind::Candles => 0.001,
316        }
317    }
318
319    pub fn default_cell_width(&self) -> f32 {
320        match self {
321            KlineChartKind::Footprint { .. } => 80.0,
322            KlineChartKind::Candles => 4.0,
323        }
324    }
325}
326
327#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
328pub enum ClusterKind {
329    #[default]
330    BidAsk,
331    VolumeProfile,
332    DeltaProfile,
333}
334
335impl ClusterKind {
336    pub const ALL: [ClusterKind; 3] = [
337        ClusterKind::BidAsk,
338        ClusterKind::VolumeProfile,
339        ClusterKind::DeltaProfile,
340    ];
341}
342
343impl std::fmt::Display for ClusterKind {
344    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345        match self {
346            ClusterKind::BidAsk => write!(f, "Bid/Ask"),
347            ClusterKind::VolumeProfile => write!(f, "Volume Profile"),
348            ClusterKind::DeltaProfile => write!(f, "Delta Profile"),
349        }
350    }
351}
352
353#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize)]
354#[serde(default)]
355pub struct Config {
356    // Whether to show last value labels on top right/left when not hovering
357    // e.g. OHLC/bar change values for the main chart, or last value of an indicator series
358    pub data_labels_always_visible: bool,
359}
360
361#[derive(Default, Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
362pub enum ClusterScaling {
363    #[default]
364    /// Scale based on the maximum quantity in the visible range.
365    VisibleRange,
366    /// Blend global VisibleRange and per-cluster Individual using a weight in [0.0, 1.0].
367    /// weight = fraction of global contribution (1.0 == all-global, 0.0 == all-individual).
368    Hybrid { weight: f32 },
369    /// Scale based only on the maximum quantity inside the datapoint (per-candle).
370    Datapoint,
371}
372
373impl ClusterScaling {
374    pub const ALL: [ClusterScaling; 3] = [
375        ClusterScaling::VisibleRange,
376        ClusterScaling::Hybrid { weight: 0.2 },
377        ClusterScaling::Datapoint,
378    ];
379}
380
381impl std::fmt::Display for ClusterScaling {
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        match self {
384            ClusterScaling::VisibleRange => write!(f, "Visible Range"),
385            ClusterScaling::Hybrid { weight } => write!(f, "Hybrid (weight: {:.2})", weight),
386            ClusterScaling::Datapoint => write!(f, "Per-candle"),
387        }
388    }
389}
390
391impl std::cmp::Eq for ClusterScaling {}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
394pub enum FootprintStudy {
395    NPoC {
396        lookback: usize,
397    },
398    Imbalance {
399        threshold: usize,
400        color_scale: Option<usize>,
401        ignore_zeros: bool,
402    },
403}
404
405impl FootprintStudy {
406    pub fn is_same_type(&self, other: &Self) -> bool {
407        matches!(
408            (self, other),
409            (FootprintStudy::NPoC { .. }, FootprintStudy::NPoC { .. })
410                | (
411                    FootprintStudy::Imbalance { .. },
412                    FootprintStudy::Imbalance { .. }
413                )
414        )
415    }
416}
417
418impl FootprintStudy {
419    pub const ALL: [FootprintStudy; 2] = [
420        FootprintStudy::NPoC { lookback: 80 },
421        FootprintStudy::Imbalance {
422            threshold: 200,
423            color_scale: Some(400),
424            ignore_zeros: true,
425        },
426    ];
427}
428
429impl std::fmt::Display for FootprintStudy {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        match self {
432            FootprintStudy::NPoC { .. } => write!(f, "Naked Point of Control"),
433            FootprintStudy::Imbalance { .. } => write!(f, "Imbalance"),
434        }
435    }
436}
437
438#[derive(Debug, Clone, Copy)]
439pub struct PointOfControl {
440    pub price: Price,
441    pub volume: Qty,
442    pub status: NPoc,
443}
444
445impl Default for PointOfControl {
446    fn default() -> Self {
447        Self {
448            price: Price::from_f32(0.0),
449            volume: Qty::ZERO,
450            status: NPoc::default(),
451        }
452    }
453}
454
455#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
456pub enum NPoc {
457    #[default]
458    None,
459    Naked,
460    Filled {
461        at: u64,
462    },
463}
464
465impl NPoc {
466    pub fn filled(&mut self, at: u64) {
467        *self = NPoc::Filled { at };
468    }
469
470    pub fn unfilled(&mut self) {
471        *self = NPoc::Naked;
472    }
473}