flowsurface_data/chart/
kline.rs

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