1use super::FootprintBar;
4use crate::model::Bar;
5use serde::{Deserialize, Serialize};
6use std::ops::{Range, RangeInclusive};
7use std::sync::Arc;
8
9#[derive(Clone, Debug)]
11pub struct StudyProfile {
12 pub bars: Range<usize>,
15 pub profile: Arc<VolumeProfile>,
17 pub config: VolumeProfileConfig,
19 pub color: [u8; 4],
21 pub right_aligned: bool,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
27pub enum ProfileDisplay {
28 #[default]
30 Total,
31 UpDown,
33 Delta,
35}
36
37fn profile_up_color() -> [u8; 4] {
38 [38, 166, 154, 180]
39}
40fn profile_down_color() -> [u8; 4] {
41 [239, 83, 80, 180]
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
46#[serde(try_from = "VolumeProfileFields")]
47pub struct VolumeProfileConfig {
48 row_size: Option<f64>,
49 rows: usize,
50 value_area: f64,
51 pub display: ProfileDisplay,
53 pub up_color: [u8; 4],
55 pub down_color: [u8; 4],
57 pub value_area_colors: Option<[[u8; 4]; 2]>,
59 pub show_value_area_lines: bool,
61}
62
63#[derive(Deserialize)]
65struct VolumeProfileFields {
66 #[serde(default)]
67 row_size: Option<f64>,
68 rows: usize,
69 value_area: f64,
70 #[serde(default)]
71 display: ProfileDisplay,
72 #[serde(default = "profile_up_color")]
73 up_color: [u8; 4],
74 #[serde(default = "profile_down_color")]
75 down_color: [u8; 4],
76 #[serde(default)]
77 value_area_colors: Option<[[u8; 4]; 2]>,
78 #[serde(default)]
79 show_value_area_lines: bool,
80}
81
82impl TryFrom<VolumeProfileFields> for VolumeProfileConfig {
83 type Error = &'static str;
84
85 fn try_from(fields: VolumeProfileFields) -> Result<Self, Self::Error> {
86 let mut config = Self::new(fields.rows, fields.value_area)
87 .ok_or("volume profile settings out of bounds")?;
88 config.display = fields.display;
89 config.up_color = fields.up_color;
90 config.down_color = fields.down_color;
91 config.value_area_colors = fields.value_area_colors;
92 config.show_value_area_lines = fields.show_value_area_lines;
93 config
94 .with_row_size(fields.row_size)
95 .ok_or("volume profile row height must be finite and positive")
96 }
97}
98
99impl VolumeProfileConfig {
100 pub const ROWS: RangeInclusive<usize> = 1..=1000;
102
103 pub fn new(rows: usize, value_area: f64) -> Option<Self> {
107 (Self::ROWS.contains(&rows) && value_area > 0.0 && value_area <= 1.0).then_some(Self {
108 rows,
109 value_area,
110 ..Self::default()
111 })
112 }
113
114 pub fn with_row_size(mut self, height: Option<f64>) -> Option<Self> {
118 if height.is_some_and(|h| !h.is_finite() || h <= 0.0) {
119 return None;
120 }
121 self.row_size = height;
122 Some(self)
123 }
124
125 pub fn row_size(&self) -> Option<f64> {
127 self.row_size
128 }
129
130 pub fn rows(&self) -> usize {
132 self.rows
133 }
134
135 pub fn value_area(&self) -> f64 {
137 self.value_area
138 }
139
140 pub fn drawing_default() -> Self {
144 Self {
145 display: ProfileDisplay::UpDown,
146 up_color: [124, 210, 225, 180],
147 down_color: [222, 119, 155, 180],
148 ..Self::default()
149 }
150 }
151}
152
153impl Default for VolumeProfileConfig {
154 fn default() -> Self {
156 Self {
157 row_size: None,
158 rows: 24,
159 value_area: 0.7,
160 display: ProfileDisplay::Total,
161 up_color: profile_up_color(),
162 down_color: profile_down_color(),
163 value_area_colors: None,
164 show_value_area_lines: false,
165 }
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum ProfileSource {
172 Estimated,
175 Executions {
180 complete: bool,
182 },
183 Options,
185}
186
187#[derive(Clone, Debug, PartialEq)]
189struct Executions {
190 first_level: i64,
191 bar: FootprintBar,
192 complete: bool,
193}
194
195#[derive(Clone, Debug, PartialEq)]
197pub struct VolumeProfile {
198 pub(crate) low: f64,
200 pub(crate) high: f64,
202 pub(crate) row_height: f64,
203 fixed_height: bool,
204 flat_price: Option<f64>,
206 pub(crate) volume: Vec<f64>,
208 pub(crate) up: Vec<f64>,
209 pub(crate) down: Vec<f64>,
210 executions: Option<Executions>,
211 options: bool,
213}
214
215fn valid_bar(bar: &Bar) -> bool {
216 [bar.open, bar.high, bar.low, bar.close, bar.volume]
217 .iter()
218 .all(|v| v.is_finite())
219 && (bar.high - bar.low).is_finite()
220 && bar.high >= bar.low
221 && bar.volume >= 0.0
222}
223
224impl VolumeProfile {
225 pub fn from_options(data: &[crate::data::OptionsVolume], row_size: f64) -> Option<Self> {
231 if data.is_empty()
232 || !row_size.is_finite()
233 || row_size <= 0.0
234 || data.iter().any(|v| {
235 !v.strike.is_finite()
236 || !v.calls.is_finite()
237 || !v.puts.is_finite()
238 || v.calls < 0.0
239 || v.puts < 0.0
240 })
241 {
242 return None;
243 }
244 let key = |strike| grid_floor(strike / row_size + 0.5);
245 let first = data
246 .iter()
247 .map(|v| key(v.strike))
248 .fold(f64::INFINITY, f64::min);
249 let last = data
250 .iter()
251 .map(|v| key(v.strike))
252 .fold(f64::NEG_INFINITY, f64::max);
253 let count = last - first + 1.0;
254 if !first.is_finite()
255 || !last.is_finite()
256 || first.abs() >= 2_f64.powi(52)
257 || last.abs() >= 2_f64.powi(52)
258 || !(1.0..=10_000.0).contains(&count)
259 {
260 return None;
261 }
262 let low = (first - 0.5) * row_size;
263 let high = low + count * row_size;
264 if !low.is_finite() || !high.is_finite() || high <= low {
265 return None;
266 }
267 let mut profile = Self {
268 low,
269 high,
270 row_height: row_size,
271 fixed_height: false,
272 flat_price: None,
273 volume: vec![0.0; count as usize],
274 up: vec![0.0; count as usize],
275 down: vec![0.0; count as usize],
276 executions: None,
277 options: true,
278 };
279 let mut total = 0.0;
280 for v in data {
281 let row = (key(v.strike) - first) as usize;
282 profile.up[row] += v.calls;
283 profile.down[row] += v.puts;
284 profile.volume[row] = profile.up[row] + profile.down[row];
285 total += v.calls + v.puts;
286 }
287 total.is_finite().then_some(profile)
288 }
289
290 pub fn from_bars(bars: &[Bar], config: VolumeProfileConfig) -> Option<Self> {
293 Self::of(bars, config.rows(), config.row_size())
294 }
295
296 pub fn from_executions(
306 bar: &FootprintBar,
307 step: f64,
308 tick_size: f64,
309 complete: bool,
310 ) -> Option<Self> {
311 let (&first, &last) = (bar.levels.keys().next()?, bar.levels.keys().next_back()?);
312 let count = last as i128 - first as i128 + 1;
313 let half_tick = tick_size * 0.5;
314 let (low, high) = (
315 first as f64 * step - half_tick,
316 (last as f64 + 1.0) * step - half_tick,
317 );
318 if bar.volume() <= 0.0
319 || !step.is_finite()
320 || step <= 0.0
321 || !tick_size.is_finite()
322 || !(1..=10_000).contains(&count)
323 || !low.is_finite()
324 || !high.is_finite()
325 {
326 return None;
327 }
328 let mut profile = Self {
329 low,
330 high,
331 row_height: step,
332 fixed_height: true,
333 flat_price: None,
334 volume: vec![0.0; count as usize],
335 up: vec![0.0; count as usize],
336 down: vec![0.0; count as usize],
337 executions: Some(Executions {
338 first_level: first,
339 bar: bar.clone(),
340 complete,
341 }),
342 options: false,
343 };
344 for (&level, row) in &bar.levels {
345 let i = (level - first) as usize;
346 profile.volume[i] = row.volume();
347 profile.up[i] = row.ask;
348 profile.down[i] = row.bid;
349 }
350 Some(profile)
351 }
352
353 pub fn source(&self) -> ProfileSource {
355 if self.options {
356 return ProfileSource::Options;
357 }
358 self.executions
359 .as_ref()
360 .map_or(ProfileSource::Estimated, |e| ProfileSource::Executions {
361 complete: e.complete,
362 })
363 }
364
365 pub fn low(&self) -> f64 {
367 self.low
368 }
369 pub fn high(&self) -> f64 {
371 self.low + self.volume.len() as f64 * self.row_height
372 }
373 pub fn row_height(&self) -> f64 {
375 self.row_height
376 }
377 pub fn volume(&self) -> &[f64] {
379 &self.volume
380 }
381 pub fn up(&self) -> &[f64] {
383 &self.up
384 }
385 pub fn down(&self) -> &[f64] {
387 &self.down
388 }
389
390 pub(crate) fn of(bars: &[Bar], rows: usize, row_size: Option<f64>) -> Option<Self> {
394 let low = bars
395 .iter()
396 .filter(|b| valid_bar(b))
397 .map(|b| b.low)
398 .fold(f64::INFINITY, f64::min);
399 let high = bars
400 .iter()
401 .filter(|b| valid_bar(b))
402 .map(|b| b.high)
403 .fold(f64::NEG_INFINITY, f64::max);
404 if rows == 0 || rows > 10_000 {
405 return None;
406 }
407 let flat_price = (row_size.is_none() && low == high).then_some(low);
408 let (low, high, row_height, rows) = if let Some(height) = row_size {
409 let (low, high, rows) = fixed_grid(low, high, height)?;
410 (low, high, height, rows)
411 } else if low == high && low.is_finite() {
412 let height = (low.abs() * 1e-6).max(1e-6);
413 (low - height * 0.5, high + height * 0.5, height, 1)
414 } else {
415 (low, high, (high - low) / rows as f64, rows)
416 };
417 if !low.is_finite() || !high.is_finite() || !row_height.is_finite() || row_height <= 0.0 {
418 return None;
419 }
420 let mut profile = Self {
421 low,
422 high,
423 row_height,
424 fixed_height: row_size.is_some(),
425 flat_price,
426 volume: vec![0.0; rows],
427 up: vec![0.0; rows],
428 down: vec![0.0; rows],
429 executions: None,
430 options: false,
431 };
432 bars.iter().for_each(|bar| profile.add(bar));
433 Some(profile)
434 }
435
436 #[cfg(any(feature = "studies", feature = "drawings"))]
440 pub(crate) fn of_finer(
441 bars: &[Bar],
442 end: chrono::DateTime<chrono::Utc>,
443 finer: &[Bar],
444 rows: usize,
445 row_size: Option<f64>,
446 ) -> (Option<Self>, usize) {
447 let mut inputs = Vec::new();
448 let mut covered = 0;
449 for (i, bar) in bars.iter().enumerate() {
450 let next = bars.get(i + 1).map_or(end, |next| next.time);
451 let inside = &finer[finer.partition_point(|f| f.time < bar.time)
452 ..finer.partition_point(|f| f.time < next)];
453 if inside.is_empty() {
454 inputs.push(bar.clone());
455 } else {
456 covered += 1;
457 inputs.extend_from_slice(inside);
458 }
459 }
460 (Self::of(&inputs, rows, row_size), covered)
461 }
462
463 pub fn with_bar(&self, bar: &Bar) -> Option<Self> {
467 let mut profile = self.clone();
468 if !valid_bar(bar) {
469 return Some(profile);
470 }
471 if self.fixed_height {
472 let (low, high, _) = fixed_grid(bar.low, bar.high, self.row_height)?;
473 let low = low.min(self.low);
474 let high = high.max(self.high);
475 let count = ((high - low) / self.row_height).round();
476 if count > 10_000.0 {
477 return None;
478 }
479 let prepend = ((self.low - low) / self.row_height).round() as usize;
480 for rows in [&mut profile.volume, &mut profile.up, &mut profile.down] {
481 let old_len = rows.len();
482 rows.resize(count as usize, 0.0);
483 if prepend > 0 {
484 rows.copy_within(..old_len, prepend);
485 rows[..prepend].fill(0.0);
486 }
487 }
488 profile.low = low;
489 profile.high = high;
490 } else if self
491 .flat_price
492 .is_some_and(|price| bar.low != price || bar.high != price)
493 || bar.low < self.low
494 || bar.high > self.high
495 {
496 return None;
497 }
498 profile.add(bar);
499 Some(profile)
500 }
501
502 fn add(&mut self, bar: &Bar) {
505 if !valid_bar(bar) {
506 return;
507 }
508 let (low, row_height) = (self.low, self.row_height);
509 let direction = if bar.close >= bar.open {
510 &mut self.up
511 } else {
512 &mut self.down
513 };
514 let top_row = self.volume.len() - 1;
515 let row_of = |price: f64| (grid_floor((price - low) / row_height) as usize).min(top_row);
516 if bar.high > bar.low {
517 let (first, last) = (row_of(bar.low), row_of(bar.high));
518 for (row, row_volume) in self
519 .volume
520 .iter_mut()
521 .enumerate()
522 .take(last + 1)
523 .skip(first)
524 {
525 let row_low = low + row as f64 * row_height;
526 let overlap = bar.high.min(row_low + row_height) - bar.low.max(row_low);
527 let amount = bar.volume * (overlap.max(0.0) / (bar.high - bar.low));
528 *row_volume += amount;
529 direction[row] += amount;
530 }
531 } else {
532 self.volume[row_of(bar.low)] += bar.volume;
533 direction[row_of(bar.low)] += bar.volume;
534 }
535 }
536
537 pub fn poc_and_value_area(&self, share: f64) -> Option<(usize, (usize, usize))> {
542 if !share.is_finite() || share <= 0.0 || share > 1.0 {
543 return None;
544 }
545 let volume = &self.volume;
546 let total: f64 = volume.iter().sum();
547 if !total.is_finite() || total <= 0.0 {
548 return None;
549 }
550 if let Some(e) = &self.executions {
551 let row = |level: i64| (level - e.first_level) as usize;
552 let (low, high) = e.bar.value_area(share)?;
553 return Some((row(e.bar.poc()?), (row(low), row(high))));
554 }
555 let poc = (0..volume.len())
556 .max_by(|&a, &b| volume[a].total_cmp(&volume[b]).then_with(|| b.cmp(&a)))?;
557 Some((poc, value_area(volume, poc, total * share)))
558 }
559}
560
561fn grid_floor(value: f64) -> f64 {
563 let nearest = value.round();
564 if (value - nearest).abs() <= 4.0 * f64::EPSILON * value.abs() {
565 nearest
566 } else {
567 value.floor()
568 }
569}
570
571fn fixed_grid(low: f64, high: f64, height: f64) -> Option<(f64, f64, usize)> {
573 if !height.is_finite() || height <= 0.0 {
574 return None;
575 }
576 let first = grid_floor(low / height);
577 let last = grid_floor(high / height);
578 let count = last - first + 1.0;
579 if !first.is_finite()
580 || !last.is_finite()
581 || first.abs() >= 2_f64.powi(52)
582 || last.abs() >= 2_f64.powi(52)
583 || !(1.0..=10_000.0).contains(&count)
584 {
585 return None;
586 }
587 let bounds = (first * height, (last + 1.0) * height);
588 (bounds.0.is_finite() && bounds.1.is_finite() && bounds.1 > bounds.0).then_some((
589 bounds.0,
590 bounds.1,
591 count as usize,
592 ))
593}
594
595fn value_area(volume: &[f64], poc: usize, target: f64) -> (usize, usize) {
599 let (mut low, mut high, mut sum) = (poc, poc, volume[poc]);
600 while sum < target && (low > 0 || high + 1 < volume.len()) {
601 let above: f64 = volume[high + 1..].iter().take(2).sum();
602 let below: f64 = volume[..low].iter().rev().take(2).sum();
603 if low > 0 && high + 1 < volume.len() && above == below {
604 low -= 1;
605 high += 1;
606 sum += volume[low] + volume[high];
607 continue;
608 }
609 let up = high + 1 < volume.len() && (low == 0 || above >= below);
610 for _ in 0..2 {
611 if up && high + 1 < volume.len() {
612 high += 1;
613 sum += volume[high];
614 } else if !up && low > 0 {
615 low -= 1;
616 sum += volume[low];
617 }
618 if sum >= target {
619 break;
620 }
621 }
622 }
623 (low, high)
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 fn sample_bars() -> Vec<Bar> {
630 [
631 (10.0, 12.0, 9.0, 11.0, 100.0),
632 (11.0, 13.0, 10.0, 12.0, 200.0),
633 (12.0, 12.5, 11.0, 11.0, 150.0),
634 (11.5, 14.0, 11.0, 13.5, 300.0),
635 (13.5, 15.0, 13.0, 14.0, 250.0),
636 (14.0, 14.5, 12.0, 12.5, 400.0),
637 (13.0, 13.0, 13.0, 13.0, 50.0),
638 ]
639 .into_iter()
640 .enumerate()
641 .map(|(i, (o, h, l, c, v))| {
642 let time = chrono::DateTime::from_timestamp(i as i64 * 60, 0).unwrap();
643 Bar::new(time, o, h, l, c, v)
644 })
645 .collect()
646 }
647 fn assert_close(got: f64, want: f64) {
648 assert!((got - want).abs() < 1e-9, "{got} != {want}");
649 }
650 #[test]
656 fn volume_profile_matches_reference() {
657 let profile = VolumeProfile::of(&sample_bars(), 4, None).unwrap();
658 for (price, representable) in [(13.0, true), (f64::MAX, false), (-f64::MAX, false)] {
659 let flat = Bar::new(
660 chrono::DateTime::UNIX_EPOCH,
661 price,
662 price,
663 price,
664 price,
665 50.0,
666 );
667 let flat_profile = VolumeProfile::of(&[flat], 4, None);
668 assert_eq!(flat_profile.is_some(), representable, "flat price {price}");
669 if let Some(flat_profile) = flat_profile {
670 assert_eq!(flat_profile.volume(), [50.0]);
671 assert_eq!(flat_profile.up(), [50.0]);
672 }
673 }
674 assert_close(profile.low, 9.0);
675 assert_close(profile.row_height, 1.5);
676 for (got, want) in
677 profile
678 .volume
679 .iter()
680 .zip([83.33333333333334, 350.0, 619.1666666666667, 397.5])
681 {
682 assert_close(*got, want);
683 }
684 for (row, (up, down)) in [
686 (250.0 / 3.0, 0.0),
687 (250.0, 100.0),
688 (3950.0 / 12.0, 290.0),
689 (237.5, 160.0),
690 ]
691 .into_iter()
692 .enumerate()
693 {
694 assert_close(profile.up[row], up);
695 assert_close(profile.down[row], down);
696 assert_close(profile.up[row] + profile.down[row], profile.volume[row]);
697 }
698 for (share, value_area) in [(0.5, (1, 2)), (0.7, (0, 2)), (1.0, (0, 3))] {
699 assert_eq!(
700 profile.poc_and_value_area(share),
701 Some((2, value_area)),
702 "share {share}"
703 );
704 }
705 }
706
707 #[test]
710 fn estimated_and_executed_profiles_agree_on_ties() {
711 use crate::data::Footprint;
712 use crate::model::{BarClock, OrderSide, Tick, Timeframe};
713 let time = chrono::DateTime::UNIX_EPOCH;
714 let single = vec![Bar::new(time, 100.0, 124.0, 100.0, 124.0, 24.0)];
716 let peak_rows = vec![1.0, 1.0, 2.0, 1.0, 1.0];
717 let peak: Vec<_> = peak_rows
718 .iter()
719 .enumerate()
720 .map(|(i, &volume)| {
721 let low = 100.0 + i as f64;
722 Bar::new(time, low, low + 1.0, low, low + 1.0, volume)
723 })
724 .collect();
725 for (bars, volumes, share, expected) in [
726 (single, vec![1.0; 24], 0.7, (0, (0, 16))),
727 (peak, peak_rows, 0.6, (2, (1, 3))),
728 ] {
729 let estimated = VolumeProfile::of(&bars, volumes.len(), None).unwrap();
730 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
731 for (i, &volume) in volumes.iter().enumerate() {
732 flow.push(&Tick::new(
733 time,
734 100.0 + i as f64,
735 volume,
736 Some(OrderSide::Buy),
737 ))
738 .unwrap();
739 }
740 let executed =
741 VolumeProfile::from_executions(flow.bar_at(time).unwrap(), 1.0, 1.0, true).unwrap();
742 assert_eq!(estimated.volume(), executed.volume());
743 assert_eq!(
744 (executed.low(), executed.high()),
745 (99.5, 99.5 + volumes.len() as f64)
746 );
747 assert_eq!(
749 (
750 estimated.poc_and_value_area(share),
751 executed.poc_and_value_area(share)
752 ),
753 (Some(expected), Some(expected)),
754 "rows {volumes:?}, share {share}"
755 );
756 }
757 }
758
759 #[test]
761 fn with_bar_rebuilds_when_a_flat_span_gains_range() {
762 let time = chrono::DateTime::UNIX_EPOCH;
763 for price in [0.000_02, 0.5, 2.0, 100.0] {
764 for rows in [1, 24] {
765 let flat = Bar::new(time, price, price, price, price, 100.0);
766 let profile = VolumeProfile::of(std::slice::from_ref(&flat), rows, None).unwrap();
767 assert_eq!(
768 profile.with_bar(&flat),
769 VolumeProfile::of(&[flat.clone(), flat.clone()], rows, None)
770 );
771 let range = (price.abs() * 1e-6).max(1e-6) * 0.4;
772 let next = Bar::new(time, price, price + range, price - range, price, 100.0);
773 assert!(next.low > profile.low() && next.high < profile.high());
774 assert!(
775 profile.with_bar(&next).is_none(),
776 "price {price}, rows {rows}"
777 );
778 let moved = Bar::new(time, price, price.next_up(), price, price, 100.0);
780 assert!(profile.with_bar(&moved).is_none());
781 let bars = [flat.clone(), next.clone()];
782 let rebuilt = VolumeProfile::of(&bars, rows, None).unwrap();
783 assert_eq!(rebuilt.volume().len(), rows);
784 assert_eq!((rebuilt.low(), rebuilt.high()), (next.low, next.high));
785 assert_close(rebuilt.row_height(), 2.0 * range / rows as f64);
786 assert!((rebuilt.volume()[rows / 2] - (100.0 + 100.0 / rows as f64)).abs() < 1e-6);
788 let third = Bar::new(
789 time,
790 price,
791 price + range / 2.0,
792 price - range / 2.0,
793 price,
794 100.0,
795 );
796 let updated = rebuilt.with_bar(&third).unwrap();
797 assert_eq!(
798 Some(updated.clone()),
799 VolumeProfile::of(&[flat, next, third], rows, None)
800 );
801 assert!(
802 (updated.volume()[rows / 2] - (100.0 + if rows == 1 { 200.0 } else { 12.5 }))
803 .abs()
804 < 1e-6
805 );
806 }
807 }
808 }
809
810 #[test]
812 fn fixed_profile_grows_without_rebinning() {
813 let time = chrono::DateTime::UNIX_EPOCH;
814 let first = Bar::new(time, -0.5, 1.0, -1.0, 0.5, 20.0);
815 let mut bars = vec![first];
816 let mut profile = VolumeProfile::of(&bars, 24, Some(1.0)).unwrap();
817 assert_eq!(profile.volume, [10.0, 10.0, 0.0]);
818 for (low, high, volume, expected) in [
819 (-2.0, -1.0, 6.0, vec![6.0, 10.0, 10.0, 0.0]),
820 (1.0, 2.0, 8.0, vec![6.0, 10.0, 10.0, 8.0, 0.0]),
821 (2.0, 2.0, 3.0, vec![6.0, 10.0, 10.0, 8.0, 3.0]),
822 ] {
823 let bar = Bar::new(time, low, high, low, high, volume);
824 profile = profile.with_bar(&bar).expect("extend fixed grid");
826 bars.push(bar);
827 assert_eq!(profile.volume, expected);
828 assert_eq!(
829 Some(profile.clone()),
830 VolumeProfile::of(&bars, 24, Some(1.0))
831 );
832 }
833 assert!(VolumeProfile::of(&bars, 24, Some(1e-12)).is_none());
834 let boundary = Bar::new(time, 0.3, 0.3, 0.3, 0.3, 7.0);
835 let decimal = VolumeProfile::of(&[boundary], 24, Some(0.1)).unwrap();
836 assert_close(decimal.low(), 0.3);
837 assert_eq!(decimal.volume(), [7.0]);
838 }
839
840 #[test]
843 fn options_profile_groups_strikes_and_scales_by_largest_total() {
844 use crate::data::OptionsVolume;
845 let data: Vec<_> = [
846 (99.0, 20.0, 10.0),
847 (100.0, 10.0, 5.0),
848 (100.0, 20.0, 15.0),
849 (101.0, 40.0, 20.0),
850 (102.0, 10.0, 40.0),
851 (103.0, 20.0, 50.0),
852 (104.0, 30.0, 50.0),
853 (107.0, 75.0, 25.0),
854 ]
855 .into_iter()
856 .map(|(strike, calls, puts)| OptionsVolume {
857 strike,
858 calls,
859 puts,
860 })
861 .collect();
862 let profile = VolumeProfile::from_options(&data, 4.0).unwrap();
863 assert_eq!(profile.source(), ProfileSource::Options);
864 assert_eq!(
865 (profile.low(), profile.high(), profile.row_height()),
866 (98.0, 110.0, 4.0)
867 );
868 let max = profile.volume().iter().copied().fold(0.0, f64::max);
869 for (row, (calls, puts, share)) in
870 [(90.0, 50.0, 0.7), (60.0, 140.0, 1.0), (75.0, 25.0, 0.5)]
871 .into_iter()
872 .enumerate()
873 {
874 assert_close(profile.up()[row], calls);
875 assert_close(profile.down()[row], puts);
876 assert_close(profile.volume()[row] / max, share);
877 }
878 #[cfg(feature = "widget")]
879 for right_aligned in [false, true] {
880 for direction in [-1.0, 1.0] {
881 let config = VolumeProfileConfig {
882 display: ProfileDisplay::UpDown,
883 ..VolumeProfileConfig::default()
884 };
885 let rgba = |c: [u8; 4]| {
886 egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3])
887 .gamma_multiply(0.35)
888 };
889 let mut output = egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
890 let painter = egui::Painter::new(
891 ui.ctx().clone(),
892 egui::LayerId::background(),
893 egui::Rect::EVERYTHING,
894 );
895 crate::painting::volume_profile::paint_profile(
896 &painter,
897 &profile,
898 config,
899 [0, 0, 255, 255],
900 100.0..300.0,
901 crate::painting::volume_profile::ProfileStyle::Study { right_aligned },
902 &|price| price as f32 * direction,
903 );
904 });
905 let shapes = std::mem::take(&mut output.shapes);
906 output.drop_without_applying_deltas();
907 let rows: Vec<_> = shapes
908 .iter()
909 .filter_map(|shape| match &shape.shape {
910 egui::Shape::Rect(r)
911 if r.fill == rgba(config.up_color)
912 || r.fill == rgba(config.down_color) =>
913 {
914 Some(r)
915 }
916 _ => None,
917 })
918 .collect();
919 assert_eq!(rows.len(), 6);
920 for (row, (call_width, put_width, strike)) in [
923 (81.0, 45.0, 100.0),
924 (54.0, 126.0, 104.0),
925 (67.5, 22.5, 108.0),
926 ]
927 .into_iter()
928 .enumerate()
929 {
930 let (calls, puts) = (rows[2 * row], rows[2 * row + 1]);
931 assert!((calls.rect.width() - call_width).abs() < 1e-4);
932 assert!((puts.rect.width() - put_width).abs() < 1e-4);
933 assert!((calls.rect.top() - (strike * direction - 2.0)).abs() < 1e-4);
934 assert_eq!(calls.fill, rgba(config.up_color));
935 assert_eq!(puts.fill, rgba(config.down_color));
936 if right_aligned {
937 assert!((calls.rect.right() - 300.0).abs() < 1e-4);
938 assert!((puts.rect.right() - calls.rect.left()).abs() < 1e-4);
939 } else {
940 assert!((calls.rect.left() - 100.0).abs() < 1e-4);
941 assert!((puts.rect.left() - calls.rect.right()).abs() < 1e-4);
942 }
943 }
944 }
945 }
946 for (strike, size, center) in [
948 (100.0, 1.0, 100.0),
949 (0.3, 0.1, 0.3),
950 (0.35, 0.1, 0.4),
951 (-0.15, 0.1, -0.1),
952 ] {
953 let point = OptionsVolume {
954 strike,
955 calls: 3.0,
956 puts: 7.0,
957 };
958 let p = VolumeProfile::from_options(&[point], size).unwrap();
959 assert_close(p.low() + p.row_height() / 2.0, center);
960 assert_eq!(p.volume(), [10.0]);
961 }
962 for bad in [f64::NAN, f64::INFINITY, -1.0] {
963 let invalid = OptionsVolume {
964 strike: 100.0,
965 calls: bad,
966 puts: 0.0,
967 };
968 assert!(VolumeProfile::from_options(&[invalid], 1.0).is_none());
969 assert!(VolumeProfile::from_options(&data, bad).is_none());
970 }
971 assert!(VolumeProfile::from_options(&data, 0.0).is_none());
972 assert!(VolumeProfile::from_options(&data, 1e-12).is_none());
973 assert!(VolumeProfile::from_options(&[], 4.0).is_none());
974 assert!(
975 VolumeProfile::from_options(
976 &[-1e308, 1e308].map(|strike| OptionsVolume {
977 strike,
978 calls: 1.0,
979 puts: 0.0,
980 }),
981 1e308,
982 )
983 .is_none()
984 );
985 assert!(
986 VolumeProfile::from_options(
987 &[OptionsVolume {
988 strike: 100.0,
989 calls: f64::MAX,
990 puts: f64::MAX
991 }],
992 1.0
993 )
994 .is_none()
995 );
996 }
997}