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 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 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 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 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 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 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 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 pub data_labels_always_visible: bool,
437 pub show_footprint_summary: bool,
439}
440
441#[derive(Default, Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
442pub enum ClusterScaling {
443 #[default]
444 VisibleRange,
446 Hybrid { weight: f32 },
449 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 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}