Skip to main content

flowsurface_data/chart/kline/
footprint.rs

1use super::poc::{NPoc, PointOfControl};
2use exchange::{
3    Trade, UnixMs,
4    unit::price::{Price, PriceStep},
5    unit::qty::Qty,
6};
7
8use rustc_hash::FxHashMap;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Default)]
12pub struct GroupedTrades {
13    pub buy_qty: Qty,
14    pub sell_qty: Qty,
15    pub first_time: UnixMs,
16    pub last_time: UnixMs,
17    pub buy_count: usize,
18    pub sell_count: usize,
19}
20
21impl GroupedTrades {
22    fn new(trade: &Trade) -> Self {
23        Self {
24            buy_qty: if trade.is_sell {
25                Qty::default()
26            } else {
27                trade.qty
28            },
29            sell_qty: if trade.is_sell {
30                trade.qty
31            } else {
32                Qty::default()
33            },
34            first_time: trade.time,
35            last_time: trade.time,
36            buy_count: if trade.is_sell { 0 } else { 1 },
37            sell_count: if trade.is_sell { 1 } else { 0 },
38        }
39    }
40
41    fn add_trade(&mut self, trade: &Trade) {
42        if trade.is_sell {
43            self.sell_qty += trade.qty;
44            self.sell_count += 1;
45        } else {
46            self.buy_qty += trade.qty;
47            self.buy_count += 1;
48        }
49        self.first_time = self.first_time.min(trade.time);
50        self.last_time = self.last_time.max(trade.time);
51    }
52
53    pub fn merge(&mut self, other: &Self) {
54        self.buy_qty += other.buy_qty;
55        self.sell_qty += other.sell_qty;
56        self.buy_count += other.buy_count;
57        self.sell_count += other.sell_count;
58        self.first_time = self.first_time.min(other.first_time);
59        self.last_time = self.last_time.max(other.last_time);
60    }
61
62    pub fn total_qty(&self) -> Qty {
63        self.buy_qty + self.sell_qty
64    }
65
66    pub fn delta_qty(&self) -> Qty {
67        self.buy_qty - self.sell_qty
68    }
69
70    pub fn max_cluster_qty(&self, cluster_kind: ClusterKind) -> Qty {
71        match cluster_kind {
72            ClusterKind::BidAsk | ClusterKind::Table => self.buy_qty.max(self.sell_qty),
73            ClusterKind::DeltaProfile => self.buy_qty.abs_diff(self.sell_qty),
74            ClusterKind::VolumeProfile => self.total_qty(),
75        }
76    }
77}
78
79#[derive(Debug, Clone, Default)]
80pub struct KlineTrades {
81    pub trades: FxHashMap<Price, GroupedTrades>,
82    exact_trades: FxHashMap<Price, GroupedTrades>,
83    pub poc: Option<PointOfControl>,
84}
85
86/// Aggregated directional trade quantities and counts for one footprint.
87///
88/// Keeping this summary alongside the grouped trades makes the footprint the
89/// single source of truth for indicator inputs as well as rendering.
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
91pub struct TradeSummary {
92    pub buy_count: u64,
93    pub sell_count: u64,
94    pub buy_qty: Qty,
95    pub sell_qty: Qty,
96}
97
98impl KlineTrades {
99    pub fn new() -> Self {
100        Self {
101            trades: FxHashMap::default(),
102            exact_trades: FxHashMap::default(),
103            poc: None,
104        }
105    }
106
107    pub fn first_trade_t(&self) -> Option<UnixMs> {
108        self.trades.values().map(|group| group.first_time).min()
109    }
110
111    pub fn last_trade_t(&self) -> Option<UnixMs> {
112        self.trades.values().map(|group| group.last_time).max()
113    }
114
115    pub fn trade_summary(&self) -> Option<TradeSummary> {
116        if self.trades.is_empty() {
117            return None;
118        }
119
120        let mut summary = TradeSummary::default();
121        for group in self.trades.values() {
122            summary.buy_count = summary.buy_count.saturating_add(group.buy_count as u64);
123            summary.sell_count = summary.sell_count.saturating_add(group.sell_count as u64);
124            summary.buy_qty += group.buy_qty;
125            summary.sell_qty += group.sell_qty;
126        }
127
128        Some(summary)
129    }
130
131    /// Add trade to the bin at the step multiple computed with side-based rounding.
132    /// Intended for order-book ladder/quotes; Floor for sells, ceil for buys.
133    /// Introduces side bias at bin edges and should not be used for OHLC/footprint aggregation
134    pub fn add_trade_to_side_bin(&mut self, trade: &Trade, step: PriceStep) {
135        let price = trade.price.round_to_side_step(trade.is_sell, step);
136
137        self.trades
138            .entry(price)
139            .and_modify(|group| group.add_trade(trade))
140            .or_insert_with(|| GroupedTrades::new(trade));
141    }
142
143    /// Add trade to the bin at the nearest step multiple (side-agnostic).
144    /// Ties (exactly half a step) round up to the higher multiple.
145    /// Intended for footprint/OHLC trade aggregation
146    pub fn add_trade_to_nearest_bin(&mut self, trade: &Trade, step: PriceStep) {
147        self.exact_trades
148            .entry(trade.price)
149            .and_modify(|group| group.add_trade(trade))
150            .or_insert_with(|| GroupedTrades::new(trade));
151        let price = trade.price.round_to_step(step);
152
153        self.trades
154            .entry(price)
155            .and_modify(|group| group.add_trade(trade))
156            .or_insert_with(|| GroupedTrades::new(trade));
157    }
158
159    /// Rebins retained exact-price trades without replaying individual trades.
160    pub fn regroup(&mut self, step: PriceStep) {
161        self.trades.clear();
162        self.poc = None;
163        for (&price, group) in &self.exact_trades {
164            self.trades
165                .entry(price.round_to_step(step))
166                .and_modify(|current| current.merge(group))
167                .or_insert_with(|| group.clone());
168        }
169        self.calculate_poc();
170    }
171
172    /// Max of some extracted qty across price levels within [`lowest`, `highest`].
173    fn max_qty_by<F>(&self, highest: Price, lowest: Price, f: F) -> Qty
174    where
175        F: Fn(&GroupedTrades) -> Qty,
176    {
177        let mut max_qty = Qty::default();
178        for (price, group) in &self.trades {
179            if *price >= lowest && *price <= highest {
180                max_qty = max_qty.max(f(group));
181            }
182        }
183        max_qty
184    }
185
186    /// Max cluster qty considering only price levels within [`lowest`, `highest`].
187    /// Used by the aggregate visible-range computation where the viewport may
188    /// show only a subset of each bar's full price range (y-pan/zoom).
189    pub fn max_cluster_qty(&self, cluster_kind: ClusterKind, highest: Price, lowest: Price) -> Qty {
190        self.max_qty_by(highest, lowest, |group| group.max_cluster_qty(cluster_kind))
191    }
192
193    /// Max cluster qty across all price levels in this bar (unfiltered).
194    /// Used for per-bar individual scaling, the full bar should contribute.
195    pub fn max_cluster_qty_all(&self, cluster_kind: ClusterKind) -> Qty {
196        self.trades
197            .values()
198            .map(|group| group.max_cluster_qty(cluster_kind))
199            .max()
200            .unwrap_or_default()
201    }
202
203    /// Selects the highest-volume row and resets its fill status.
204    /// For equal positive volumes, chooses the highest price.
205    pub fn calculate_poc(&mut self) {
206        if self.trades.is_empty() {
207            return;
208        }
209
210        let mut max_volume = Qty::ZERO;
211        let mut poc_price = Price::from_f32(0.0);
212
213        for (price, group) in &self.trades {
214            let total_volume = group.total_qty();
215            if total_volume > max_volume
216                || (total_volume == max_volume && !total_volume.is_zero() && *price > poc_price)
217            {
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.exact_trades.clear();
243        self.poc = None;
244    }
245}
246
247#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
248pub enum ClusterKind {
249    #[default]
250    BidAsk,
251    VolumeProfile,
252    DeltaProfile,
253    Table,
254}
255
256impl ClusterKind {
257    pub const ALL: [ClusterKind; 4] = [
258        ClusterKind::BidAsk,
259        ClusterKind::VolumeProfile,
260        ClusterKind::DeltaProfile,
261        ClusterKind::Table,
262    ];
263}
264
265impl std::fmt::Display for ClusterKind {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        match self {
268            ClusterKind::BidAsk => write!(f, "Bid/Ask"),
269            ClusterKind::VolumeProfile => write!(f, "Volume Profile"),
270            ClusterKind::DeltaProfile => write!(f, "Delta Profile"),
271            ClusterKind::Table => write!(f, "Table"),
272        }
273    }
274}
275
276#[derive(Default, Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
277pub enum ClusterScaling {
278    #[default]
279    /// Scale based on the maximum quantity in the visible range.
280    VisibleRange,
281    /// Blend global VisibleRange and per-cluster Individual using a weight in [0.0, 1.0].
282    /// weight = fraction of global contribution (1.0 == all-global, 0.0 == all-individual).
283    Hybrid { weight: f32 },
284    /// Scale based only on the maximum quantity inside the datapoint (per-candle).
285    Datapoint,
286}
287
288impl ClusterScaling {
289    pub const ALL: [ClusterScaling; 3] = [
290        ClusterScaling::VisibleRange,
291        ClusterScaling::Hybrid { weight: 0.2 },
292        ClusterScaling::Datapoint,
293    ];
294
295    /// Blend the global visible-range max qty with the per-candle individual max qty
296    /// according to the scaling strategy.
297    pub fn effective_qty(self, visible_max: f64, individual_max: Qty) -> f64 {
298        match self {
299            ClusterScaling::VisibleRange => Qty::scale_or_one(visible_max),
300            ClusterScaling::Datapoint => individual_max.to_scale_or_one(),
301            ClusterScaling::Hybrid { weight } => {
302                let w = weight.clamp(0.0, 1.0) as f64;
303                Qty::scale_or_one(visible_max * w + individual_max.to_f64() * (1.0 - w))
304            }
305        }
306    }
307}
308
309impl std::fmt::Display for ClusterScaling {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        match self {
312            ClusterScaling::VisibleRange => write!(f, "Visible Range"),
313            ClusterScaling::Hybrid { weight } => write!(f, "Hybrid (weight: {:.2})", weight),
314            ClusterScaling::Datapoint => write!(f, "Per-candle"),
315        }
316    }
317}
318
319impl std::cmp::Eq for ClusterScaling {}