flowsurface_data/chart/kline/
footprint.rs1use 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#[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 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 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 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 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 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 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 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 VisibleRange,
281 Hybrid { weight: f32 },
284 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 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 {}