Skip to main content

egui_charts/studies/builtin/
intraday_pivot_points.rs

1//! Intraday Pivot Points.
2//!
3//! Pivot levels for each block of a trading session from the block before it. The session is an
4//! exchange trading day, counted as [`PivotPoints`](super::PivotPoints) counts them: the chart
5//! supplies the instrument's time zone and session through [`Indicator::set_instrument`] (UTC days
6//! until then). The first bar of a session opens its first block; block `k` holds the bars from
7//! `k * source period` to the next multiple after that bar's time, so a block is the same length
8//! in every session and never crosses one. The levels of a block come from the high, low and close
9//! of the latest earlier block of the same session, with the formulas of the chosen
10//! [`PivotMethod`]; the first block of a session has none, and bars a day or longer, which each
11//! open a session, never have any.
12//!
13//! # Output
14//!
15//! Eleven lines, `[P, R1, S1, R2, S2, R3, S3, R4, S4, R5, S5]`, constant within a block. A line is
16//! not drawn across the change from one block to the next. Each level is labelled with its name,
17//! and optionally its price, at its left or right end. Overlay indicator. The default is
18//! Traditional pivots from 60-minute blocks.
19//!
20//! # Example
21//!
22//! ```rust
23//! use chrono::{TimeZone, Utc};
24//! use egui_charts::model::Bar;
25//! use egui_charts::studies::{Indicator, IntradayPivotPoints};
26//!
27//! // Half-hourly bars: the second hour takes its levels from the first.
28//! let bars: Vec<Bar> = [(10.0, 8.0, 9.0), (12.0, 9.0, 11.0), (11.0, 7.0, 8.0)]
29//!     .into_iter()
30//!     .enumerate()
31//!     .map(|(i, (high, low, close))| {
32//!         let time = Utc.with_ymd_and_hms(2026, 3, 2, 9, 0, 0).unwrap() + chrono::Duration::minutes(30 * i as i64);
33//!         Bar::new(time, close, high, low, close, 1.0)
34//!     })
35//!     .collect();
36//! let mut pivots = IntradayPivotPoints::new(60);
37//! pivots.calculate(&bars);
38//! assert_eq!(pivots.values().value(0, 1), None);
39//! // P = (12 + 8 + 11) / 3 from the high, low and close of the first hour.
40//! assert!((pivots.values().value(0, 2).unwrap() - 31.0 / 3.0).abs() < 1e-12);
41//! ```
42
43use super::anchor_period::ExchangeDays;
44use super::pivot_points::{
45    LevelLabels, PivotLabelSide, PivotMethod, PivotPoints, refresh_level_labels,
46};
47use crate::model::{Bar, Instrument};
48use crate::studies::stepper::Stepper;
49use crate::studies::{Indicator, IndicatorValue, IndicatorValues, StudyLabel};
50use chrono::{DateTime, NaiveDate, Utc};
51use egui::Color32;
52
53/// Intraday Pivot Points: levels for each block of a trading session from the block before it.
54#[derive(Clone)]
55pub struct IntradayPivotPoints {
56    method: PivotMethod,
57    minutes: usize,
58    days: ExchangeDays,
59    values: IndicatorValues,
60    stepper: Stepper<State>,
61    /// One per line: P, then R1, S1 through R5, S5.
62    colors: Vec<Color32>,
63    visible: bool,
64    show_labels: bool,
65    show_prices: bool,
66    label_side: PivotLabelSide,
67    /// The instrument's decimals and those of the study's presentation settings.
68    precision: Option<usize>,
69    label_precision: Option<usize>,
70    labels: Vec<StudyLabel>,
71}
72
73#[derive(Clone)]
74struct State {
75    session: Option<NaiveDate>,
76    origin: DateTime<Utc>,
77    block: i64,
78    current: Option<Bar>,
79    levels: [f64; 11],
80}
81
82impl Default for State {
83    fn default() -> Self {
84        Self {
85            session: None,
86            origin: DateTime::UNIX_EPOCH,
87            block: 0,
88            current: None,
89            levels: [f64::NAN; 11],
90        }
91    }
92}
93
94impl IntradayPivotPoints {
95    /// Traditional pivots from blocks of `minutes` (1 to 1440; 0 becomes 1).
96    pub fn new(minutes: usize) -> Self {
97        Self {
98            method: PivotMethod::Traditional,
99            minutes: minutes.clamp(1, 1440),
100            days: ExchangeDays::default(),
101            values: IndicatorValues::new(),
102            stepper: Stepper::default(),
103            colors: PivotPoints::new().colors(),
104            visible: true,
105            show_labels: true,
106            show_prices: false,
107            label_side: PivotLabelSide::Left,
108            precision: None,
109            label_precision: None,
110            labels: Vec::new(),
111        }
112    }
113
114    /// The level formulas.
115    pub fn with_method(mut self, method: PivotMethod) -> Self {
116        self.method = method;
117        self
118    }
119
120    /// Whether each level is labelled with its name (P, R1, S1 and so on) and with its price.
121    /// Names only by default.
122    pub fn with_labels(mut self, names: bool, prices: bool) -> Self {
123        self.show_labels = names;
124        self.show_prices = prices;
125        self
126    }
127
128    /// The end of each level its label sits at; the left end by default.
129    pub fn with_label_side(mut self, side: PivotLabelSide) -> Self {
130        self.label_side = side;
131        self
132    }
133}
134
135/// Construct with the conventional default parameters.
136impl Default for IntradayPivotPoints {
137    fn default() -> Self {
138        Self::new(60)
139    }
140}
141
142impl Indicator for IntradayPivotPoints {
143    crate::studies::inputs::study_inputs!(self;
144        method: choice("Type", [PivotMethod::Traditional, PivotMethod::Fibonacci, PivotMethod::Woodie, PivotMethod::Classic, PivotMethod::DM, PivotMethod::Camarilla]),
145        minutes: length("Source period (minutes)", 1, 1440),
146        show_labels: toggle("Show Labels"),
147        show_prices: toggle("Show Prices"),
148        label_side: choice("Labels Position", [PivotLabelSide::Left, PivotLabelSide::Right])
149    ; {});
150
151    fn name(&self) -> &str {
152        "Intraday Pivot Points"
153    }
154
155    fn desc(&self) -> &str {
156        "Intraday Pivot Points - Support and resistance levels from the previous block of the session"
157    }
158
159    fn calculate(&mut self, data: &[Bar]) {
160        self.update(data, 0);
161    }
162
163    fn update(&mut self, data: &[Bar], from: usize) {
164        let method = self.method;
165        let block_ms = self.minutes as i64 * 60_000;
166        let needs_open = matches!(method, PivotMethod::Woodie | PivotMethod::DM);
167        self.stepper
168            .run(data, from, &mut self.values, |state, bar| {
169                if !(bar.high.is_finite()
170                    && bar.low.is_finite()
171                    && bar.close.is_finite()
172                    && (!needs_open || bar.open.is_finite()))
173                {
174                    return IndicatorValue::None;
175                }
176                let day = self.days.day(bar, false);
177                let block = (bar.time - state.origin)
178                    .num_milliseconds()
179                    .div_euclid(block_ms);
180                match &mut state.current {
181                    Some(current) if state.session == Some(day) => {
182                        if block != state.block {
183                            state.levels = PivotPoints::levels(method, current, bar.open);
184                            state.block = block;
185                            *current = bar.clone();
186                        } else {
187                            current.high = current.high.max(bar.high);
188                            current.low = current.low.min(bar.low);
189                            current.close = bar.close;
190                        }
191                    }
192                    _ => {
193                        state.session = Some(day);
194                        state.origin = bar.time;
195                        state.block = 0;
196                        state.current = Some(bar.clone());
197                        state.levels = [f64::NAN; 11];
198                    }
199                }
200                IndicatorValue::Multiple(state.levels.to_vec())
201            });
202        let options = LevelLabels {
203            names: self.show_labels,
204            prices: self.show_prices,
205            side: self.label_side,
206            precision: self.label_precision.or(self.precision),
207        };
208        refresh_level_labels(&mut self.labels, &self.values, from, options);
209    }
210
211    fn is_incremental(&self) -> bool {
212        true
213    }
214
215    fn set_instrument(&mut self, instrument: Option<&Instrument>) -> bool {
216        let precision = instrument
217            .and_then(crate::scales::instrument_price_precision)
218            .map(usize::from);
219        let changed = precision != self.precision;
220        self.precision = precision;
221        self.days.set(instrument) | changed
222    }
223
224    fn labels(&self) -> &[StudyLabel] {
225        &self.labels
226    }
227
228    fn set_label_precision(&mut self, precision: Option<usize>) {
229        self.label_precision = precision;
230    }
231
232    /// Transparent on the first bar of a block, so the change of level is not drawn.
233    fn plot_color(&self, line: usize, bar: usize) -> Option<Color32> {
234        let previous = self.values.value(line, bar.checked_sub(1)?)?;
235        (Some(previous) != self.values.value(line, bar)).then_some(Color32::TRANSPARENT)
236    }
237
238    fn values(&self) -> &IndicatorValues {
239        &self.values
240    }
241
242    fn colors(&self) -> Vec<Color32> {
243        self.colors.clone()
244    }
245
246    /// Sets the colors line by line; lines without a color keep theirs.
247    fn set_colors(&mut self, colors: Vec<Color32>) {
248        for (line, color) in self.colors.iter_mut().zip(colors) {
249            *line = color;
250        }
251    }
252
253    fn is_overlay(&self) -> bool {
254        true
255    }
256
257    fn is_visible(&self) -> bool {
258        self.visible
259    }
260
261    fn set_visible(&mut self, visible: bool) {
262        self.visible = visible;
263    }
264
265    fn clone_box(&self) -> Box<dyn Indicator> {
266        Box::new(self.clone())
267    }
268
269    fn line_names(&self) -> Vec<String> {
270        [
271            "P", "R1", "S1", "R2", "S2", "R3", "S3", "R4", "S4", "R5", "S5",
272        ]
273        .map(String::from)
274        .to_vec()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use chrono::TimeZone;
282
283    /// Half-hourly bars of two sessions, with 60-minute blocks from the first bar of each.
284    /// Block 0 of the first session has high 12, low 8 and close 11, so block 1 has P = 31/3,
285    /// R1 = 2P - L = 38/3, S1 = 2P - H = 26/3, R2 = P + (H - L) = 43/3 and S2 = 19/3; block 2 takes
286    /// block 1 (high 13, low 7, close 12), P = 32/3 and R1 = 2P - L = 43/3. The next session starts
287    /// afresh, with no levels in its first block.
288    #[test]
289    fn levels_come_from_the_previous_block_of_the_session() {
290        let start = Utc.with_ymd_and_hms(2026, 3, 2, 9, 0, 0).unwrap();
291        let next_day = Utc.with_ymd_and_hms(2026, 3, 3, 9, 0, 0).unwrap();
292        let data: Vec<Bar> = [
293            (start, 10.0, 8.0, 9.0),
294            (start + chrono::Duration::minutes(30), 12.0, 9.0, 11.0),
295            (start + chrono::Duration::minutes(60), 11.0, 7.0, 8.0),
296            (start + chrono::Duration::minutes(90), 13.0, 9.0, 12.0),
297            (start + chrono::Duration::minutes(120), 9.0, 8.0, 8.5),
298            (next_day, 9.0, 8.0, 8.5),
299            (next_day + chrono::Duration::minutes(30), 9.5, 8.0, 9.0),
300            (next_day + chrono::Duration::minutes(60), 9.5, 8.0, 9.0),
301        ]
302        .into_iter()
303        .map(|(time, high, low, close)| Bar::new(time, close, high, low, close, 1.0))
304        .collect();
305        let mut pivots = IntradayPivotPoints::new(60);
306        pivots.calculate(&data);
307        let level = |line, bar| pivots.values().value(line, bar);
308        for bar in [0, 1, 5, 6] {
309            assert_eq!(level(0, bar), None, "bar {bar}");
310        }
311        for (line, want) in [31.0 / 3.0, 38.0 / 3.0, 26.0 / 3.0, 43.0 / 3.0, 19.0 / 3.0]
312            .into_iter()
313            .enumerate()
314        {
315            for bar in [2, 3] {
316                assert!(
317                    (level(line, bar).unwrap() - want).abs() < 1e-12,
318                    "line {line} bar {bar}"
319                );
320            }
321        }
322        assert!((level(0, 4).unwrap() - 32.0 / 3.0).abs() < 1e-12);
323        assert!((level(1, 4).unwrap() - 43.0 / 3.0).abs() < 1e-12);
324        // The second session's second block uses its first block, 9.5, 8 and 9: P = 8.8333.
325        assert!((level(0, 7).unwrap() - (9.5 + 8.0 + 9.0) / 3.0).abs() < 1e-12);
326    }
327}