1use super::anchor_period::{AnchorPeriod, ExchangeDays};
45use crate::model::{Bar, Instrument, Timeframe};
46use crate::studies::stepper::Stepper;
47use crate::studies::{
48 Indicator, IndicatorValue, IndicatorValues, LabelPlacement, StudyDataRequest, StudyLabel,
49};
50use chrono::{Datelike, NaiveDate};
51use egui::Color32;
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
55#[non_exhaustive]
56pub enum PivotMethod {
57 #[default]
59 Traditional,
60 Fibonacci,
62 Woodie,
64 Classic,
66 DM,
68 Camarilla,
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
74#[non_exhaustive]
75pub enum PivotTimeframe {
76 #[default]
79 Auto,
80 Daily,
82 Weekly,
84 Monthly,
86 Quarterly,
88 Yearly,
90 TwoYears,
92 ThreeYears,
94 FiveYears,
96 TenYears,
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
102#[non_exhaustive]
103pub enum PivotLabelSide {
104 #[default]
106 Left,
107 Right,
109}
110
111const LEVEL_NAMES: [&str; 11] = [
112 "P", "R1", "S1", "R2", "S2", "R3", "S3", "R4", "S4", "R5", "S5",
113];
114
115#[derive(Clone, Copy)]
117pub(super) struct LevelLabels {
118 pub names: bool,
119 pub prices: bool,
120 pub side: PivotLabelSide,
121 pub precision: Option<usize>,
122}
123
124impl LevelLabels {
125 fn text(self, line: usize, price: f64) -> String {
126 let name = LEVEL_NAMES[line];
127 let price = || crate::scales::StudyFormat::Price.format(price, self.precision);
128 match (self.names, self.prices) {
129 (true, true) => format!("{name} ({})", price()),
130 (true, false) => name.to_owned(),
131 _ => price(),
132 }
133 }
134}
135
136pub(super) fn refresh_level_labels(
139 labels: &mut Vec<StudyLabel>,
140 values: &IndicatorValues,
141 from: usize,
142 options: LevelLabels,
143) {
144 if !options.names && !options.prices {
145 labels.clear();
146 return;
147 }
148 let len = values.len();
149 let first = from.min(len).saturating_sub(1);
150 labels.truncate(labels.partition_point(|label| label.bar < first));
151 for bar in first..len {
152 for (line, _) in LEVEL_NAMES.iter().enumerate() {
153 let Some(price) = values.value(line, bar) else {
154 continue;
155 };
156 let (neighbour, placement) = match options.side {
157 PivotLabelSide::Left => (bar.checked_sub(1), LabelPlacement::AboveRight),
158 PivotLabelSide::Right => (
159 Some(bar + 1).filter(|&next| next < len),
160 LabelPlacement::AboveLeft,
161 ),
162 };
163 if neighbour.and_then(|at| values.value(line, at)) != Some(price) {
164 labels.push(StudyLabel {
165 bar,
166 price,
167 text: options.text(line, price),
168 placement,
169 plot: line,
170 });
171 }
172 }
173 }
174}
175
176#[derive(Clone)]
186pub struct PivotPoints {
187 method: PivotMethod,
188 timeframe: PivotTimeframe,
189 pivots_back: usize,
190 interval: Timeframe,
191 days: ExchangeDays,
192 values: IndicatorValues,
193 stepper: Stepper<State>,
194 intraday: bool,
195 shown_start: usize,
196 colors: Vec<Color32>,
198 visible: bool,
199 show_labels: bool,
200 show_prices: bool,
201 label_side: PivotLabelSide,
202 daily_bars: bool,
203 precision: Option<usize>,
205 label_precision: Option<usize>,
206 daily: Option<Vec<((i32, u32), Bar)>>,
208 labels: Vec<StudyLabel>,
209}
210
211#[derive(Clone)]
212struct Period {
213 key: (i32, u32),
214 start: usize,
215 ohlc: Bar,
216 levels: [f64; 11],
217}
218
219#[derive(Clone, Default)]
220struct State {
221 index: usize,
222 previous_date: Option<chrono::NaiveDate>,
223 intraday: bool,
224 periods: std::collections::VecDeque<Period>,
225}
226
227impl PivotPoints {
228 pub fn new() -> Self {
230 let colors = vec![crate::studies::palette::DARK_ORANGE; 11];
232 Self {
233 method: PivotMethod::Traditional,
234 timeframe: PivotTimeframe::Auto,
235 pivots_back: 15,
236 interval: Timeframe::Min1,
237 days: ExchangeDays::default(),
238 values: IndicatorValues::new(),
239 stepper: Stepper::default(),
240 intraday: false,
241 shown_start: 0,
242 colors,
243 visible: true,
244 show_labels: true,
245 show_prices: false,
246 label_side: PivotLabelSide::Left,
247 daily_bars: false,
248 precision: None,
249 label_precision: None,
250 daily: None,
251 labels: Vec::new(),
252 }
253 }
254
255 pub fn with_method(mut self, method: PivotMethod) -> Self {
257 self.method = method;
258 self
259 }
260
261 pub fn with_timeframe(mut self, timeframe: PivotTimeframe) -> Self {
263 self.timeframe = timeframe;
264 self
265 }
266
267 pub fn with_pivots_back(mut self, pivots_back: usize) -> Self {
269 self.pivots_back = pivots_back.max(1);
270 self
271 }
272
273 pub fn with_colors(mut self, colors: Vec<Color32>) -> Self {
275 self.set_colors(colors);
276 self
277 }
278
279 pub fn with_labels(mut self, names: bool, prices: bool) -> Self {
282 self.show_labels = names;
283 self.show_prices = prices;
284 self
285 }
286
287 pub fn with_label_side(mut self, side: PivotLabelSide) -> Self {
289 self.label_side = side;
290 self
291 }
292
293 pub fn with_daily_bars(mut self, daily: bool) -> Self {
299 self.daily_bars = daily;
300 self
301 }
302
303 fn period(&self) -> Option<AnchorPeriod> {
305 Some(match self.timeframe {
306 PivotTimeframe::Auto => {
307 let ms = self.interval.duration_ms();
308 if ms <= Timeframe::Min15.duration_ms() {
309 AnchorPeriod::Session
310 } else if ms < Timeframe::Day1.duration_ms() {
311 AnchorPeriod::Week
312 } else if ms < Timeframe::Week1.duration_ms() {
313 AnchorPeriod::Month
314 } else {
315 AnchorPeriod::Year
316 }
317 }
318 PivotTimeframe::Daily => AnchorPeriod::Session,
319 PivotTimeframe::Weekly => AnchorPeriod::Week,
320 PivotTimeframe::Monthly => AnchorPeriod::Month,
321 PivotTimeframe::Quarterly => AnchorPeriod::Quarter,
322 PivotTimeframe::Yearly => AnchorPeriod::Year,
323 PivotTimeframe::TenYears => AnchorPeriod::Decade,
324 PivotTimeframe::TwoYears | PivotTimeframe::ThreeYears | PivotTimeframe::FiveYears => {
325 return None;
326 }
327 })
328 }
329
330 fn key(period: Option<AnchorPeriod>, timeframe: PivotTimeframe, day: NaiveDate) -> (i32, u32) {
333 match (period, timeframe) {
334 (Some(period), _) => period.key(day),
335 (None, PivotTimeframe::TwoYears) => (day.year().div_euclid(2), 0),
336 (None, PivotTimeframe::ThreeYears) => (day.year().div_euclid(3), 0),
337 (None, _) => (day.year().div_euclid(5), 0),
338 }
339 }
340
341 fn usable(method: PivotMethod, bar: &Bar) -> bool {
342 bar.high.is_finite()
343 && bar.low.is_finite()
344 && bar.close.is_finite()
345 && (!matches!(method, PivotMethod::Woodie | PivotMethod::DM) || bar.open.is_finite())
346 }
347
348 fn label_options(&self) -> LevelLabels {
349 LevelLabels {
350 names: self.show_labels,
351 prices: self.show_prices,
352 side: self.label_side,
353 precision: self.label_precision.or(self.precision),
354 }
355 }
356
357 pub(super) fn label_projection(&mut self, bars: std::ops::Range<usize>, levels: &[f64; 11]) {
359 let options = self.label_options();
360 if !options.names && !options.prices {
361 return;
362 }
363 let (bar, placement) = match options.side {
364 PivotLabelSide::Left => (bars.start, LabelPlacement::AboveRight),
365 PivotLabelSide::Right => (bars.end, LabelPlacement::AboveLeft),
366 };
367 self.labels.extend(
368 levels
369 .iter()
370 .enumerate()
371 .filter(|(_, price)| price.is_finite())
372 .map(|(plot, &price)| StudyLabel {
373 bar,
374 price,
375 text: options.text(plot, price),
376 placement,
377 plot,
378 }),
379 );
380 }
381
382 fn daily_periods(&self, bars: &[Bar]) -> Vec<((i32, u32), Bar)> {
385 let (period, timeframe) = (self.period(), self.timeframe);
386 let mut periods: Vec<((i32, u32), Bar)> = Vec::new();
387 for bar in bars.iter().filter(|bar| Self::usable(self.method, bar)) {
388 let key = Self::key(period, timeframe, bar.time.date_naive());
389 match periods.last_mut() {
390 Some((last, current)) if *last == key => {
391 current.high = current.high.max(bar.high);
392 current.low = current.low.min(bar.low);
393 current.close = bar.close;
394 }
395 _ => periods.push((key, bar.clone())),
396 }
397 }
398 periods
399 }
400
401 fn daily_levels_changed(&self, new: &[((i32, u32), Bar)]) -> bool {
405 let Some(old) = &self.daily else {
406 return true;
407 };
408 let Some(latest) = self.stepper.state().periods.back().map(|period| period.key) else {
409 return false;
410 };
411 let used = |periods: &[((i32, u32), Bar)]| periods.partition_point(|(k, _)| *k <= latest);
412 let same = |a: f64, b: f64| a.to_bits() == b.to_bits();
413 used(old) != used(new)
414 || old[..used(old)]
415 .iter()
416 .zip(new)
417 .any(|((key, a), (new_key, b))| {
418 key != new_key
419 || !same(a.open, b.open)
420 || (*key < latest
421 && !(same(a.high, b.high)
422 && same(a.low, b.low)
423 && same(a.close, b.close)))
424 })
425 }
426
427 fn run(&mut self, data: &[Bar], mut from: usize) {
428 let (period, timeframe) = (self.period(), self.timeframe);
429 let method = self.method;
430 let entry_shown = self.shown_start;
431 let len = self.values.len();
432 if len == 0 || from < len - 1 || data.len() < len {
433 self.shown_start = 0;
434 }
435 loop {
436 self.stepper
437 .run(data, from, &mut self.values, |state, bar| {
438 let index = state.index;
439 state.index += 1;
440 if !Self::usable(method, bar) {
441 return IndicatorValue::None;
442 }
443 let date = bar.time.date_naive();
444 state.intraday |= state.previous_date == Some(date);
445 state.previous_date = Some(date);
446 let key = Self::key(period, timeframe, self.days.day(bar, !self.intraday));
447 if state.periods.back().is_none_or(|last| last.key != key) {
448 let levels = match &self.daily {
449 Some(daily) => {
450 let at = daily.partition_point(|(k, _)| *k < key);
451 let open = daily
452 .get(at)
453 .filter(|(k, _)| *k == key)
454 .map_or(bar.open, |(_, period)| period.open);
455 at.checked_sub(1).map_or([f64::NAN; 11], |previous| {
456 Self::levels(method, &daily[previous].1, open)
457 })
458 }
459 None => state.periods.back().map_or([f64::NAN; 11], |last| {
460 Self::levels(method, &last.ohlc, bar.open)
461 }),
462 };
463 state.periods.push_back(Period {
464 key,
465 start: index,
466 ohlc: bar.clone(),
467 levels,
468 });
469 if state.periods.len() > self.pivots_back {
470 state.periods.pop_front();
471 }
472 } else {
473 let current = state.periods.back_mut().unwrap();
474 current.ohlc.high = current.ohlc.high.max(bar.high);
475 current.ohlc.low = current.ohlc.low.min(bar.low);
476 current.ohlc.close = bar.close;
477 }
478 IndicatorValue::Multiple(state.periods.back().unwrap().levels.to_vec())
479 });
480 if self.intraday == self.stepper.state().intraday {
481 break;
482 }
483 self.intraday = self.stepper.state().intraday;
484 self.shown_start = 0;
485 from = 0;
486 }
487 let periods = &self.stepper.state().periods;
488 let start = periods.front().map_or(data.len(), |period| period.start);
489 if start > self.shown_start {
490 for bar in self.shown_start..start {
491 self.values.set(bar, IndicatorValue::None);
492 }
493 } else if start < self.shown_start {
494 for (at, period) in periods.iter().enumerate() {
496 let end = periods
497 .get(at + 1)
498 .map_or(data.len(), |next| next.start)
499 .min(self.shown_start);
500 for (bar, source) in data.iter().enumerate().take(end).skip(period.start) {
501 if Self::usable(method, source) {
502 self.values
503 .set(bar, IndicatorValue::Multiple(period.levels.to_vec()));
504 }
505 }
506 }
507 }
508 let unchanged = if start != entry_shown { 0 } else { from };
510 self.shown_start = start;
511 let options = self.label_options();
512 refresh_level_labels(&mut self.labels, &self.values, unchanged, options);
513 }
514
515 pub(super) fn latest_period(&self) -> Option<(PivotMethod, &Bar, usize)> {
518 let period = self.stepper.state().periods.back()?;
519 Some((self.method, &period.ohlc, period.start))
520 }
521
522 pub(super) fn levels(method: PivotMethod, prev: &Bar, open: f64) -> [f64; 11] {
524 let (h, l, c) = (prev.high, prev.low, prev.close);
525 let r = h - l;
526 let p = (h + l + c) / 3.0;
527 let nan = f64::NAN;
528 match method {
529 PivotMethod::Traditional => [
530 p,
531 2.0 * p - l,
532 2.0 * p - h,
533 p + r,
534 p - r,
535 2.0 * p + (h - 2.0 * l),
536 2.0 * p - (2.0 * h - l),
537 3.0 * p + (h - 3.0 * l),
538 3.0 * p - (3.0 * h - l),
539 4.0 * p + (h - 4.0 * l),
540 4.0 * p - (4.0 * h - l),
541 ],
542 PivotMethod::Fibonacci => [
543 p,
544 p + 0.382 * r,
545 p - 0.382 * r,
546 p + 0.618 * r,
547 p - 0.618 * r,
548 p + r,
549 p - r,
550 nan,
551 nan,
552 nan,
553 nan,
554 ],
555 PivotMethod::Woodie => {
556 let p = (h + l + 2.0 * open) / 4.0;
557 let (r3, s3) = (h + 2.0 * (p - l), l - 2.0 * (h - p));
558 [
559 p,
560 2.0 * p - l,
561 2.0 * p - h,
562 p + r,
563 p - r,
564 r3,
565 s3,
566 r3 + r,
567 s3 - r,
568 nan,
569 nan,
570 ]
571 }
572 PivotMethod::Classic => [
573 p,
574 2.0 * p - l,
575 2.0 * p - h,
576 p + r,
577 p - r,
578 p + 2.0 * r,
579 p - 2.0 * r,
580 p + 3.0 * r,
581 p - 3.0 * r,
582 nan,
583 nan,
584 ],
585 PivotMethod::DM => {
586 let x = if c == prev.open {
587 h + l + 2.0 * c
588 } else if c > prev.open {
589 2.0 * h + l + c
590 } else {
591 h + 2.0 * l + c
592 };
593 [
594 x / 4.0,
595 x / 2.0 - l,
596 x / 2.0 - h,
597 nan,
598 nan,
599 nan,
600 nan,
601 nan,
602 nan,
603 nan,
604 nan,
605 ]
606 }
607 PivotMethod::Camarilla => {
608 let k = 1.1 * r;
609 let r5 = h / l * c;
610 [
611 p,
612 c + k / 12.0,
613 c - k / 12.0,
614 c + k / 6.0,
615 c - k / 6.0,
616 c + k / 4.0,
617 c - k / 4.0,
618 c + k / 2.0,
619 c - k / 2.0,
620 r5,
621 c - (r5 - c),
622 ]
623 }
624 }
625 }
626}
627
628impl Default for PivotPoints {
630 fn default() -> Self {
631 Self::new()
632 }
633}
634
635impl Indicator for PivotPoints {
636 crate::studies::inputs::study_inputs!(self;
637 method: choice("Type", [PivotMethod::Traditional, PivotMethod::Fibonacci, PivotMethod::Woodie, PivotMethod::Classic, PivotMethod::DM, PivotMethod::Camarilla]),
638 timeframe: choice("Pivots Timeframe", [PivotTimeframe::Auto, PivotTimeframe::Daily, PivotTimeframe::Weekly, PivotTimeframe::Monthly, PivotTimeframe::Quarterly, PivotTimeframe::Yearly, PivotTimeframe::TwoYears, PivotTimeframe::ThreeYears, PivotTimeframe::FiveYears, PivotTimeframe::TenYears]),
639 pivots_back: length("Number of Pivots Back", 1, usize::MAX),
640 show_labels: toggle("Show Labels"),
641 show_prices: toggle("Show Prices"),
642 label_side: choice("Labels Position", [PivotLabelSide::Left, PivotLabelSide::Right]),
643 daily_bars: toggle("Use Daily Bars")
644 ; {});
645
646 fn name(&self) -> &str {
647 "Pivot Points Standard"
648 }
649
650 fn desc(&self) -> &str {
651 "Pivot Points Standard - Support and resistance levels from the previous calendar period"
652 }
653
654 fn calculate(&mut self, data: &[Bar]) {
655 self.update(data, 0);
656 }
657
658 fn update(&mut self, data: &[Bar], from: usize) {
659 let from = if self.daily.take().is_some() { 0 } else { from };
660 self.run(data, from);
661 }
662
663 fn data_requests(&self) -> Vec<StudyDataRequest> {
664 if self.daily_bars && self.interval.duration_ms() < Timeframe::Day1.duration_ms() {
665 vec![StudyDataRequest {
666 symbol: String::new(),
667 timeframe: Some(Timeframe::Day1),
668 metric: None,
669 }]
670 } else {
671 Vec::new()
672 }
673 }
674
675 fn update_data(
676 &mut self,
677 data: &[Bar],
678 from: usize,
679 sources: &[crate::studies::StudyData<'_>],
680 ) {
681 let requests = self.data_requests();
682 let Some(request) = requests.first() else {
683 return self.update(data, from);
684 };
685 let bars = sources
686 .iter()
687 .find(|source| source.request == *request)
688 .map_or(&[][..], |source| source.bars);
689 let daily = self.daily_periods(bars);
690 let from = if self.daily_levels_changed(&daily) {
691 0
692 } else {
693 from
694 };
695 self.daily = Some(daily);
696 self.run(data, from);
697 }
698
699 fn is_incremental(&self) -> bool {
700 true
701 }
702
703 fn set_timeframe(&mut self, timeframe: Timeframe) {
704 self.interval = timeframe;
705 }
706
707 fn set_instrument(&mut self, instrument: Option<&Instrument>) -> bool {
708 let precision = instrument
709 .and_then(crate::scales::instrument_price_precision)
710 .map(usize::from);
711 let changed = precision != self.precision;
712 self.precision = precision;
713 self.days.set(instrument) | changed
714 }
715
716 fn labels(&self) -> &[StudyLabel] {
717 &self.labels
718 }
719
720 fn set_label_precision(&mut self, precision: Option<usize>) {
721 self.label_precision = precision;
722 }
723
724 fn plot_color(&self, line: usize, bar: usize) -> Option<Color32> {
726 let previous = self.values.value(line, bar.checked_sub(1)?)?;
727 (Some(previous) != self.values.value(line, bar)).then_some(Color32::TRANSPARENT)
728 }
729
730 fn values(&self) -> &IndicatorValues {
731 &self.values
732 }
733
734 fn colors(&self) -> Vec<Color32> {
735 self.colors.clone()
736 }
737
738 fn set_colors(&mut self, colors: Vec<Color32>) {
740 for (line, color) in self.colors.iter_mut().zip(colors) {
741 *line = color;
742 }
743 }
744
745 fn is_overlay(&self) -> bool {
746 true }
748
749 fn is_visible(&self) -> bool {
750 self.visible
751 }
752
753 fn set_visible(&mut self, visible: bool) {
754 self.visible = visible;
755 }
756
757 fn clone_box(&self) -> Box<dyn Indicator> {
758 Box::new(self.clone())
759 }
760
761 fn line_names(&self) -> Vec<String> {
762 LEVEL_NAMES.map(String::from).to_vec()
763 }
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769 use chrono::{TimeZone, Utc};
770
771 #[test]
774 fn auto_follows_the_chart_interval() {
775 let mut pivots = PivotPoints::new();
776 for (interval, period) in [
777 (Timeframe::Min15, AnchorPeriod::Session),
778 (Timeframe::Min30, AnchorPeriod::Week),
779 (Timeframe::Hour4, AnchorPeriod::Week),
780 (Timeframe::Day1, AnchorPeriod::Month),
781 (Timeframe::Week1, AnchorPeriod::Year),
782 ] {
783 pivots.set_timeframe(interval);
784 assert_eq!(pivots.period(), Some(period), "{interval:?}");
785 }
786 }
787
788 fn three_days() -> Vec<Bar> {
792 let hour = |day, h| Utc.with_ymd_and_hms(2026, 3, day, h, 0, 0).unwrap();
793 [
794 (hour(2, 9), 10.0, 8.0, 9.0),
795 (hour(2, 10), 12.0, 9.0, 11.0),
796 (hour(3, 9), 11.0, 9.0, 10.0),
797 (hour(3, 10), 14.0, 10.0, 12.0),
798 (hour(4, 9), 12.0, 10.0, 11.0),
799 ]
800 .into_iter()
801 .map(|(time, high, low, close)| Bar::new(time, close, high, low, close, 1.0))
802 .collect()
803 }
804
805 #[test]
806 fn every_defined_level_is_labelled_at_the_chosen_end_of_its_period() {
807 let daily = || PivotPoints::new().with_timeframe(PivotTimeframe::Daily);
808 let names = [
809 "P", "R1", "S1", "R2", "S2", "R3", "S3", "R4", "S4", "R5", "S5",
810 ];
811 let priced = [
812 "P (10.33)",
813 "R1 (12.67)",
814 "S1 (8.67)",
815 "R2 (14.33)",
816 "S2 (6.33)",
817 "R3 (16.67)",
818 "S3 (4.67)",
819 "R4 (19.00)",
820 "S4 (3.00)",
821 "R5 (21.33)",
822 "S5 (1.33)",
823 ];
824 let mut tick = daily().with_labels(true, true);
825 tick.set_instrument(Some(
826 &crate::model::Instrument::new("X").with_tick_size(0.001),
827 ));
828 type Case<'a> = (
830 &'a str,
831 Box<dyn Indicator>,
832 &'a [usize],
833 LabelPlacement,
834 Vec<&'a str>,
835 );
836 let mut styled =
837 crate::studies::ConfiguredStudy::new(Box::new(daily().with_labels(false, true)));
838 styled.set_instrument(Some(
839 &crate::model::Instrument::new("X").with_tick_size(0.001),
840 ));
841 let mut settings = styled.settings();
842 settings.precision = Some(4);
843 styled.apply_settings(&[], settings).unwrap();
844 let cases: Vec<Case> = vec![
845 (
846 "defaults",
847 Box::new(daily()),
848 &[2, 4],
849 LabelPlacement::AboveRight,
850 names.to_vec(),
851 ),
852 (
853 "prices at the right end",
854 Box::new(
855 daily()
856 .with_labels(true, true)
857 .with_label_side(PivotLabelSide::Right),
858 ),
859 &[3, 4],
860 LabelPlacement::AboveLeft,
861 priced.to_vec(),
862 ),
863 (
864 "prices only",
865 Box::new(daily().with_labels(false, true)),
866 &[2, 4],
867 LabelPlacement::AboveRight,
868 vec![
869 "10.33", "12.67", "8.67", "14.33", "6.33", "16.67", "4.67", "19.00", "3.00",
870 "21.33", "1.33",
871 ],
872 ),
873 (
874 "seven Fibonacci levels",
875 Box::new(daily().with_method(PivotMethod::Fibonacci)),
876 &[2, 4],
877 LabelPlacement::AboveRight,
878 names[..7].to_vec(),
879 ),
880 (
881 "expired period",
882 Box::new(daily().with_pivots_back(1)),
883 &[4],
884 LabelPlacement::AboveRight,
885 names.to_vec(),
886 ),
887 (
888 "instrument decimals",
889 Box::new(tick),
890 &[2, 4],
891 LabelPlacement::AboveRight,
892 vec![
893 "P (10.333)",
894 "R1 (12.667)",
895 "S1 (8.667)",
896 "R2 (14.333)",
897 "S2 (6.333)",
898 "R3 (16.667)",
899 "S3 (4.667)",
900 "R4 (19.000)",
901 "S4 (3.000)",
902 "R5 (21.333)",
903 "S5 (1.333)",
904 ],
905 ),
906 (
907 "style precision over instrument decimals",
908 Box::new(styled),
909 &[2, 4],
910 LabelPlacement::AboveRight,
911 vec![
912 "10.3333", "12.6667", "8.6667", "14.3333", "6.3333", "16.6667", "4.6667",
913 "19.0000", "3.0000", "21.3333", "1.3333",
914 ],
915 ),
916 ];
917 for (name, mut study, bars, placement, texts) in cases {
918 study.calculate(&three_days());
919 let labels = study.labels();
920 let per_period = texts.len();
921 assert_eq!(labels.len(), per_period * bars.len(), "{name}");
922 for (at, label) in labels.iter().enumerate() {
923 let bar = bars[at / per_period];
924 let plot = at % per_period;
925 assert_eq!(
926 (label.bar, label.plot, label.placement),
927 (bar, plot, placement),
928 "{name}"
929 );
930 assert_eq!(
931 label.price,
932 study.values().value(plot, bar).unwrap(),
933 "{name}"
934 );
935 }
936 let first: Vec<_> = labels[..per_period]
937 .iter()
938 .map(|l| l.text.as_str())
939 .collect();
940 assert_eq!(first, texts, "{name}");
941 }
942
943 let mut off = daily().with_labels(false, false);
944 off.calculate(&three_days());
945 assert!(off.labels().is_empty());
946 let mut right = daily()
947 .with_labels(true, true)
948 .with_label_side(PivotLabelSide::Right);
949 right.calculate(&three_days());
950 assert_eq!(right.labels()[11].text, "P (11.67)");
951 }
952
953 #[test]
957 fn periods_of_several_years_start_in_years_divisible_by_their_length() {
958 let bars: Vec<Bar> = (2014..=2025)
959 .map(|year| {
960 let k = f64::from(year - 2014);
961 let time = Utc.with_ymd_and_hms(year, 1, 2, 0, 0, 0).unwrap();
962 Bar::new(time, 95.0 + k, 100.0 + k, 90.0 + k, 95.0 + k, 1.0)
963 })
964 .collect();
965 for (timeframe, p, r1) in [
966 (PivotTimeframe::TwoYears, 299.0 / 3.0, 316.0 / 3.0),
968 (PivotTimeframe::ThreeYears, 295.0 / 3.0, 314.0 / 3.0),
970 (PivotTimeframe::FiveYears, 296.0 / 3.0, 319.0 / 3.0),
972 (PivotTimeframe::TenYears, 295.0 / 3.0, 320.0 / 3.0),
974 ] {
975 let mut pivots = PivotPoints::new().with_timeframe(timeframe);
976 pivots.calculate(&bars);
977 let level = |line| pivots.values().value(line, 6).unwrap();
978 assert!((level(0) - p).abs() < 1e-9, "{timeframe:?} P {}", level(0));
979 assert!(
980 (level(1) - r1).abs() < 1e-9,
981 "{timeframe:?} R1 {}",
982 level(1)
983 );
984 }
985 }
986
987 #[test]
991 fn daily_bars_supply_the_previous_period_of_an_intraday_chart() {
992 let day = |d| Utc.with_ymd_and_hms(2026, 3, d, 0, 0, 0).unwrap();
993 let daily: Vec<Bar> = [
994 (2, 10.0, 8.0, 9.0),
995 (3, 12.0, 9.0, 11.0),
996 (4, 11.0, 7.0, 8.0),
997 (5, 13.0, 9.0, 12.0),
998 (6, 12.0, 10.0, 11.0),
999 (9, 60.0, 30.0, 45.0),
1000 (10, 60.0, 30.0, 45.0),
1001 ]
1002 .into_iter()
1003 .map(|(d, high, low, close)| Bar::new(day(d), close, high, low, close, 1.0))
1004 .collect();
1005 let hourly: Vec<Bar> = (10..13)
1006 .map(|h| {
1007 let time = Utc.with_ymd_and_hms(2026, 3, 9, h, 0, 0).unwrap();
1008 Bar::new(time, 45.0, 50.0, 40.0, 45.0, 1.0)
1009 })
1010 .collect();
1011 let study = || {
1012 let mut pivots = PivotPoints::new().with_timeframe(PivotTimeframe::Weekly);
1013 pivots.set_timeframe(Timeframe::Hour1);
1014 pivots
1015 };
1016 let mut pivots = study().with_daily_bars(true);
1017 let requests = pivots.data_requests();
1018 assert_eq!(requests.len(), 1);
1019 assert_eq!(requests[0].timeframe, Some(Timeframe::Day1));
1020 pivots.update_data(
1021 &hourly,
1022 0,
1023 &[crate::studies::StudyData {
1024 request: requests[0].clone(),
1025 bars: &daily,
1026 clock: crate::model::BarClock::new(Timeframe::Day1),
1027 instrument: None,
1028 timeframe: Timeframe::Day1,
1029 }],
1030 );
1031 let mut on_daily = PivotPoints::new().with_timeframe(PivotTimeframe::Weekly);
1032 on_daily.set_timeframe(Timeframe::Day1);
1033 on_daily.calculate(&daily);
1034 for bar in 0..3 {
1035 for (line, want) in [(0, 31.0 / 3.0), (1, 41.0 / 3.0), (2, 23.0 / 3.0)] {
1036 let got = pivots.values().value(line, bar).unwrap();
1037 assert!((got - want).abs() < 1e-9, "line {line} bar {bar}: {got}");
1038 }
1039 for line in 0..11 {
1040 assert_eq!(
1041 pivots.values().value(line, bar),
1042 on_daily.values().value(line, 5),
1043 "line {line} bar {bar}"
1044 );
1045 }
1046 }
1047 let mut ticked = daily.clone();
1049 ticked[6].close = 44.0;
1050 let mut corrected = daily.clone();
1051 corrected[0].low = 1.0;
1052 for (series, p) in [(&ticked, 31.0 / 3.0), (&corrected, 25.0 / 3.0)] {
1053 pivots.update_data(
1054 &hourly,
1055 3,
1056 &[crate::studies::StudyData {
1057 request: requests[0].clone(),
1058 bars: series,
1059 clock: crate::model::BarClock::new(Timeframe::Day1),
1060 instrument: None,
1061 timeframe: Timeframe::Day1,
1062 }],
1063 );
1064 assert!((pivots.values().value(0, 2).unwrap() - p).abs() < 1e-9);
1065 }
1066
1067 let mut own = study();
1070 assert!(own.data_requests().is_empty());
1071 own.calculate(&hourly);
1072 let mut missing = study().with_daily_bars(true);
1073 missing.update_data(&hourly, 0, &[]);
1074 for study in [&own, &missing] {
1075 assert!((0..3).all(|bar| study.values().value(0, bar).is_none()));
1076 }
1077 }
1078}