Skip to main content

flowsurface_data/chart/
kline.rs

1use crate::{aggr::time::DataPoint, chart::indicator::KlineIndicator};
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 | ClusterKind::Table => 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    /// Max of some extracted qty across price levels within [`lowest`, `highest`].
209    pub fn max_qty_by<F>(&self, highest: Price, lowest: Price, f: F) -> Qty
210    where
211        F: Fn(&GroupedTrades) -> Qty,
212    {
213        let mut max_qty = Qty::default();
214        for (price, group) in &self.trades {
215            if *price >= lowest && *price <= highest {
216                max_qty = max_qty.max(f(group));
217            }
218        }
219        max_qty
220    }
221
222    /// Max cluster qty considering only price levels within [`lowest`, `highest`].
223    /// Used by the aggregate visible-range computation where the viewport may
224    /// show only a subset of each bar's full price range (y-pan/zoom).
225    pub fn max_cluster_qty(&self, cluster_kind: ClusterKind, highest: Price, lowest: Price) -> Qty {
226        self.max_qty_by(highest, lowest, |group| group.max_cluster_qty(cluster_kind))
227    }
228
229    /// Max cluster qty across all price levels in this bar (unfiltered).
230    /// Used for per-bar individual scaling, the full bar should contribute.
231    pub fn max_cluster_qty_all(&self, cluster_kind: ClusterKind) -> Qty {
232        self.trades
233            .values()
234            .map(|group| group.max_cluster_qty(cluster_kind))
235            .max()
236            .unwrap_or_default()
237    }
238
239    pub fn calculate_poc(&mut self) {
240        if self.trades.is_empty() {
241            return;
242        }
243
244        let mut max_volume = Qty::ZERO;
245        let mut poc_price = Price::from_f32(0.0);
246
247        for (price, group) in &self.trades {
248            let total_volume = group.total_qty();
249            if total_volume > max_volume {
250                max_volume = total_volume;
251                poc_price = *price;
252            }
253        }
254
255        self.poc = Some(PointOfControl {
256            price: poc_price,
257            volume: max_volume,
258            status: NPoc::default(),
259        });
260    }
261
262    pub fn set_poc_status(&mut self, status: NPoc) {
263        if let Some(poc) = &mut self.poc {
264            poc.status = status;
265        }
266    }
267
268    pub fn poc_price(&self) -> Option<Price> {
269        self.poc.map(|poc| poc.price)
270    }
271
272    pub fn clear(&mut self) {
273        self.trades.clear();
274        self.poc = None;
275    }
276}
277
278#[derive(Debug, Clone, Copy, Default)]
279pub struct FootprintSummary {
280    pub buy: Qty,
281    pub sell: Qty,
282    pub total: Qty,
283    pub delta: Qty,
284    pub delta_pct: f64,
285}
286
287impl FootprintSummary {
288    pub fn new(buy: Qty, sell: Qty) -> Self {
289        let total = buy + sell;
290        let delta = buy - sell;
291        let total_f = total.to_f64();
292        let delta_pct = if total_f > 0.0 {
293            (delta.to_f64() / total_f) * 100.0
294        } else {
295            0.0
296        };
297
298        Self {
299            buy,
300            sell,
301            total,
302            delta,
303            delta_pct,
304        }
305    }
306
307    pub fn from_trades(footprint: &KlineTrades) -> Option<Self> {
308        if footprint.trades.is_empty() {
309            return None;
310        }
311
312        let (buy, sell) = footprint
313            .trades
314            .values()
315            .fold((Qty::ZERO, Qty::ZERO), |(buy, sell), group| {
316                (buy + group.buy_qty, sell + group.sell_qty)
317            });
318
319        Some(Self::new(buy, sell))
320    }
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
324pub enum KlineChartKind {
325    #[default]
326    Candles,
327    Footprint {
328        clusters: ClusterKind,
329        #[serde(default)]
330        scaling: ClusterScaling,
331        studies: Vec<FootprintStudy>,
332    },
333}
334
335impl KlineChartKind {
336    pub fn allows_indicator(&self, indicator: KlineIndicator) -> bool {
337        !matches!(
338            (self, indicator),
339            (KlineChartKind::Candles, KlineIndicator::BarAnalysis)
340        )
341    }
342
343    pub fn min_scaling(&self) -> f32 {
344        match self {
345            KlineChartKind::Footprint { .. } => 0.4,
346            KlineChartKind::Candles => 0.6,
347        }
348    }
349
350    pub fn max_scaling(&self) -> f32 {
351        match self {
352            KlineChartKind::Footprint { .. } => 1.2,
353            KlineChartKind::Candles => 2.5,
354        }
355    }
356
357    pub fn max_cell_width(&self) -> f32 {
358        match self {
359            KlineChartKind::Footprint { .. } => 360.0,
360            KlineChartKind::Candles => 16.0,
361        }
362    }
363
364    pub fn min_cell_width(&self) -> f32 {
365        match self {
366            KlineChartKind::Footprint { .. } => 80.0,
367            KlineChartKind::Candles => 1.0,
368        }
369    }
370
371    pub fn max_cell_height(&self) -> f32 {
372        match self {
373            KlineChartKind::Footprint { .. } => 90.0,
374            KlineChartKind::Candles => 8.0,
375        }
376    }
377
378    pub fn min_cell_height(&self) -> f32 {
379        match self {
380            KlineChartKind::Footprint { .. } => 1.0,
381            KlineChartKind::Candles => 0.001,
382        }
383    }
384
385    pub fn default_cell_width(&self) -> f32 {
386        match self {
387            KlineChartKind::Footprint { .. } => 80.0,
388            KlineChartKind::Candles => 4.0,
389        }
390    }
391}
392
393#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
394pub enum ClusterKind {
395    #[default]
396    BidAsk,
397    VolumeProfile,
398    DeltaProfile,
399    Table,
400}
401
402impl ClusterKind {
403    pub const ALL: [ClusterKind; 4] = [
404        ClusterKind::BidAsk,
405        ClusterKind::VolumeProfile,
406        ClusterKind::DeltaProfile,
407        ClusterKind::Table,
408    ];
409
410    /// Minimum footprint cell width (in unscaled pixels) for the cluster rendering mode.
411    pub fn min_footprint_width(self) -> f32 {
412        match self {
413            ClusterKind::VolumeProfile | ClusterKind::DeltaProfile => 80.0,
414            ClusterKind::BidAsk => 120.0,
415            ClusterKind::Table => 100.0,
416        }
417    }
418}
419
420impl std::fmt::Display for ClusterKind {
421    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422        match self {
423            ClusterKind::BidAsk => write!(f, "Bid/Ask"),
424            ClusterKind::VolumeProfile => write!(f, "Volume Profile"),
425            ClusterKind::DeltaProfile => write!(f, "Delta Profile"),
426            ClusterKind::Table => write!(f, "Table"),
427        }
428    }
429}
430
431#[derive(Default, Debug, Copy, Clone, PartialEq, Deserialize, Serialize)]
432#[serde(default)]
433pub struct Config {
434    // Whether to show last value labels on top right/left when not hovering
435    // e.g. OHLC/bar change values for the main chart, or last value of an indicator series
436    pub data_labels_always_visible: bool,
437    // Whether to show the footprint per-bar summary below each candle.
438    pub show_footprint_summary: bool,
439}
440
441#[derive(Default, Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
442pub enum ClusterScaling {
443    #[default]
444    /// Scale based on the maximum quantity in the visible range.
445    VisibleRange,
446    /// Blend global VisibleRange and per-cluster Individual using a weight in [0.0, 1.0].
447    /// weight = fraction of global contribution (1.0 == all-global, 0.0 == all-individual).
448    Hybrid { weight: f32 },
449    /// Scale based only on the maximum quantity inside the datapoint (per-candle).
450    Datapoint,
451}
452
453impl ClusterScaling {
454    pub const ALL: [ClusterScaling; 3] = [
455        ClusterScaling::VisibleRange,
456        ClusterScaling::Hybrid { weight: 0.2 },
457        ClusterScaling::Datapoint,
458    ];
459
460    /// Blend the global visible-range max qty with the per-candle individual max qty
461    /// according to the scaling strategy.
462    pub fn effective_qty(self, visible_max: f64, individual_max: Qty) -> f64 {
463        match self {
464            ClusterScaling::VisibleRange => Qty::scale_or_one(visible_max),
465            ClusterScaling::Datapoint => individual_max.to_scale_or_one(),
466            ClusterScaling::Hybrid { weight } => {
467                let w = weight.clamp(0.0, 1.0) as f64;
468                Qty::scale_or_one(visible_max * w + individual_max.to_f64() * (1.0 - w))
469            }
470        }
471    }
472}
473
474impl std::fmt::Display for ClusterScaling {
475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        match self {
477            ClusterScaling::VisibleRange => write!(f, "Visible Range"),
478            ClusterScaling::Hybrid { weight } => write!(f, "Hybrid (weight: {:.2})", weight),
479            ClusterScaling::Datapoint => write!(f, "Per-candle"),
480        }
481    }
482}
483
484impl std::cmp::Eq for ClusterScaling {}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
487pub enum FootprintStudy {
488    NPoC {
489        lookback: usize,
490    },
491    Imbalance {
492        threshold: usize,
493        color_scale: Option<usize>,
494        ignore_zeros: bool,
495    },
496}
497
498impl FootprintStudy {
499    pub fn is_same_type(&self, other: &Self) -> bool {
500        matches!(
501            (self, other),
502            (FootprintStudy::NPoC { .. }, FootprintStudy::NPoC { .. })
503                | (
504                    FootprintStudy::Imbalance { .. },
505                    FootprintStudy::Imbalance { .. }
506                )
507        )
508    }
509}
510
511impl FootprintStudy {
512    pub const ALL: [FootprintStudy; 2] = [
513        FootprintStudy::NPoC { lookback: 80 },
514        FootprintStudy::Imbalance {
515            threshold: 200,
516            color_scale: Some(400),
517            ignore_zeros: true,
518        },
519    ];
520}
521
522impl std::fmt::Display for FootprintStudy {
523    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524        match self {
525            FootprintStudy::NPoC { .. } => write!(f, "Naked Point of Control"),
526            FootprintStudy::Imbalance { .. } => write!(f, "Imbalance"),
527        }
528    }
529}
530
531#[derive(Debug, Clone, Copy)]
532pub struct PointOfControl {
533    pub price: Price,
534    pub volume: Qty,
535    pub status: NPoc,
536}
537
538impl Default for PointOfControl {
539    fn default() -> Self {
540        Self {
541            price: Price::from_f32(0.0),
542            volume: Qty::ZERO,
543            status: NPoc::default(),
544        }
545    }
546}
547
548#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
549pub enum NPoc {
550    #[default]
551    None,
552    Naked,
553    Filled {
554        at: u64,
555    },
556}
557
558impl NPoc {
559    pub fn filled(&mut self, at: u64) {
560        *self = NPoc::Filled { at };
561    }
562
563    pub fn unfilled(&mut self) {
564        *self = NPoc::Naked;
565    }
566}