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
500pub 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 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}