Skip to main content

kestrel_chartkit/indicator/
price_levels.rs

1//! Unified price-level candidates: Opening Ranges, period midpoints, round numbers, and swing
2//! Fibonacci retracements/extensions all produce the same [`PriceLevel`] shape, so a consumer
3//! (e.g. a zone/confluence step) can merge and rank them uniformly instead of handling four
4//! bespoke, differently-shaped outputs.
5
6use crate::model::Bar;
7use crate::session::{SessionConfig, SessionConfigError, SessionTracker};
8use crate::timeframe::{Timeframe, TimeframeError};
9
10/// Which family a [`PriceLevel`] candidate comes from.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum PriceLevelKind {
13    OpeningRangeHigh,
14    OpeningRangeLow,
15    OpeningRangeMid,
16    PeriodMidpoint,
17    RoundNumber,
18    SwingFibonacci,
19}
20
21/// A single, provider-neutral price-level candidate.
22#[derive(Debug, Clone, PartialEq)]
23pub struct PriceLevel {
24    pub kind: PriceLevelKind,
25    pub price: f64,
26    /// Human-readable qualifier (e.g. a Fibonacci ratio `"0.618"`, or the round increment
27    /// `"100"`). Empty for kinds that need no further qualification.
28    pub label: String,
29}
30
31impl PriceLevel {
32    fn new(kind: PriceLevelKind, price: f64, label: impl Into<String>) -> Self {
33        Self {
34            kind,
35            price,
36            label: label.into(),
37        }
38    }
39}
40
41/// The three canonical Opening Range levels once the ORB window has closed.
42pub fn opening_range_levels(orb_high: f64, orb_low: f64) -> Vec<PriceLevel> {
43    vec![
44        PriceLevel::new(PriceLevelKind::OpeningRangeHigh, orb_high, ""),
45        PriceLevel::new(PriceLevelKind::OpeningRangeLow, orb_low, ""),
46        PriceLevel::new(
47            PriceLevelKind::OpeningRangeMid,
48            (orb_high + orb_low) / 2.0,
49            "",
50        ),
51    ]
52}
53
54/// Round-number levels nearest `price`: `levels_each_side` multiples of `increment` above and
55/// below (plus the multiple `price` itself falls between), e.g. `increment = 100.0` for
56/// round-hundred levels on an equity index, or `0.0050` for a 50-pip FX grid.
57pub fn round_number_levels(price: f64, increment: f64, levels_each_side: usize) -> Vec<PriceLevel> {
58    if !increment.is_finite() || increment <= 0.0 || !price.is_finite() {
59        return Vec::new();
60    }
61    let base = (price / increment).floor() * increment;
62    let mut levels = Vec::with_capacity(levels_each_side * 2 + 2);
63    for i in 0..=(levels_each_side as i64 + 1) {
64        let level = base + i as f64 * increment;
65        levels.push(PriceLevel::new(
66            PriceLevelKind::RoundNumber,
67            level,
68            format!("{increment}"),
69        ));
70    }
71    for i in 1..=levels_each_side as i64 {
72        let level = base - i as f64 * increment;
73        levels.push(PriceLevel::new(
74            PriceLevelKind::RoundNumber,
75            level,
76            format!("{increment}"),
77        ));
78    }
79    levels
80}
81
82/// Standard retracement/extension ratios for [`swing_fibonacci_levels`].
83pub const FIBONACCI_RATIOS: [f64; 8] = [0.236, 0.382, 0.5, 0.618, 0.786, 1.0, 1.272, 1.618];
84
85/// Fibonacci levels between a confirmed swing high and low (from e.g.
86/// [`super::zigzag_advanced::AdvancedZigZagEngine::nodes`]'s last two opposite-type confirmed
87/// nodes). `is_uptrend`: `true` if the swing ran low-to-high (retracements measured down from the
88/// high), `false` if it ran high-to-low (retracements measured up from the low).
89pub fn swing_fibonacci_levels(
90    swing_high: f64,
91    swing_low: f64,
92    is_uptrend: bool,
93) -> Vec<PriceLevel> {
94    let range = swing_high - swing_low;
95    FIBONACCI_RATIOS
96        .iter()
97        .map(|&ratio| {
98            let price = if is_uptrend {
99                swing_high - range * ratio
100            } else {
101                swing_low + range * ratio
102            };
103            PriceLevel::new(PriceLevelKind::SwingFibonacci, price, format!("{ratio}"))
104        })
105        .collect()
106}
107
108/// Tracks period open/high/low/close on a configurable [`Timeframe`] and yields the prior
109/// (confirmed) period's midpoint once a new period starts.
110pub struct PeriodMidpointTracker {
111    period_tf: Timeframe,
112    utc_offset_seconds: i32,
113    current_period_start: Option<i64>,
114    curr_high: f64,
115    curr_low: f64,
116}
117
118impl PeriodMidpointTracker {
119    pub fn new(period_tf: Timeframe) -> Result<Self, TimeframeError> {
120        Self::with_utc_offset(period_tf, 0)
121    }
122
123    pub fn with_utc_offset(
124        period_tf: Timeframe,
125        utc_offset_seconds: i32,
126    ) -> Result<Self, TimeframeError> {
127        Ok(Self {
128            period_tf: period_tf.validate()?,
129            utc_offset_seconds,
130            current_period_start: None,
131            curr_high: f64::MIN,
132            curr_low: f64::MAX,
133        })
134    }
135
136    pub fn reset(&mut self) {
137        self.current_period_start = None;
138        self.curr_high = f64::MIN;
139        self.curr_low = f64::MAX;
140    }
141
142    /// Returns `Some(midpoint)` only on the bar that closes a period (the same "confirmed value
143    /// becomes available one bar later" convention as [`crate::timeframe::BarResampler`]).
144    pub fn on_bar(&mut self, bar: &Bar) -> Option<PriceLevel> {
145        let period_start = self
146            .period_tf
147            .bucket_start(bar.timestamp, self.utc_offset_seconds);
148        let mut completed = None;
149
150        match self.current_period_start {
151            Some(start) if start != period_start => {
152                completed = Some(PriceLevel::new(
153                    PriceLevelKind::PeriodMidpoint,
154                    (self.curr_high + self.curr_low) / 2.0,
155                    "",
156                ));
157                self.current_period_start = Some(period_start);
158                self.curr_high = bar.high;
159                self.curr_low = bar.low;
160            }
161            Some(_) => {
162                self.curr_high = self.curr_high.max(bar.high);
163                self.curr_low = self.curr_low.min(bar.low);
164            }
165            None => {
166                self.current_period_start = Some(period_start);
167                self.curr_high = bar.high;
168                self.curr_low = bar.low;
169            }
170        }
171
172        completed
173    }
174}
175
176/// Combines Opening Range and period-midpoint levels (both stateful/time-driven) plus round
177/// numbers (stateless, derived from the current price) into one merged candidate list per bar.
178/// Swing Fibonacci levels are intentionally not included here: they need an externally supplied
179/// confirmed swing (see [`swing_fibonacci_levels`]), which this aggregator has no opinion on.
180pub struct PriceLevelAggregator {
181    session: SessionTracker,
182    period: PeriodMidpointTracker,
183    round_increment: f64,
184    round_levels_each_side: usize,
185}
186
187impl PriceLevelAggregator {
188    pub fn new(
189        session_config: SessionConfig,
190        period_tf: Timeframe,
191        round_increment: f64,
192        round_levels_each_side: usize,
193    ) -> Result<Self, PriceLevelAggregatorError> {
194        Ok(Self {
195            session: SessionTracker::new(session_config)
196                .map_err(PriceLevelAggregatorError::Session)?,
197            period: PeriodMidpointTracker::new(period_tf)
198                .map_err(PriceLevelAggregatorError::Timeframe)?,
199            round_increment,
200            round_levels_each_side,
201        })
202    }
203
204    pub fn reset(&mut self) {
205        self.session.reset();
206        self.period.reset();
207    }
208
209    pub fn on_bar(&mut self, bar: &Bar) -> Vec<PriceLevel> {
210        self.session.on_bar(bar);
211        let period_level = self.period.on_bar(bar);
212
213        let mut levels = Vec::new();
214        if let (Some(h), Some(l)) = (self.session.orb_high(), self.session.orb_low()) {
215            if !self.session.in_orb_window() {
216                levels.extend(opening_range_levels(h, l));
217            }
218        }
219        if let Some(level) = period_level {
220            levels.push(level);
221        }
222        levels.extend(round_number_levels(
223            bar.close,
224            self.round_increment,
225            self.round_levels_each_side,
226        ));
227        levels
228    }
229}
230
231#[derive(Debug, Clone, PartialEq)]
232pub enum PriceLevelAggregatorError {
233    Session(SessionConfigError),
234    Timeframe(TimeframeError),
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn test_opening_range_levels() {
243        let levels = opening_range_levels(110.0, 100.0);
244        assert_eq!(levels.len(), 3);
245        assert!(levels
246            .iter()
247            .any(|l| l.kind == PriceLevelKind::OpeningRangeMid && (l.price - 105.0).abs() < 1e-9));
248    }
249
250    #[test]
251    fn test_round_number_levels_bracket_price() {
252        let levels = round_number_levels(1234.0, 100.0, 1);
253        let prices: Vec<f64> = levels.iter().map(|l| l.price).collect();
254        assert!(prices.contains(&1200.0));
255        assert!(prices.contains(&1300.0));
256        assert!(prices.contains(&1100.0));
257        assert!(prices.contains(&1400.0));
258    }
259
260    #[test]
261    fn test_round_number_levels_rejects_degenerate_input() {
262        assert!(round_number_levels(100.0, 0.0, 3).is_empty());
263        assert!(round_number_levels(f64::NAN, 10.0, 3).is_empty());
264    }
265
266    #[test]
267    fn test_swing_fibonacci_uptrend_retraces_down_from_high() {
268        let levels = swing_fibonacci_levels(200.0, 100.0, true);
269        assert_eq!(levels.len(), FIBONACCI_RATIOS.len());
270        let half = levels.iter().find(|l| l.label == "0.5").unwrap();
271        assert!((half.price - 150.0).abs() < 1e-9);
272        let full_ext = levels.iter().find(|l| l.label == "1.618").unwrap();
273        assert!(
274            full_ext.price < 100.0,
275            "1.618 extension in an uptrend must project below the swing low"
276        );
277    }
278
279    #[test]
280    fn test_swing_fibonacci_downtrend_retraces_up_from_low() {
281        let levels = swing_fibonacci_levels(200.0, 100.0, false);
282        let half = levels.iter().find(|l| l.label == "0.5").unwrap();
283        assert!((half.price - 150.0).abs() < 1e-9);
284        let full_ext = levels.iter().find(|l| l.label == "1.618").unwrap();
285        assert!(
286            full_ext.price > 200.0,
287            "1.618 extension in a downtrend must project above the swing high"
288        );
289    }
290
291    #[test]
292    fn test_period_midpoint_tracker_yields_prior_period_confirmed() {
293        let mut tracker = PeriodMidpointTracker::new(Timeframe::Minute(5)).unwrap();
294        for i in 0..5 {
295            let out = tracker.on_bar(&Bar::new(i * 60, 100.0, 110.0, 90.0, 100.0, 10.0));
296            assert!(out.is_none());
297        }
298        let level = tracker
299            .on_bar(&Bar::new(300, 100.0, 101.0, 99.0, 100.0, 10.0))
300            .unwrap();
301        assert!((level.price - 100.0).abs() < 1e-9); // (110+90)/2
302    }
303
304    #[test]
305    fn test_aggregator_merges_round_numbers_and_opening_range() {
306        let session = SessionConfig {
307            start_hour: 0,
308            start_minute: 0,
309            end_hour: 23,
310            end_minute: 59,
311            orb_duration_mins: 1,
312            utc_offset_seconds: 0,
313        };
314        let mut aggregator =
315            PriceLevelAggregator::new(session, Timeframe::Day(1), 10.0, 1).unwrap();
316
317        // First bar starts the ORB window (still forming), later bars are past it.
318        aggregator.on_bar(&Bar::new(0, 100.0, 101.0, 99.0, 100.0, 10.0));
319        let levels = aggregator.on_bar(&Bar::new(120, 105.0, 106.0, 104.0, 105.0, 10.0));
320
321        assert!(levels.iter().any(|l| l.kind == PriceLevelKind::RoundNumber));
322        assert!(levels
323            .iter()
324            .any(|l| l.kind == PriceLevelKind::OpeningRangeHigh));
325    }
326}