Skip to main content

flowsurface_data/chart/kline/
studies.rs

1pub mod big_trades;
2pub mod tpo;
3pub mod volume_profile;
4
5pub use big_trades::{
6    BigTradesConfig, DEFAULT_BIG_TRADES_THRESHOLD, MAX_BIG_TRADES_THRESHOLD,
7    MIN_BIG_TRADES_THRESHOLD, big_trades_step_count, big_trades_step_for_threshold,
8    big_trades_threshold_at_step, big_trades_threshold_label,
9};
10pub use tpo::{
11    DEFAULT_TPO_LETTER_PERIOD, DEFAULT_TPO_VALUE_AREA_PERCENT, MAX_TPO_VALUE_AREA_PERCENT,
12    MIN_TPO_VALUE_AREA_PERCENT, TpoColorConfig,
13};
14pub use volume_profile::{
15    DEFAULT_RECURRING_VOLUME_PROFILE_INTERVAL, DEFAULT_RECURRING_VOLUME_PROFILE_OPACITY_PERCENT,
16    DEFAULT_VISIBLE_VOLUME_PROFILE_OPACITY_PERCENT, DEFAULT_VOLUME_PROFILE_WIDTH_PERCENT,
17    MAX_VOLUME_PROFILE_OPACITY_PERCENT, MAX_VOLUME_PROFILE_WIDTH_PERCENT,
18    MIN_RECURRING_VOLUME_PROFILE_BASIS_MULTIPLIER, MIN_VOLUME_PROFILE_OPACITY_PERCENT,
19    MIN_VOLUME_PROFILE_WIDTH_PERCENT, VolumeProfileColorConfig, VolumeProfileColorSlot,
20    VolumeProfileColorSpec, VolumeProfileSchedule,
21};
22
23use self::big_trades::{
24    default_big_trades_buy_color, default_big_trades_radius_scale, default_big_trades_sell_color,
25    default_big_trades_threshold,
26};
27use self::tpo::{
28    TpoAnchor, default_tpo_anchor, default_tpo_letter_period, default_tpo_value_area_percent,
29    default_tpo_visible,
30};
31use self::volume_profile::default_volume_profile_width_percent;
32
33use crate::chart::Basis;
34use crate::config::theme::ThemeColor;
35use exchange::Timeframe;
36use serde::{Deserialize, Serialize};
37use std::fmt::{Display, Formatter};
38
39const MAX_PROFILE_PRICE_ROWS: usize = 4_096;
40
41const fn default_study_visible() -> bool {
42    true
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46pub enum StudyDataSource {
47    Kline,
48    Trades,
49}
50
51impl StudyDataSource {
52    pub const fn requires_trade_data(self) -> bool {
53        matches!(self, Self::Trades)
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum SourceConstraint {
59    Exclusive(StudyDataSource),
60    Independent,
61}
62
63impl Display for StudyDataSource {
64    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
65        formatter.write_str(match self {
66            Self::Kline => "Kline",
67            Self::Trades => "Trades",
68        })
69    }
70}
71
72#[derive(Debug, Clone, Copy, Deserialize)]
73enum PersistedKlineStudy {
74    VolumeProfile {
75        #[serde(default)]
76        source: Option<StudyDataSource>,
77        schedule: VolumeProfileSchedule,
78        #[serde(default)]
79        colors: VolumeProfileColorConfig,
80        #[serde(default = "default_volume_profile_width_percent")]
81        width_percent: u8,
82        opacity_percent: Option<u8>,
83        #[serde(default = "default_study_visible")]
84        visible: bool,
85    },
86    TpoProfile {
87        #[serde(default)]
88        source: Option<StudyDataSource>,
89        #[serde(default = "default_tpo_anchor")]
90        anchor: TpoAnchor,
91        #[serde(default = "default_tpo_letter_period")]
92        letter_period: Timeframe,
93        #[serde(default = "default_tpo_value_area_percent")]
94        value_area_percent: u8,
95        #[serde(default)]
96        colors: TpoColorConfig,
97        #[serde(default = "default_tpo_visible")]
98        visible: bool,
99    },
100    BigTrades {
101        #[serde(default = "default_big_trades_threshold")]
102        threshold: u64,
103        #[serde(default = "default_big_trades_radius_scale")]
104        radius_scale: Option<i32>,
105        #[serde(default = "default_big_trades_buy_color")]
106        buy_color: ThemeColor,
107        #[serde(default = "default_big_trades_sell_color")]
108        sell_color: ThemeColor,
109        #[serde(default = "default_study_visible")]
110        visible: bool,
111    },
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
115pub enum KlineStudy {
116    VolumeProfile {
117        source: StudyDataSource,
118        schedule: VolumeProfileSchedule,
119        #[serde(default)]
120        colors: VolumeProfileColorConfig,
121        #[serde(default = "default_volume_profile_width_percent")]
122        width_percent: u8,
123        opacity_percent: u8,
124        #[serde(default = "default_study_visible")]
125        visible: bool,
126    },
127    TpoProfile {
128        source: StudyDataSource,
129        #[serde(default = "default_tpo_anchor")]
130        anchor: TpoAnchor,
131        #[serde(default = "default_tpo_letter_period")]
132        letter_period: Timeframe,
133        #[serde(default = "default_tpo_value_area_percent")]
134        value_area_percent: u8,
135        #[serde(default)]
136        colors: TpoColorConfig,
137        #[serde(default = "default_tpo_visible")]
138        visible: bool,
139    },
140    BigTrades {
141        #[serde(default = "default_big_trades_threshold")]
142        threshold: u64,
143        #[serde(default = "default_big_trades_radius_scale")]
144        radius_scale: Option<i32>,
145        #[serde(default = "default_big_trades_buy_color")]
146        buy_color: ThemeColor,
147        #[serde(default = "default_big_trades_sell_color")]
148        sell_color: ThemeColor,
149        #[serde(default = "default_study_visible")]
150        visible: bool,
151    },
152}
153
154impl<'de> Deserialize<'de> for KlineStudy {
155    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
156    where
157        D: serde::Deserializer<'de>,
158    {
159        match PersistedKlineStudy::deserialize(deserializer)? {
160            PersistedKlineStudy::VolumeProfile {
161                source,
162                schedule,
163                colors,
164                width_percent,
165                opacity_percent,
166                visible,
167            } => Ok(Self::VolumeProfile {
168                source: source.unwrap_or(StudyDataSource::Trades),
169                schedule,
170                colors,
171                width_percent,
172                opacity_percent: opacity_percent
173                    .unwrap_or_else(|| schedule.default_opacity_percent()),
174                visible,
175            }),
176            PersistedKlineStudy::TpoProfile {
177                source,
178                anchor,
179                letter_period,
180                value_area_percent,
181                colors,
182                visible,
183            } => Ok(Self::TpoProfile {
184                source: source.unwrap_or(StudyDataSource::Kline),
185                anchor,
186                letter_period,
187                value_area_percent: value_area_percent
188                    .clamp(MIN_TPO_VALUE_AREA_PERCENT, MAX_TPO_VALUE_AREA_PERCENT),
189                colors,
190                visible,
191            }),
192            PersistedKlineStudy::BigTrades {
193                threshold,
194                radius_scale,
195                buy_color,
196                sell_color,
197                visible,
198            } => Ok(Self::BigTrades {
199                threshold: threshold.clamp(MIN_BIG_TRADES_THRESHOLD, MAX_BIG_TRADES_THRESHOLD),
200                radius_scale: radius_scale.map(|scale| scale.clamp(10, 200)),
201                buy_color,
202                sell_color,
203                visible,
204            }),
205        }
206    }
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
210pub enum KlineStudyId {
211    VolumeProfileVisibleRange(StudyDataSource),
212    VolumeProfileRecurring(StudyDataSource),
213    TpoProfile(StudyDataSource),
214    BigTrades,
215}
216
217impl KlineStudy {
218    pub const ALL: [Self; 7] = [
219        Self::VolumeProfile {
220            source: StudyDataSource::Kline,
221            schedule: VolumeProfileSchedule::VisibleRange,
222            colors: VolumeProfileColorConfig::defaults(),
223            width_percent: DEFAULT_VOLUME_PROFILE_WIDTH_PERCENT,
224            opacity_percent: DEFAULT_VISIBLE_VOLUME_PROFILE_OPACITY_PERCENT,
225            visible: true,
226        },
227        Self::TpoProfile {
228            source: StudyDataSource::Kline,
229            anchor: TpoAnchor::Daily,
230            letter_period: DEFAULT_TPO_LETTER_PERIOD,
231            value_area_percent: DEFAULT_TPO_VALUE_AREA_PERCENT,
232            colors: TpoColorConfig::defaults(),
233            visible: true,
234        },
235        Self::VolumeProfile {
236            source: StudyDataSource::Kline,
237            schedule: VolumeProfileSchedule::Recurring {
238                interval: DEFAULT_RECURRING_VOLUME_PROFILE_INTERVAL,
239            },
240            colors: VolumeProfileColorConfig::defaults(),
241            width_percent: DEFAULT_VOLUME_PROFILE_WIDTH_PERCENT,
242            opacity_percent: DEFAULT_RECURRING_VOLUME_PROFILE_OPACITY_PERCENT,
243            visible: true,
244        },
245        Self::VolumeProfile {
246            source: StudyDataSource::Trades,
247            schedule: VolumeProfileSchedule::VisibleRange,
248            colors: VolumeProfileColorConfig::defaults(),
249            width_percent: DEFAULT_VOLUME_PROFILE_WIDTH_PERCENT,
250            opacity_percent: DEFAULT_VISIBLE_VOLUME_PROFILE_OPACITY_PERCENT,
251            visible: true,
252        },
253        Self::TpoProfile {
254            source: StudyDataSource::Trades,
255            anchor: TpoAnchor::Daily,
256            letter_period: DEFAULT_TPO_LETTER_PERIOD,
257            value_area_percent: DEFAULT_TPO_VALUE_AREA_PERCENT,
258            colors: TpoColorConfig::defaults(),
259            visible: true,
260        },
261        Self::VolumeProfile {
262            source: StudyDataSource::Trades,
263            schedule: VolumeProfileSchedule::Recurring {
264                interval: DEFAULT_RECURRING_VOLUME_PROFILE_INTERVAL,
265            },
266            colors: VolumeProfileColorConfig::defaults(),
267            width_percent: DEFAULT_VOLUME_PROFILE_WIDTH_PERCENT,
268            opacity_percent: DEFAULT_RECURRING_VOLUME_PROFILE_OPACITY_PERCENT,
269            visible: true,
270        },
271        Self::BigTrades {
272            threshold: DEFAULT_BIG_TRADES_THRESHOLD,
273            radius_scale: default_big_trades_radius_scale(),
274            buy_color: default_big_trades_buy_color(),
275            sell_color: default_big_trades_sell_color(),
276            visible: true,
277        },
278    ];
279
280    pub fn defaults() -> Vec<Self> {
281        Self::ALL.to_vec()
282    }
283
284    fn default_for_type(self) -> Self {
285        Self::ALL
286            .into_iter()
287            .find(|default| default.is_same_type(self))
288            .unwrap_or(self)
289    }
290
291    pub fn default_for_type_for_basis(self, basis: Basis) -> Self {
292        self.default_for_type()
293            .normalized_for_basis(basis)
294            .unwrap_or_else(|| self.default_for_type())
295            .with_visibility(self.visible())
296    }
297
298    pub fn with_default_inputs(self, basis: Basis) -> Self {
299        self.default_for_type_for_basis(basis).with_style_from(self)
300    }
301
302    pub fn with_default_style(self) -> Self {
303        self.with_style_from(self.default_for_type())
304    }
305
306    fn with_style_from(mut self, other: Self) -> Self {
307        match (&mut self, other) {
308            (
309                Self::VolumeProfile {
310                    colors,
311                    width_percent,
312                    opacity_percent,
313                    ..
314                },
315                Self::VolumeProfile {
316                    colors: other_colors,
317                    width_percent: other_width,
318                    opacity_percent: other_opacity,
319                    ..
320                },
321            ) => {
322                *colors = other_colors;
323                *width_percent = other_width;
324                *opacity_percent = other_opacity;
325            }
326            (Self::TpoProfile { colors, .. }, Self::TpoProfile { colors: other, .. }) => {
327                *colors = other;
328            }
329            (
330                Self::BigTrades {
331                    buy_color,
332                    sell_color,
333                    ..
334                },
335                Self::BigTrades {
336                    buy_color: other_buy,
337                    sell_color: other_sell,
338                    ..
339                },
340            ) => {
341                *buy_color = other_buy;
342                *sell_color = other_sell;
343            }
344            _ => unreachable!("study defaults retain the study type"),
345        }
346        self
347    }
348
349    pub const fn visible(self) -> bool {
350        match self {
351            Self::VolumeProfile { visible, .. }
352            | Self::TpoProfile { visible, .. }
353            | Self::BigTrades { visible, .. } => visible,
354        }
355    }
356
357    pub const fn with_visibility(self, visible: bool) -> Self {
358        match self {
359            Self::VolumeProfile {
360                source,
361                schedule,
362                colors,
363                width_percent,
364                opacity_percent,
365                ..
366            } => Self::VolumeProfile {
367                source,
368                schedule,
369                colors,
370                width_percent,
371                opacity_percent,
372                visible,
373            },
374            Self::TpoProfile {
375                source,
376                anchor,
377                letter_period,
378                value_area_percent,
379                colors,
380                ..
381            } => Self::TpoProfile {
382                source,
383                anchor,
384                letter_period,
385                value_area_percent,
386                colors,
387                visible,
388            },
389            Self::BigTrades {
390                threshold,
391                radius_scale,
392                buy_color,
393                sell_color,
394                ..
395            } => Self::BigTrades {
396                threshold,
397                radius_scale,
398                buy_color,
399                sell_color,
400                visible,
401            },
402        }
403    }
404
405    pub const fn id(self) -> KlineStudyId {
406        match self {
407            Self::VolumeProfile {
408                source,
409                schedule: VolumeProfileSchedule::VisibleRange,
410                ..
411            } => KlineStudyId::VolumeProfileVisibleRange(source),
412            Self::VolumeProfile {
413                source,
414                schedule: VolumeProfileSchedule::Recurring { .. },
415                ..
416            } => KlineStudyId::VolumeProfileRecurring(source),
417            Self::TpoProfile { source, .. } => KlineStudyId::TpoProfile(source),
418            Self::BigTrades { .. } => KlineStudyId::BigTrades,
419        }
420    }
421
422    pub fn is_same_type(self, other: Self) -> bool {
423        self.id() == other.id()
424    }
425
426    pub fn requires_reconfiguration(self, previous: Self) -> bool {
427        match (self, previous) {
428            (
429                Self::VolumeProfile {
430                    source,
431                    schedule,
432                    colors: _,
433                    width_percent: _,
434                    opacity_percent: _,
435                    visible: _,
436                },
437                Self::VolumeProfile {
438                    source: previous_source,
439                    schedule: previous_schedule,
440                    colors: _,
441                    width_percent: _,
442                    opacity_percent: _,
443                    visible: _,
444                },
445            ) => (source, schedule) != (previous_source, previous_schedule),
446            (
447                Self::TpoProfile {
448                    source,
449                    anchor,
450                    letter_period,
451                    value_area_percent,
452                    colors: _,
453                    visible: _,
454                },
455                Self::TpoProfile {
456                    source: previous_source,
457                    anchor: previous_anchor,
458                    letter_period: previous_letter_period,
459                    value_area_percent: previous_value_area_percent,
460                    colors: _,
461                    visible: _,
462                },
463            ) => {
464                (source, anchor, letter_period, value_area_percent)
465                    != (
466                        previous_source,
467                        previous_anchor,
468                        previous_letter_period,
469                        previous_value_area_percent,
470                    )
471            }
472            (
473                Self::BigTrades {
474                    threshold,
475                    radius_scale: _,
476                    buy_color: _,
477                    sell_color: _,
478                    visible,
479                },
480                Self::BigTrades {
481                    threshold: previous_threshold,
482                    radius_scale: _,
483                    buy_color: _,
484                    sell_color: _,
485                    visible: previous_visible,
486                },
487            ) => (threshold, visible) != (previous_threshold, previous_visible),
488            _ => true,
489        }
490    }
491
492    pub fn is_available(self, basis: Basis) -> bool {
493        match self {
494            Self::VolumeProfile { schedule, .. } => schedule.is_available(basis),
495            Self::TpoProfile { .. } => matches!(basis, Basis::Time(_)),
496            Self::BigTrades { .. } => true,
497        }
498    }
499
500    pub fn is_interval_available(self, basis: Basis) -> bool {
501        match self {
502            Self::VolumeProfile { schedule, .. } => schedule.is_interval_available(basis),
503            Self::TpoProfile { .. } | Self::BigTrades { .. } => self.is_available(basis),
504        }
505    }
506
507    pub const fn requires_trade_data(self) -> bool {
508        self.source().requires_trade_data()
509    }
510
511    pub const fn source(self) -> StudyDataSource {
512        match self {
513            Self::VolumeProfile { source, .. } | Self::TpoProfile { source, .. } => source,
514            Self::BigTrades { .. } => StudyDataSource::Trades,
515        }
516    }
517
518    /// How the study relates to the pane's exclusive profile source.
519    pub const fn source_constraint(self) -> SourceConstraint {
520        match self {
521            Self::VolumeProfile { source, .. } | Self::TpoProfile { source, .. } => {
522                SourceConstraint::Exclusive(source)
523            }
524            Self::BigTrades { .. } => SourceConstraint::Independent,
525        }
526    }
527
528    pub fn same_family(self, other: Self) -> bool {
529        matches!(
530            (self, other),
531            (
532                Self::VolumeProfile {
533                    schedule: VolumeProfileSchedule::VisibleRange,
534                    ..
535                },
536                Self::VolumeProfile {
537                    schedule: VolumeProfileSchedule::VisibleRange,
538                    ..
539                },
540            ) | (
541                Self::VolumeProfile {
542                    schedule: VolumeProfileSchedule::Recurring { .. },
543                    ..
544                },
545                Self::VolumeProfile {
546                    schedule: VolumeProfileSchedule::Recurring { .. },
547                    ..
548                },
549            ) | (Self::TpoProfile { .. }, Self::TpoProfile { .. })
550                | (Self::BigTrades { .. }, Self::BigTrades { .. })
551        )
552    }
553
554    pub fn normalized_for_basis(self, basis: Basis) -> Option<Self> {
555        if self.is_interval_available(basis) {
556            return Some(self);
557        }
558
559        match self {
560            Self::VolumeProfile {
561                source,
562                schedule: VolumeProfileSchedule::Recurring { .. },
563                colors,
564                width_percent,
565                opacity_percent,
566                visible,
567            } => VolumeProfileSchedule::minimum_recurring_interval(basis).map(|interval| {
568                Self::VolumeProfile {
569                    source,
570                    schedule: VolumeProfileSchedule::Recurring { interval },
571                    colors,
572                    width_percent,
573                    opacity_percent,
574                    visible,
575                }
576            }),
577            Self::VolumeProfile { .. } => None,
578            Self::TpoProfile { .. } | Self::BigTrades { .. } => {
579                self.is_available(basis).then_some(self)
580            }
581        }
582    }
583
584    pub const fn volume_profile_schedule(self) -> Option<VolumeProfileSchedule> {
585        match self {
586            Self::VolumeProfile { schedule, .. } => Some(schedule),
587            Self::TpoProfile { .. } | Self::BigTrades { .. } => None,
588        }
589    }
590
591    pub const fn volume_profile_colors(self) -> Option<VolumeProfileColorConfig> {
592        match self {
593            Self::VolumeProfile { colors, .. } => Some(colors),
594            Self::TpoProfile { .. } | Self::BigTrades { .. } => None,
595        }
596    }
597
598    const fn volume_profile_width_percent(self) -> Option<u8> {
599        match self {
600            Self::VolumeProfile { width_percent, .. } => Some(width_percent),
601            Self::TpoProfile { .. } | Self::BigTrades { .. } => None,
602        }
603    }
604
605    const fn volume_profile_opacity_percent(self) -> Option<u8> {
606        match self {
607            Self::VolumeProfile {
608                opacity_percent, ..
609            } => Some(opacity_percent),
610            Self::TpoProfile { .. } | Self::BigTrades { .. } => None,
611        }
612    }
613
614    pub fn volume_profile_opacity_ratio(self) -> Option<f32> {
615        Some(
616            f32::from(self.volume_profile_opacity_percent()?.clamp(
617                MIN_VOLUME_PROFILE_OPACITY_PERCENT,
618                MAX_VOLUME_PROFILE_OPACITY_PERCENT,
619            )) / f32::from(MAX_VOLUME_PROFILE_OPACITY_PERCENT),
620        )
621    }
622
623    pub fn volume_profile_opacity_scale(self) -> Option<f32> {
624        let opacity = f32::from(self.volume_profile_opacity_percent()?.clamp(
625            MIN_VOLUME_PROFILE_OPACITY_PERCENT,
626            MAX_VOLUME_PROFILE_OPACITY_PERCENT,
627        ));
628        Some(opacity / f32::from(self.volume_profile_schedule()?.default_opacity_percent()))
629    }
630
631    pub fn volume_profile_width_ratio(self) -> Option<f32> {
632        Some(
633            f32::from(self.volume_profile_width_percent()?.clamp(
634                MIN_VOLUME_PROFILE_WIDTH_PERCENT,
635                MAX_VOLUME_PROFILE_WIDTH_PERCENT,
636            )) / f32::from(MAX_VOLUME_PROFILE_WIDTH_PERCENT),
637        )
638    }
639
640    pub const fn is_tpo_profile(self) -> bool {
641        matches!(self, Self::TpoProfile { .. })
642    }
643
644    pub const fn big_trades(self) -> Option<BigTradesConfig> {
645        match self {
646            Self::BigTrades {
647                threshold,
648                radius_scale,
649                buy_color,
650                sell_color,
651                ..
652            } => Some(BigTradesConfig {
653                threshold,
654                radius_scale,
655                buy_color,
656                sell_color,
657            }),
658            _ => None,
659        }
660    }
661
662    pub const fn tpo_anchor(self) -> Option<TpoAnchor> {
663        match self {
664            Self::TpoProfile { anchor, .. } => Some(anchor),
665            Self::VolumeProfile { .. } | Self::BigTrades { .. } => None,
666        }
667    }
668
669    pub const fn tpo_colors(self) -> Option<TpoColorConfig> {
670        match self {
671            Self::TpoProfile { colors, .. } => Some(colors),
672            Self::VolumeProfile { .. } | Self::BigTrades { .. } => None,
673        }
674    }
675
676    pub const fn recurring_profile_interval(self) -> Option<Timeframe> {
677        match self {
678            Self::VolumeProfile {
679                schedule: VolumeProfileSchedule::Recurring { interval },
680                ..
681            } => Some(interval),
682            Self::VolumeProfile {
683                schedule: VolumeProfileSchedule::VisibleRange,
684                ..
685            } => None,
686            Self::TpoProfile { .. } | Self::BigTrades { .. } => None,
687        }
688    }
689}
690
691impl Display for KlineStudy {
692    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
693        match self {
694            Self::VolumeProfile {
695                source, schedule, ..
696            } => {
697                write!(formatter, "{source} Volume Profile ({schedule})")
698            }
699            Self::TpoProfile {
700                source,
701                anchor,
702                letter_period,
703                ..
704            } => {
705                write!(
706                    formatter,
707                    "{source} TPO Profile ({anchor}, {letter_period})"
708                )
709            }
710            Self::BigTrades { threshold, .. } => {
711                write!(
712                    formatter,
713                    "Big Trades ({})",
714                    big_trades_threshold_label(*threshold)
715                )
716            }
717        }
718    }
719}