1use super::{HistoricalDepth, ProfileKind};
2use crate::chart::value::{
3 PanelValueLabelMode, PanelValueLabelPolicy, PanelValuePrecision, PanelValueScale,
4};
5use enum_map::{Enum, EnumMap};
6use exchange::{
7 TickerInfo, Timeframe, UnixMs,
8 adapter::Venue,
9 depth::Depth,
10 unit::{MinTicksize, Power10, Price, PriceStep, Qty},
11};
12use serde::{Deserialize, Deserializer, Serialize};
13use std::{
14 collections::BTreeMap,
15 ops::{Bound, ControlFlow},
16};
17
18const MIN_PANEL_RATIO: f32 = 0.08;
19const DEFAULT_PANEL_WEIGHTS: [f32; 3] = [70.0, 15.0, 15.0];
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum HeatmapIndicator {
23 Volume,
24 Spread,
25 Depth,
26 VolumeProfile,
27}
28
29impl HeatmapIndicator {
30 pub const ALL: [Self; 4] = [Self::Volume, Self::VolumeProfile, Self::Spread, Self::Depth];
31
32 pub const fn label(self) -> &'static str {
33 match self {
34 Self::Volume => "Volume",
35 Self::Spread => "Spread",
36 Self::Depth => "Depth",
37 Self::VolumeProfile => "Volume Profile",
38 }
39 }
40
41 pub fn unsupported_reason(self, venue: Venue) -> Option<&'static str> {
42 (self == Self::Spread && venue == Venue::Hyperliquid)
43 .then_some("Spread unavailable.\nDepth prices may be server-aggregated.")
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Enum)]
48pub enum HeatmapPanel {
49 Spread,
50 Depth,
51}
52
53impl HeatmapPanel {
54 pub const ALL: [Self; 2] = [Self::Spread, Self::Depth];
55
56 fn weight_index(self) -> usize {
57 match self {
58 Self::Spread => 1,
59 Self::Depth => 2,
60 }
61 }
62
63 pub fn value_scale(
64 self,
65 config: &HeatmapIndicatorConfig,
66 ticker: TickerInfo,
67 ) -> PanelValueScale {
68 let precision = match self {
69 Self::Spread => match config.spread_unit {
70 SpreadUnit::Bps => PanelValuePrecision::FixedStep(0.001),
71 SpreadUnit::Ticks => PanelValuePrecision::FixedStep(1.0),
72 },
73 Self::Depth => PanelValuePrecision::FixedPower10(Power10::new(-Qty::QTY_SCALE as i8)),
74 };
75 PanelValueScale::new(Some(precision), Some(ticker))
76 }
77
78 pub fn value_label_policy(self) -> PanelValueLabelPolicy {
79 let mode = match self {
80 Self::Spread => PanelValueLabelMode::Compact,
81 Self::Depth => PanelValueLabelMode::Abbreviated,
82 };
83 PanelValueLabelPolicy {
84 axis_mode: mode,
85 header_mode: mode,
86 max_decimals: None,
87 }
88 }
89}
90
91impl From<HeatmapPanel> for HeatmapIndicator {
92 fn from(panel: HeatmapPanel) -> Self {
93 match panel {
94 HeatmapPanel::Spread => Self::Spread,
95 HeatmapPanel::Depth => Self::Depth,
96 }
97 }
98}
99
100#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101pub enum SpreadUnit {
102 #[default]
103 Bps,
104 Ticks,
105}
106
107impl std::fmt::Display for SpreadUnit {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.write_str(match self {
110 Self::Bps => "Bps",
111 Self::Ticks => "Ticks",
112 })
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(try_from = "f64", into = "f64")]
118pub struct DepthDistance(u16);
119
120impl Default for DepthDistance {
121 fn default() -> Self {
122 Self(10)
123 }
124}
125
126impl TryFrom<f64> for DepthDistance {
128 type Error = &'static str;
129
130 fn try_from(basis_points: f64) -> Result<Self, Self::Error> {
131 if !basis_points.is_finite() || !(1.0..=1_000.0).contains(&basis_points) {
132 return Err("Depth distance must be between 0.01% and 10%");
133 }
134 Ok(Self(basis_points.round() as u16))
135 }
136}
137
138impl From<DepthDistance> for f64 {
139 fn from(value: DepthDistance) -> Self {
140 f64::from(value.0)
141 }
142}
143
144impl DepthDistance {
145 pub fn from_percent(input: &str) -> Option<Self> {
146 let percent = input.trim().parse::<f64>().ok()?;
147 Self::try_from(percent * 100.0).ok()
148 }
149
150 pub fn percent(self) -> f64 {
151 f64::from(self.0) / 100.0
152 }
153
154 fn bounds(self, bid: Price, ask: Price) -> (Price, Price) {
155 let distance = i128::from(self.0);
156 let lower = (i128::from(bid.units) * (10_000 - distance) + 9_999) / 10_000;
157 let upper = i128::from(ask.units) * (10_000 + distance) / 10_000;
158 (
159 Price::from_units(lower as i64),
160 Price::from_units(upper.min(i128::from(i64::MAX)) as i64),
161 )
162 }
163}
164
165impl std::fmt::Display for DepthDistance {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 write!(f, "{:.2}", self.percent())
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(default)]
173pub struct HeatmapIndicatorConfig {
174 pub volume: bool,
175 pub volume_profile: Option<ProfileKind>,
176 pub spread: bool,
177 pub spread_unit: SpreadUnit,
178 pub depth: bool,
179 pub distance: DepthDistance,
180 #[serde(deserialize_with = "deserialize_panel_weights")]
181 panel_weights: [f32; 3],
182}
183
184impl Default for HeatmapIndicatorConfig {
185 fn default() -> Self {
186 Self {
187 volume: true,
188 volume_profile: Some(ProfileKind::default()),
189 spread: false,
190 spread_unit: SpreadUnit::default(),
191 depth: false,
192 distance: DepthDistance::default(),
193 panel_weights: DEFAULT_PANEL_WEIGHTS,
194 }
195 }
196}
197
198impl HeatmapIndicatorConfig {
199 fn accepts_depth_replay(&self, distance: DepthDistance) -> bool {
200 self.depth && self.distance == distance
201 }
202
203 pub fn is_enabled(&self, kind: HeatmapIndicator) -> bool {
204 match kind {
205 HeatmapIndicator::Volume => self.volume,
206 HeatmapIndicator::Spread => self.spread,
207 HeatmapIndicator::Depth => self.depth,
208 HeatmapIndicator::VolumeProfile => self.volume_profile.is_some(),
209 }
210 }
211
212 pub fn set_enabled(&mut self, kind: HeatmapIndicator, enabled: bool) {
213 match kind {
214 HeatmapIndicator::Volume => self.volume = enabled,
215 HeatmapIndicator::Spread => self.spread = enabled,
216 HeatmapIndicator::Depth => self.depth = enabled,
217 HeatmapIndicator::VolumeProfile => {
218 self.volume_profile = enabled.then(|| self.volume_profile.unwrap_or_default());
219 }
220 }
221 }
222
223 fn enabled_panels(&self, venue: Venue) -> impl Iterator<Item = HeatmapPanel> + '_ {
224 HeatmapPanel::ALL.into_iter().filter(move |kind| {
225 let indicator = HeatmapIndicator::from(*kind);
226 self.is_enabled(indicator) && indicator.unsupported_reason(venue).is_none()
227 })
228 }
229
230 fn panel_indices(&self, venue: Venue) -> Vec<usize> {
231 std::iter::once(0)
232 .chain(self.enabled_panels(venue).map(HeatmapPanel::weight_index))
233 .collect()
234 }
235
236 fn normalized_weights(&self, venue: Venue) -> Vec<f32> {
237 let indices = self.panel_indices(venue);
238 let weights: Vec<_> = indices
239 .iter()
240 .map(|&index| self.panel_weights[index])
241 .collect();
242
243 let total: f64 = weights.iter().map(|weight| f64::from(*weight)).sum();
244 let mut cumulative = 0.0;
245 let mut previous = 0.0;
246 let mut normalized = Vec::with_capacity(weights.len());
247 for (index, weight) in weights.iter().enumerate() {
248 cumulative += (f64::from(*weight) / total) as f32;
249 let boundary = if index + 1 == weights.len() {
250 1.0
251 } else {
252 cumulative.clamp(
253 previous + MIN_PANEL_RATIO,
254 1.0 - MIN_PANEL_RATIO * (weights.len() - index - 1) as f32,
255 )
256 };
257 normalized.push(boundary - previous);
258 previous = boundary;
259 }
260 normalized
261 }
262
263 fn set_split(&mut self, index: usize, split: f32, venue: Venue) {
264 if !split.is_finite() {
265 return;
266 }
267 let indices = self.panel_indices(venue);
268 if index + 1 >= indices.len() {
269 return;
270 }
271
272 let mut weights = self.normalized_weights(venue);
273 let before: f32 = weights[..index].iter().sum();
274 let combined = weights[index] + weights[index + 1];
275 let first = (split - before).clamp(
276 MIN_PANEL_RATIO,
277 (combined - MIN_PANEL_RATIO).max(MIN_PANEL_RATIO),
278 );
279 weights[index] = first;
280 weights[index + 1] = combined - first;
281 let all_weights = self.panel_weights.map(f64::from);
282
283 let total: f64 = all_weights.iter().sum();
284 let active_total: f64 = indices
285 .iter()
286 .map(|&index| all_weights[index] / total)
287 .sum();
288 let mut panel_weights = all_weights.map(|weight| (weight / total) as f32);
289 for (&slot, weight) in indices.iter().zip(weights) {
290 panel_weights[slot] = (f64::from(weight) * active_total) as f32;
291 }
292 self.panel_weights = sanitize_panel_weights(panel_weights);
293 }
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum DepthCoverage {
298 Unknown,
299 ReachesBounds,
300 FallsShort,
301}
302
303#[derive(Clone, Copy)]
304pub enum DepthSource {
305 Live,
306 Reconstructed,
307}
308
309#[derive(Clone, Copy)]
310enum IndicatorValue {
311 Spread {
312 bid: Price,
313 ask: Price,
314 },
315 Depth {
316 bid: Qty,
317 ask: Qty,
318 coverage: DepthCoverage,
319 },
320}
321
322impl IndicatorValue {
323 fn percentage_depth(
324 depth: &Depth,
325 (bid, ask): (Price, Price),
326 step: PriceStep,
327 distance: DepthDistance,
328 source: DepthSource,
329 ) -> Option<Self> {
330 let bid = bid.round_to_side_step(true, step);
331 let ask = ask.round_to_side_step(false, step);
332 if bid.units <= 0 {
333 return None;
334 }
335
336 let (lower, upper) = distance.bounds(bid, ask);
337 let sum = |side: &BTreeMap<Price, Qty>, is_bid, lower: Price, upper: Price| {
338 let raw_lower = Price::from_units(lower.units.saturating_sub(step.units));
339 let raw_upper = Price::from_units(upper.units.saturating_add(step.units));
340
341 side.range(raw_lower..=raw_upper)
342 .filter(|(price, qty)| {
343 let price = price.round_to_side_step(is_bid, step);
344 **qty > Qty::ZERO && price >= lower && price <= upper
345 })
346 .try_fold(0i64, |sum, (_, qty)| sum.checked_add(qty.units))
347 .map(Qty::from_units)
348 };
349
350 let coverage = match source {
351 DepthSource::Reconstructed => DepthCoverage::Unknown,
352 DepthSource::Live => {
353 let bid_reaches_bound = depth
354 .bids
355 .iter()
356 .find(|(_, qty)| **qty > Qty::ZERO)
357 .is_some_and(|(price, _)| price.round_to_side_step(true, step) <= lower);
358 let ask_reaches_bound = depth
359 .asks
360 .iter()
361 .rev()
362 .find(|(_, qty)| **qty > Qty::ZERO)
363 .is_some_and(|(price, _)| price.round_to_side_step(false, step) >= upper);
364
365 if bid_reaches_bound && ask_reaches_bound {
366 DepthCoverage::ReachesBounds
367 } else {
368 DepthCoverage::FallsShort
369 }
370 }
371 };
372
373 Some(Self::Depth {
374 bid: sum(&depth.bids, true, lower, bid)?,
375 ask: sum(&depth.asks, false, ask, upper)?,
376 coverage,
377 })
378 }
379}
380
381struct Sample {
382 timestamp: UnixMs,
383 value: Option<IndicatorValue>,
384}
385
386impl Sample {
387 fn from_depth(
388 kind: HeatmapPanel,
389 depth: &Depth,
390 timestamp: UnixMs,
391 step: PriceStep,
392 distance: DepthDistance,
393 source: DepthSource,
394 ) -> Self {
395 let best_prices = depth
396 .bids
397 .iter()
398 .rev()
399 .find(|(_, qty)| **qty > Qty::ZERO)
400 .zip(depth.asks.iter().find(|(_, qty)| **qty > Qty::ZERO))
401 .map(|((&bid, _), (&ask, _))| (bid, ask))
402 .filter(|(bid, ask)| bid.units > 0 && *ask >= *bid);
403
404 let value = best_prices.and_then(|(bid, ask)| match kind {
405 HeatmapPanel::Spread => Some(IndicatorValue::Spread { bid, ask }),
406 HeatmapPanel::Depth => {
407 IndicatorValue::percentage_depth(depth, (bid, ask), step, distance, source)
408 }
409 });
410
411 Self { timestamp, value }
412 }
413}
414
415#[derive(Debug, Clone, Copy)]
416pub enum IndicatorValues {
417 Spread(f64),
418 Depth { bid: f64, ask: f64 },
419}
420
421#[derive(Clone)]
422pub struct HeatmapReplay {
423 distance: DepthDistance,
424 depth_timestamp: Option<UnixMs>,
425}
426
427pub struct DepthReplayResult {
428 distance: DepthDistance,
429 samples: BTreeMap<UnixMs, Sample>,
430}
431
432impl HeatmapReplay {
433 pub fn matches(&self, config: &HeatmapIndicatorConfig) -> bool {
434 config.accepts_depth_replay(self.distance)
435 }
436
437 pub fn include(&mut self, previous: &Self) {
438 self.depth_timestamp = self.depth_timestamp.max(previous.depth_timestamp);
439 }
440
441 pub fn run(
442 self,
443 history: &HistoricalDepth,
444 start: UnixMs,
445 control: impl FnMut() -> ControlFlow<()>,
446 ) -> ControlFlow<(), DepthReplayResult> {
447 let mut samples = BTreeMap::new();
448 history.replay_depth_interruptible(
449 start,
450 |time, depth| {
451 let sample = Sample::from_depth(
452 HeatmapPanel::Depth,
453 depth,
454 time,
455 history.tick_size,
456 self.distance,
457 DepthSource::Reconstructed,
458 );
459 samples.insert(time.floor_to(history.aggr_time), sample);
460 },
461 control,
462 )?;
463
464 if let Some(time) = self.depth_timestamp
465 && let Some(mut latest) = samples.last_entry()
466 {
467 latest.get_mut().timestamp = latest.get().timestamp.max(time);
468 }
469
470 ControlFlow::Continue(DepthReplayResult {
471 distance: self.distance,
472 samples,
473 })
474 }
475}
476
477pub struct HeatmapIndicators {
478 config: HeatmapIndicatorConfig,
479 venue: Venue,
480 samples: EnumMap<HeatmapPanel, BTreeMap<UnixMs, Sample>>,
481 revision: u64,
482}
483
484impl HeatmapIndicators {
485 pub fn new(config: HeatmapIndicatorConfig, venue: Venue) -> Self {
486 Self {
487 config,
488 venue,
489 revision: 0,
490 samples: EnumMap::default(),
491 }
492 }
493
494 pub fn revision(&self) -> u64 {
495 self.revision
496 }
497
498 pub fn config(&self) -> &HeatmapIndicatorConfig {
499 &self.config
500 }
501
502 pub fn enabled_panels(&self) -> impl Iterator<Item = HeatmapPanel> + '_ {
503 self.config.enabled_panels(self.venue)
504 }
505
506 pub fn normalized_weights(&self) -> Vec<f32> {
507 self.config.normalized_weights(self.venue)
508 }
509
510 pub fn set_panel_split(&mut self, index: usize, split: f32) {
511 self.config.set_split(index, split, self.venue);
512 }
513
514 pub fn reconfigure(&mut self, config: HeatmapIndicatorConfig) -> Option<HeatmapReplay> {
515 let depth_distance_changed = self.config.distance != config.distance;
516 let replay = (config.depth && (!self.config.depth || depth_distance_changed)).then(|| {
517 HeatmapReplay {
518 distance: config.distance,
519 depth_timestamp: self.samples[HeatmapPanel::Depth]
520 .last_key_value()
521 .map(|(_, sample)| sample.timestamp),
522 }
523 });
524
525 let depth_changed = self.config.depth != config.depth || depth_distance_changed;
526 let values_changed = depth_changed
527 || self.config.spread != config.spread
528 || self.config.spread_unit != config.spread_unit;
529 if depth_changed {
530 self.samples[HeatmapPanel::Depth].clear();
531 }
532
533 self.config = config;
534 if values_changed {
535 self.revision = self.revision.wrapping_add(1);
536 }
537
538 replay
539 }
540
541 pub fn merge_replay(&mut self, replay: DepthReplayResult, cutoff: UnixMs) -> bool {
542 if !self.config.accepts_depth_replay(replay.distance) {
543 return false;
544 }
545
546 for (time, sample) in replay.samples {
547 self.samples[HeatmapPanel::Depth]
548 .entry(time)
549 .or_insert(sample);
550 }
551
552 self.prune_before(cutoff);
553 true
554 }
555
556 pub fn ingest(
557 &mut self,
558 kind: HeatmapPanel,
559 depth: &Depth,
560 timestamp: UnixMs,
561 interval: Timeframe,
562 source: DepthSource,
563 step: PriceStep,
564 ) {
565 if HeatmapIndicator::from(kind)
566 .unsupported_reason(self.venue)
567 .is_some()
568 || (kind == HeatmapPanel::Depth && !self.config.depth)
569 {
570 return;
571 }
572
573 let samples = &mut self.samples[kind];
574 if samples
575 .last_key_value()
576 .is_some_and(|(_, sample)| sample.timestamp > timestamp)
577 {
578 return;
579 }
580
581 let sample = Sample::from_depth(kind, depth, timestamp, step, self.config.distance, source);
582 samples.insert(timestamp.floor_to(interval), sample);
583 self.revision = self.revision.wrapping_add(1);
584 }
585
586 pub fn prune_before(&mut self, cutoff: UnixMs) {
587 self.revision = self.revision.wrapping_add(1);
588 for samples in self.samples.values_mut() {
589 let mut retained = samples.split_off(&cutoff);
590 if !retained.contains_key(&cutoff)
591 && let Some((_, sample)) = samples.pop_last()
592 {
593 retained.insert(cutoff, sample);
594 }
595 *samples = retained;
596 }
597 }
598
599 pub fn samples(
600 &self,
601 kind: HeatmapPanel,
602 min_ticksize: MinTicksize,
603 start: UnixMs,
604 end: UnixMs,
605 interval_ms: u64,
606 ) -> impl Iterator<Item = (UnixMs, Option<IndicatorValues>)> + '_ {
607 let interval_ms = interval_ms.max(1);
608 let start = start.as_u64() / interval_ms * interval_ms;
609 let end = end.as_u64();
610 let samples = &self.samples[kind];
611
612 let mut current = samples.range(..=UnixMs::new(start)).next_back();
613 let mut updates = samples
614 .range((Bound::Excluded(UnixMs::new(start)), Bound::Unbounded))
615 .peekable();
616
617 std::iter::successors((start <= end).then_some(start), move |time| {
619 time.checked_add(interval_ms).filter(|next| *next <= end)
620 })
621 .map(move |time| {
622 let time = UnixMs::new(time);
623 while updates.peek().is_some_and(|(update, _)| **update <= time) {
624 current = updates.next();
625 }
626 (
627 time,
628 current.and_then(|(_, sample)| self.project(sample, min_ticksize)),
629 )
630 })
631 }
632
633 pub fn value_at(
634 &self,
635 kind: HeatmapPanel,
636 min_ticksize: MinTicksize,
637 time: UnixMs,
638 ) -> Option<IndicatorValues> {
639 self.samples[kind]
640 .range(..=time)
641 .next_back()
642 .and_then(|(_, sample)| self.project(sample, min_ticksize))
643 }
644
645 pub fn depth_coverage_at(&self, time: UnixMs) -> Option<DepthCoverage> {
646 let (_, sample) = self.samples[HeatmapPanel::Depth]
647 .range(..=time)
648 .next_back()?;
649 match sample.value? {
650 IndicatorValue::Depth { coverage, .. } => Some(coverage),
651 IndicatorValue::Spread { .. } => None,
652 }
653 }
654
655 fn project(&self, sample: &Sample, min_ticksize: MinTicksize) -> Option<IndicatorValues> {
656 match sample.value? {
657 IndicatorValue::Spread { bid, ask } => {
658 let gap = (ask.units - bid.units) as f64;
659 Some(IndicatorValues::Spread(match self.config.spread_unit {
660 SpreadUnit::Bps => 20_000.0 * gap / (bid.units as f64 + ask.units as f64),
661 SpreadUnit::Ticks => gap / Price::min_tick_units(min_ticksize) as f64,
662 }))
663 }
664 IndicatorValue::Depth { bid, ask, .. } => Some(IndicatorValues::Depth {
665 bid: bid.to_f64(),
666 ask: ask.to_f64(),
667 }),
668 }
669 }
670}
671
672fn sanitize_panel_weights(weights: [f32; 3]) -> [f32; 3] {
673 std::array::from_fn(|index| {
674 let weight = weights[index];
675 if weight.is_finite() && weight > 0.0 {
676 weight
677 } else {
678 DEFAULT_PANEL_WEIGHTS[index]
679 }
680 })
681}
682
683fn deserialize_panel_weights<'de, D>(deserializer: D) -> Result<[f32; 3], D::Error>
684where
685 D: Deserializer<'de>,
686{
687 <[f32; 3]>::deserialize(deserializer).map(sanitize_panel_weights)
688}