kestrel_chartkit/indicator/
price_levels.rs1use crate::model::Bar;
7use crate::session::{SessionConfig, SessionConfigError, SessionTracker};
8use crate::timeframe::{Timeframe, TimeframeError};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum PriceLevelKind {
13 OpeningRangeHigh,
14 OpeningRangeLow,
15 OpeningRangeMid,
16 PeriodMidpoint,
17 RoundNumber,
18 SwingFibonacci,
19}
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct PriceLevel {
24 pub kind: PriceLevelKind,
25 pub price: f64,
26 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
41pub 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
54pub 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
82pub const FIBONACCI_RATIOS: [f64; 8] = [0.236, 0.382, 0.5, 0.618, 0.786, 1.0, 1.272, 1.618];
84
85pub 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
108pub 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 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
176pub 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); }
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 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}