use crate::model::Bar;
use crate::session::{SessionConfig, SessionConfigError, SessionTracker};
use crate::timeframe::{Timeframe, TimeframeError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PriceLevelKind {
OpeningRangeHigh,
OpeningRangeLow,
OpeningRangeMid,
PeriodMidpoint,
RoundNumber,
SwingFibonacci,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PriceLevel {
pub kind: PriceLevelKind,
pub price: f64,
pub label: String,
}
impl PriceLevel {
fn new(kind: PriceLevelKind, price: f64, label: impl Into<String>) -> Self {
Self {
kind,
price,
label: label.into(),
}
}
}
pub fn opening_range_levels(orb_high: f64, orb_low: f64) -> Vec<PriceLevel> {
vec![
PriceLevel::new(PriceLevelKind::OpeningRangeHigh, orb_high, ""),
PriceLevel::new(PriceLevelKind::OpeningRangeLow, orb_low, ""),
PriceLevel::new(
PriceLevelKind::OpeningRangeMid,
(orb_high + orb_low) / 2.0,
"",
),
]
}
pub fn round_number_levels(price: f64, increment: f64, levels_each_side: usize) -> Vec<PriceLevel> {
if !increment.is_finite() || increment <= 0.0 || !price.is_finite() {
return Vec::new();
}
let base = (price / increment).floor() * increment;
let mut levels = Vec::with_capacity(levels_each_side * 2 + 2);
for i in 0..=(levels_each_side as i64 + 1) {
let level = base + i as f64 * increment;
levels.push(PriceLevel::new(
PriceLevelKind::RoundNumber,
level,
format!("{increment}"),
));
}
for i in 1..=levels_each_side as i64 {
let level = base - i as f64 * increment;
levels.push(PriceLevel::new(
PriceLevelKind::RoundNumber,
level,
format!("{increment}"),
));
}
levels
}
pub const FIBONACCI_RATIOS: [f64; 8] = [0.236, 0.382, 0.5, 0.618, 0.786, 1.0, 1.272, 1.618];
pub fn swing_fibonacci_levels(
swing_high: f64,
swing_low: f64,
is_uptrend: bool,
) -> Vec<PriceLevel> {
let range = swing_high - swing_low;
FIBONACCI_RATIOS
.iter()
.map(|&ratio| {
let price = if is_uptrend {
swing_high - range * ratio
} else {
swing_low + range * ratio
};
PriceLevel::new(PriceLevelKind::SwingFibonacci, price, format!("{ratio}"))
})
.collect()
}
pub struct PeriodMidpointTracker {
period_tf: Timeframe,
utc_offset_seconds: i32,
current_period_start: Option<i64>,
curr_high: f64,
curr_low: f64,
}
impl PeriodMidpointTracker {
pub fn new(period_tf: Timeframe) -> Result<Self, TimeframeError> {
Self::with_utc_offset(period_tf, 0)
}
pub fn with_utc_offset(
period_tf: Timeframe,
utc_offset_seconds: i32,
) -> Result<Self, TimeframeError> {
Ok(Self {
period_tf: period_tf.validate()?,
utc_offset_seconds,
current_period_start: None,
curr_high: f64::MIN,
curr_low: f64::MAX,
})
}
pub fn reset(&mut self) {
self.current_period_start = None;
self.curr_high = f64::MIN;
self.curr_low = f64::MAX;
}
pub fn on_bar(&mut self, bar: &Bar) -> Option<PriceLevel> {
let period_start = self
.period_tf
.bucket_start(bar.timestamp, self.utc_offset_seconds);
let mut completed = None;
match self.current_period_start {
Some(start) if start != period_start => {
completed = Some(PriceLevel::new(
PriceLevelKind::PeriodMidpoint,
(self.curr_high + self.curr_low) / 2.0,
"",
));
self.current_period_start = Some(period_start);
self.curr_high = bar.high;
self.curr_low = bar.low;
}
Some(_) => {
self.curr_high = self.curr_high.max(bar.high);
self.curr_low = self.curr_low.min(bar.low);
}
None => {
self.current_period_start = Some(period_start);
self.curr_high = bar.high;
self.curr_low = bar.low;
}
}
completed
}
}
pub struct PriceLevelAggregator {
session: SessionTracker,
period: PeriodMidpointTracker,
round_increment: f64,
round_levels_each_side: usize,
}
impl PriceLevelAggregator {
pub fn new(
session_config: SessionConfig,
period_tf: Timeframe,
round_increment: f64,
round_levels_each_side: usize,
) -> Result<Self, PriceLevelAggregatorError> {
Ok(Self {
session: SessionTracker::new(session_config)
.map_err(PriceLevelAggregatorError::Session)?,
period: PeriodMidpointTracker::new(period_tf)
.map_err(PriceLevelAggregatorError::Timeframe)?,
round_increment,
round_levels_each_side,
})
}
pub fn reset(&mut self) {
self.session.reset();
self.period.reset();
}
pub fn on_bar(&mut self, bar: &Bar) -> Vec<PriceLevel> {
self.session.on_bar(bar);
let period_level = self.period.on_bar(bar);
let mut levels = Vec::new();
if let (Some(h), Some(l)) = (self.session.orb_high(), self.session.orb_low()) {
if !self.session.in_orb_window() {
levels.extend(opening_range_levels(h, l));
}
}
if let Some(level) = period_level {
levels.push(level);
}
levels.extend(round_number_levels(
bar.close,
self.round_increment,
self.round_levels_each_side,
));
levels
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PriceLevelAggregatorError {
Session(SessionConfigError),
Timeframe(TimeframeError),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_opening_range_levels() {
let levels = opening_range_levels(110.0, 100.0);
assert_eq!(levels.len(), 3);
assert!(levels
.iter()
.any(|l| l.kind == PriceLevelKind::OpeningRangeMid && (l.price - 105.0).abs() < 1e-9));
}
#[test]
fn test_round_number_levels_bracket_price() {
let levels = round_number_levels(1234.0, 100.0, 1);
let prices: Vec<f64> = levels.iter().map(|l| l.price).collect();
assert!(prices.contains(&1200.0));
assert!(prices.contains(&1300.0));
assert!(prices.contains(&1100.0));
assert!(prices.contains(&1400.0));
}
#[test]
fn test_round_number_levels_rejects_degenerate_input() {
assert!(round_number_levels(100.0, 0.0, 3).is_empty());
assert!(round_number_levels(f64::NAN, 10.0, 3).is_empty());
}
#[test]
fn test_swing_fibonacci_uptrend_retraces_down_from_high() {
let levels = swing_fibonacci_levels(200.0, 100.0, true);
assert_eq!(levels.len(), FIBONACCI_RATIOS.len());
let half = levels.iter().find(|l| l.label == "0.5").unwrap();
assert!((half.price - 150.0).abs() < 1e-9);
let full_ext = levels.iter().find(|l| l.label == "1.618").unwrap();
assert!(
full_ext.price < 100.0,
"1.618 extension in an uptrend must project below the swing low"
);
}
#[test]
fn test_swing_fibonacci_downtrend_retraces_up_from_low() {
let levels = swing_fibonacci_levels(200.0, 100.0, false);
let half = levels.iter().find(|l| l.label == "0.5").unwrap();
assert!((half.price - 150.0).abs() < 1e-9);
let full_ext = levels.iter().find(|l| l.label == "1.618").unwrap();
assert!(
full_ext.price > 200.0,
"1.618 extension in a downtrend must project above the swing high"
);
}
#[test]
fn test_period_midpoint_tracker_yields_prior_period_confirmed() {
let mut tracker = PeriodMidpointTracker::new(Timeframe::Minute(5)).unwrap();
for i in 0..5 {
let out = tracker.on_bar(&Bar::new(i * 60, 100.0, 110.0, 90.0, 100.0, 10.0));
assert!(out.is_none());
}
let level = tracker
.on_bar(&Bar::new(300, 100.0, 101.0, 99.0, 100.0, 10.0))
.unwrap();
assert!((level.price - 100.0).abs() < 1e-9); }
#[test]
fn test_aggregator_merges_round_numbers_and_opening_range() {
let session = SessionConfig {
start_hour: 0,
start_minute: 0,
end_hour: 23,
end_minute: 59,
orb_duration_mins: 1,
utc_offset_seconds: 0,
};
let mut aggregator =
PriceLevelAggregator::new(session, Timeframe::Day(1), 10.0, 1).unwrap();
aggregator.on_bar(&Bar::new(0, 100.0, 101.0, 99.0, 100.0, 10.0));
let levels = aggregator.on_bar(&Bar::new(120, 105.0, 106.0, 104.0, 105.0, 10.0));
assert!(levels.iter().any(|l| l.kind == PriceLevelKind::RoundNumber));
assert!(levels
.iter()
.any(|l| l.kind == PriceLevelKind::OpeningRangeHigh));
}
}