Skip to main content

flowsurface_data/chart/kline/studies/
volume_profile.rs

1use super::{MAX_PROFILE_PRICE_ROWS, StudyDataSource};
2use crate::chart::Basis;
3use crate::chart::kline::poc::{NPoc, PocLineTracker, PocProfile, PointOfControl};
4use crate::config::theme::{ThemeColor, ThemeColorRole, ThemeColorShade};
5use exchange::{
6    Kline, Timeframe, UnixMs,
7    unit::price::{Price, PriceStep},
8    unit::qty::Qty,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet};
12use std::fmt::{Display, Formatter};
13
14pub const DEFAULT_RECURRING_VOLUME_PROFILE_INTERVAL: Timeframe = Timeframe::M15;
15pub const MIN_RECURRING_VOLUME_PROFILE_BASIS_MULTIPLIER: u64 = 4;
16pub const DEFAULT_VOLUME_PROFILE_WIDTH_PERCENT: u8 = 20;
17pub const DEFAULT_VISIBLE_VOLUME_PROFILE_OPACITY_PERCENT: u8 = 20;
18pub const DEFAULT_RECURRING_VOLUME_PROFILE_OPACITY_PERCENT: u8 = 55;
19pub const MIN_VOLUME_PROFILE_OPACITY_PERCENT: u8 = 5;
20pub const MAX_VOLUME_PROFILE_OPACITY_PERCENT: u8 = 100;
21pub const MIN_VOLUME_PROFILE_WIDTH_PERCENT: u8 = 5;
22pub const MAX_VOLUME_PROFILE_WIDTH_PERCENT: u8 = 100;
23
24pub(super) fn default_volume_profile_width_percent() -> u8 {
25    DEFAULT_VOLUME_PROFILE_WIDTH_PERCENT
26}
27
28const fn default_show_volume_profile_poc() -> bool {
29    false
30}
31
32const fn default_show_volume_profile_npoc() -> bool {
33    true
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum VolumeProfileColorSlot {
38    Buy,
39    Sell,
40    NakedPoc,
41    FilledPoc,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct VolumeProfileColorSpec {
46    pub label: &'static str,
47    pub slot: VolumeProfileColorSlot,
48}
49
50const VOLUME_PROFILE_COLOR_SPECS: [VolumeProfileColorSpec; 4] = [
51    VolumeProfileColorSpec {
52        label: "Buy volume",
53        slot: VolumeProfileColorSlot::Buy,
54    },
55    VolumeProfileColorSpec {
56        label: "Sell volume",
57        slot: VolumeProfileColorSlot::Sell,
58    },
59    VolumeProfileColorSpec {
60        label: "NPOC",
61        slot: VolumeProfileColorSlot::NakedPoc,
62    },
63    VolumeProfileColorSpec {
64        label: "POC",
65        slot: VolumeProfileColorSlot::FilledPoc,
66    },
67];
68
69const KLINE_VOLUME_PROFILE_COLOR_SPECS: [VolumeProfileColorSpec; 4] = [
70    VolumeProfileColorSpec {
71        label: "Up volume",
72        slot: VolumeProfileColorSlot::Buy,
73    },
74    VolumeProfileColorSpec {
75        label: "Down volume",
76        slot: VolumeProfileColorSlot::Sell,
77    },
78    VolumeProfileColorSpec {
79        label: "NPOC",
80        slot: VolumeProfileColorSlot::NakedPoc,
81    },
82    VolumeProfileColorSpec {
83        label: "POC",
84        slot: VolumeProfileColorSlot::FilledPoc,
85    },
86];
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89pub struct VolumeProfileColorConfig {
90    pub buy: ThemeColor,
91    pub sell: ThemeColor,
92    pub naked_poc: ThemeColor,
93    pub filled_poc: ThemeColor,
94    #[serde(default = "default_show_volume_profile_poc")]
95    pub show_poc: bool,
96    #[serde(default = "default_show_volume_profile_npoc")]
97    pub show_npoc: bool,
98}
99
100impl Default for VolumeProfileColorConfig {
101    fn default() -> Self {
102        Self::defaults()
103    }
104}
105
106impl VolumeProfileColorConfig {
107    pub const fn defaults() -> Self {
108        Self {
109            buy: ThemeColor::new(ThemeColorRole::Secondary, ThemeColorShade::Strong),
110            sell: ThemeColor::new(ThemeColorRole::Secondary, ThemeColorShade::Soft),
111            naked_poc: ThemeColor::new(ThemeColorRole::Warning, ThemeColorShade::Strong),
112            filled_poc: ThemeColor::new(ThemeColorRole::Neutral, ThemeColorShade::Strong),
113            show_poc: false,
114            show_npoc: true,
115        }
116    }
117
118    pub const fn color_specs() -> &'static [VolumeProfileColorSpec] {
119        &VOLUME_PROFILE_COLOR_SPECS
120    }
121
122    pub fn color_specs_for_source(
123        source: StudyDataSource,
124        schedule: VolumeProfileSchedule,
125    ) -> &'static [VolumeProfileColorSpec] {
126        let specs = match source {
127            StudyDataSource::Kline => &KLINE_VOLUME_PROFILE_COLOR_SPECS,
128            StudyDataSource::Trades => &VOLUME_PROFILE_COLOR_SPECS,
129        };
130        match schedule {
131            VolumeProfileSchedule::VisibleRange => &specs[..2],
132            VolumeProfileSchedule::Recurring { .. } => specs,
133        }
134    }
135
136    pub const fn color_for(self, slot: VolumeProfileColorSlot) -> ThemeColor {
137        match slot {
138            VolumeProfileColorSlot::Buy => self.buy,
139            VolumeProfileColorSlot::Sell => self.sell,
140            VolumeProfileColorSlot::NakedPoc => self.naked_poc,
141            VolumeProfileColorSlot::FilledPoc => self.filled_poc,
142        }
143    }
144
145    pub const fn with_color(self, slot: VolumeProfileColorSlot, color: ThemeColor) -> Self {
146        match slot {
147            VolumeProfileColorSlot::Buy => Self { buy: color, ..self },
148            VolumeProfileColorSlot::Sell => Self {
149                sell: color,
150                ..self
151            },
152            VolumeProfileColorSlot::NakedPoc => Self {
153                naked_poc: color,
154                ..self
155            },
156            VolumeProfileColorSlot::FilledPoc => Self {
157                filled_poc: color,
158                ..self
159            },
160        }
161    }
162
163    pub const fn with_show_poc(self, show_poc: bool) -> Self {
164        Self { show_poc, ..self }
165    }
166
167    pub const fn with_show_npoc(self, show_npoc: bool) -> Self {
168        Self { show_npoc, ..self }
169    }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173pub enum VolumeProfileSchedule {
174    VisibleRange,
175    Recurring { interval: Timeframe },
176}
177
178impl VolumeProfileSchedule {
179    pub(super) const fn default_opacity_percent(self) -> u8 {
180        match self {
181            Self::VisibleRange => DEFAULT_VISIBLE_VOLUME_PROFILE_OPACITY_PERCENT,
182            Self::Recurring { .. } => DEFAULT_RECURRING_VOLUME_PROFILE_OPACITY_PERCENT,
183        }
184    }
185
186    pub fn recurring_intervals_for(basis: Basis) -> Vec<Timeframe> {
187        let Some(minimum_interval) = Self::minimum_recurring_interval(basis) else {
188            return Vec::new();
189        };
190
191        Timeframe::KLINE
192            .iter()
193            .copied()
194            .filter(|interval| interval.to_milliseconds() >= minimum_interval.to_milliseconds())
195            .collect()
196    }
197
198    pub fn minimum_recurring_interval(basis: Basis) -> Option<Timeframe> {
199        let Basis::Time(basis_interval) = basis else {
200            return None;
201        };
202
203        let minimum_interval_ms = basis_interval
204            .to_milliseconds()
205            .saturating_mul(MIN_RECURRING_VOLUME_PROFILE_BASIS_MULTIPLIER);
206
207        Timeframe::KLINE
208            .iter()
209            .copied()
210            .find(|interval| interval.to_milliseconds() >= minimum_interval_ms)
211    }
212
213    pub fn is_available(self, basis: Basis) -> bool {
214        match (self, basis) {
215            (Self::VisibleRange, Basis::Time(_)) => true,
216            (Self::VisibleRange, Basis::Tick(_)) => false,
217            (Self::Recurring { .. }, Basis::Time(_)) => {
218                Self::minimum_recurring_interval(basis).is_some()
219            }
220            (Self::Recurring { .. }, Basis::Tick(_)) => false,
221        }
222    }
223
224    pub fn is_interval_available(self, basis: Basis) -> bool {
225        match (self, basis) {
226            (Self::VisibleRange, _) => self.is_available(basis),
227            (Self::Recurring { interval }, Basis::Time(_)) => {
228                Self::minimum_recurring_interval(basis).is_some_and(|minimum_interval| {
229                    interval.to_milliseconds() >= minimum_interval.to_milliseconds()
230                })
231            }
232            (Self::Recurring { .. }, Basis::Tick(_)) => false,
233        }
234    }
235}
236
237impl Display for VolumeProfileSchedule {
238    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
239        match self {
240            Self::VisibleRange => formatter.write_str("Visible Range"),
241            Self::Recurring { interval } => write!(formatter, "Recurring {interval}"),
242        }
243    }
244}
245#[derive(Debug, Clone, Copy)]
246pub struct ProfileLevel {
247    pub price: Price,
248    pub positive_qty: Qty,
249    pub negative_qty: Qty,
250}
251
252impl ProfileLevel {
253    pub fn total_qty(self) -> Qty {
254        self.positive_qty + self.negative_qty
255    }
256}
257
258#[derive(Debug, Clone, Default)]
259pub struct ProfileDistribution {
260    pub levels: Vec<ProfileLevel>,
261    pub max_qty: Qty,
262    pub poc: Option<PointOfControl>,
263    pub price_step: PriceStep,
264}
265
266pub(crate) struct ProfileAccumulator {
267    levels: BTreeMap<Price, (Qty, Qty)>,
268    max_qty: Qty,
269    poc: Option<PointOfControl>,
270    price_step: PriceStep,
271}
272
273impl ProfileAccumulator {
274    pub(crate) fn new(price_step: PriceStep) -> Self {
275        Self {
276            levels: BTreeMap::new(),
277            max_qty: Qty::ZERO,
278            poc: None,
279            price_step,
280        }
281    }
282
283    pub(crate) fn add_volume(&mut self, price: Price, positive_qty: Qty, negative_qty: Qty) {
284        let level = self.levels.entry(price).or_insert((Qty::ZERO, Qty::ZERO));
285        level.0 += positive_qty;
286        level.1 += negative_qty;
287
288        let total_qty = level.0 + level.1;
289        self.max_qty = self.max_qty.max(total_qty);
290        if self.poc.is_none_or(|current| {
291            total_qty > current.volume || (total_qty == current.volume && price > current.price)
292        }) {
293            self.poc = Some(PointOfControl {
294                price,
295                volume: total_qty,
296                status: NPoc::None,
297            });
298        } else if self.poc.is_some_and(|current| current.price == price)
299            && let Some(poc) = self.poc.as_mut()
300        {
301            poc.volume = total_qty;
302        }
303    }
304
305    pub(crate) fn distribution(&self) -> ProfileDistribution {
306        ProfileDistribution {
307            levels: self
308                .levels
309                .iter()
310                .map(|(&price, &(positive_qty, negative_qty))| ProfileLevel {
311                    price,
312                    positive_qty,
313                    negative_qty,
314                })
315                .collect(),
316            max_qty: self.max_qty,
317            poc: self.poc,
318            price_step: self.price_step,
319        }
320    }
321
322    pub(crate) fn finish(self) -> Option<ProfileDistribution> {
323        if self.levels.is_empty() {
324            return None;
325        }
326
327        let levels = self
328            .levels
329            .into_iter()
330            .map(|(price, (positive_qty, negative_qty))| ProfileLevel {
331                price,
332                positive_qty,
333                negative_qty,
334            })
335            .collect();
336        Some(ProfileDistribution {
337            levels,
338            max_qty: self.max_qty,
339            poc: self.poc,
340            price_step: self.price_step,
341        })
342    }
343
344    fn add_kline(&mut self, bar: &Kline) {
345        let tick_size = self.price_step;
346        let (up_total, down_total) = bar.volume.buy_sell().unwrap_or_else(|| {
347            let total = bar.volume.total();
348            if bar.close >= bar.open {
349                (total, Qty::ZERO)
350            } else {
351                (Qty::ZERO, total)
352            }
353        });
354        if up_total.is_zero() && down_total.is_zero() {
355            return;
356        }
357
358        let low = bar.low.floor_to_step(tick_size);
359        let high = bar.high.floor_to_step(tick_size);
360        let Some(row_count) = Price::steps_between_inclusive(low, high, tick_size) else {
361            return;
362        };
363        if row_count == 0 {
364            return;
365        }
366        let row_count = i64::try_from(row_count).unwrap_or(i64::MAX);
367        let up_row_volume = up_total.units / row_count;
368        let up_remainder = up_total.units % row_count;
369        let down_row_volume = down_total.units / row_count;
370        let down_remainder = down_total.units % row_count;
371
372        for index in 0..row_count {
373            let Some(price) = low.checked_add_steps(index, tick_size) else {
374                break;
375            };
376            self.add_volume(
377                price,
378                Qty::from_units(up_row_volume + if index < up_remainder { 1 } else { 0 }),
379                Qty::from_units(down_row_volume + if index < down_remainder { 1 } else { 0 }),
380            );
381        }
382    }
383}
384
385impl PocProfile for ProfileAccumulator {
386    fn price_range(&self) -> Option<(Price, Price)> {
387        self.levels
388            .first_key_value()
389            .zip(self.levels.last_key_value())
390            .map(|((low, _), (high, _))| (*low, *high))
391    }
392
393    fn poc(&self) -> Option<PointOfControl> {
394        self.poc
395    }
396
397    fn set_poc_status(&mut self, status: NPoc) {
398        if let Some(poc) = self.poc.as_mut() {
399            poc.status = status;
400        }
401    }
402}
403
404impl PocProfile for ProfileDistribution {
405    fn price_range(&self) -> Option<(Price, Price)> {
406        self.levels
407            .first()
408            .zip(self.levels.last())
409            .map(|(low, high)| (low.price, high.price))
410    }
411
412    fn poc(&self) -> Option<PointOfControl> {
413        self.poc
414    }
415
416    fn set_poc_status(&mut self, status: NPoc) {
417        if let Some(poc) = self.poc.as_mut() {
418            poc.status = status;
419        }
420    }
421}
422
423#[derive(Debug, Clone)]
424pub struct AnchoredProfileDistribution {
425    pub start: UnixMs,
426    pub end: UnixMs,
427    pub distribution: ProfileDistribution,
428}
429
430pub fn kline_volume_profile(
431    bars: &[Kline],
432    earliest: UnixMs,
433    latest: UnixMs,
434    tick_size: PriceStep,
435) -> Option<ProfileDistribution> {
436    if tick_size.units <= 0 || earliest >= latest {
437        return None;
438    }
439
440    let mut range: Option<(Price, Price)> = None;
441    for bar in bars
442        .iter()
443        .filter(|bar| bar.time >= earliest && bar.time < latest)
444    {
445        range = Some(match range {
446            Some((low, high)) => (low.min(bar.low), high.max(bar.high)),
447            None => (bar.low, bar.high),
448        });
449    }
450    let (low, high) = range?;
451    let price_step = tick_size.coarsened_to_fit(low, high, MAX_PROFILE_PRICE_ROWS)?;
452    let mut accumulator = ProfileAccumulator::new(price_step);
453    for &bar in bars
454        .iter()
455        .filter(|bar| bar.time >= earliest && bar.time < latest)
456    {
457        accumulator.add_kline(&bar);
458    }
459    accumulator.finish()
460}
461
462pub fn recurring_kline_volume_profiles(
463    bars: &[Kline],
464    interval: Timeframe,
465    earliest: UnixMs,
466    latest: UnixMs,
467    tick_size: PriceStep,
468) -> Vec<AnchoredProfileDistribution> {
469    if tick_size.units <= 0 || earliest >= latest {
470        return Vec::new();
471    }
472
473    let mut index = RecurringKlineProfileIndex::new(interval, tick_size);
474    index.update(bars);
475    index.visible_profiles(earliest, latest)
476}
477
478#[derive(Default)]
479struct KlineProfileSession {
480    bars: BTreeMap<UnixMs, Kline>,
481    distribution: Option<ProfileDistribution>,
482}
483
484impl PocProfile for KlineProfileSession {
485    fn price_range(&self) -> Option<(Price, Price)> {
486        self.distribution.as_ref()?.price_range()
487    }
488
489    fn poc(&self) -> Option<PointOfControl> {
490        self.distribution.as_ref()?.poc
491    }
492
493    fn set_poc_status(&mut self, status: NPoc) {
494        if let Some(distribution) = &mut self.distribution {
495            distribution.set_poc_status(status);
496        }
497    }
498}
499
500/// Retained candle sessions and their calculated volume distributions and POC lines.
501pub struct RecurringKlineProfileIndex {
502    interval: Timeframe,
503    tick_size: PriceStep,
504    profiles: BTreeMap<UnixMs, KlineProfileSession>,
505    poc_lines: PocLineTracker,
506}
507
508impl RecurringKlineProfileIndex {
509    pub fn new(interval: Timeframe, tick_size: PriceStep) -> Self {
510        Self {
511            interval,
512            tick_size,
513            profiles: BTreeMap::new(),
514            poc_lines: PocLineTracker::default(),
515        }
516    }
517
518    /// Replaces a complete retained candle snapshot outside the live update path.
519    pub fn update(&mut self, bars: &[Kline]) -> bool {
520        let bars = bars
521            .iter()
522            .map(|bar| (bar.time, *bar))
523            .collect::<BTreeMap<_, _>>();
524        if self
525            .profiles
526            .values()
527            .map(|profile| profile.bars.len())
528            .sum::<usize>()
529            == bars.len()
530            && bars.values().all(|bar| {
531                self.profiles
532                    .get(&bar.time.floor_to(self.interval))
533                    .and_then(|profile| profile.bars.get(&bar.time))
534                    == Some(bar)
535            })
536        {
537            return false;
538        }
539        self.profiles.clear();
540        self.poc_lines = PocLineTracker::default();
541        self.upsert_bars(&bars.into_values().collect::<Vec<_>>());
542        true
543    }
544
545    pub fn upsert_bars(&mut self, bars: &[Kline]) -> bool {
546        if bars.is_empty() {
547            return false;
548        }
549        let previous_latest = self.latest_profile_range();
550        let mut changed = BTreeSet::new();
551        for &bar in bars {
552            let start = bar.time.floor_to(self.interval);
553            let profile = self.profiles.entry(start).or_default();
554            if profile.bars.get(&bar.time) == Some(&bar) {
555                continue;
556            }
557            profile.bars.insert(bar.time, bar);
558            changed.insert(start);
559        }
560        self.recalculate_profiles(changed, previous_latest, false)
561    }
562
563    pub fn retain_range(&mut self, start: UnixMs, end: UnixMs) -> bool {
564        let previous_latest = self.latest_profile_range();
565        let mut changed = BTreeSet::new();
566        let previous_count = self.profiles.len();
567        self.profiles.retain(|profile_start, profile| {
568            let previous_len = profile.bars.len();
569            profile.bars.retain(|time, _| *time >= start && *time < end);
570            if profile.bars.is_empty() {
571                return false;
572            }
573            if profile.bars.len() != previous_len {
574                changed.insert(*profile_start);
575            }
576            true
577        });
578        self.recalculate_profiles(
579            changed,
580            previous_latest,
581            self.profiles.len() != previous_count,
582        )
583    }
584
585    fn latest_profile_range(&self) -> Option<(UnixMs, (Price, Price))> {
586        self.profiles
587            .iter()
588            .rev()
589            .find_map(|(&start, profile)| profile.price_range().map(|range| (start, range)))
590    }
591
592    fn recalculate_profiles(
593        &mut self,
594        changed: BTreeSet<UnixMs>,
595        previous_latest: Option<(UnixMs, (Price, Price))>,
596        removed: bool,
597    ) -> bool {
598        for &start in &changed {
599            let profile = self
600                .profiles
601                .get_mut(&start)
602                .expect("changed profile exists");
603            let bars = profile.bars.values().copied().collect::<Vec<_>>();
604            profile.distribution = kline_volume_profile(
605                &bars,
606                start,
607                start.offset_by_timeframe(self.interval, 1),
608                self.tick_size,
609            );
610        }
611        if changed.is_empty() && !removed {
612            return false;
613        }
614
615        let append_only = !removed
616            && previous_latest.is_none_or(|(latest, (low, high))| {
617                changed.iter().all(|start| *start >= latest)
618                    && self
619                        .profiles
620                        .get(&latest)
621                        .and_then(PocProfile::price_range)
622                        .is_some_and(|(next_low, next_high)| next_low <= low && next_high >= high)
623            });
624        if append_only {
625            for start in changed {
626                self.poc_lines.process_profile(&mut self.profiles, start);
627            }
628        } else {
629            self.poc_lines.rebuild(&mut self.profiles);
630        }
631        true
632    }
633
634    pub fn visible_profiles(
635        &self,
636        earliest: UnixMs,
637        latest: UnixMs,
638    ) -> Vec<AnchoredProfileDistribution> {
639        if earliest >= latest {
640            return Vec::new();
641        }
642
643        let visible_starts =
644            self.poc_lines
645                .visible_starts(self.interval, earliest, latest, &self.profiles);
646
647        visible_starts
648            .into_iter()
649            .filter_map(|start| {
650                self.profiles
651                    .get(&start)
652                    .and_then(|profile| profile.distribution.clone())
653                    .map(|distribution| AnchoredProfileDistribution {
654                        start,
655                        end: start.offset_by_timeframe(self.interval, 1),
656                        distribution,
657                    })
658            })
659            .collect()
660    }
661}