Skip to main content

flowsurface_data/chart/
kline.rs

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