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