Skip to main content

flowsurface_data/chart/heatmap/
indicators.rs

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
126// Persist basis points so saved layouts retain their percentages.
127impl 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        // Project the last observed book onto chart buckets without inventing source timestamps.
618        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}