use std::{cmp::Ordering, collections::VecDeque};
use crate::{
data::{
domain::{CandleDirection, Price, PriceSource},
event::{IndexedOhlcv, MarketEvent, Ohlcv},
},
math::StreamingIndicator,
};
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PivotType {
High,
Low,
}
impl From<MarketStructureSequence> for PivotType {
fn from(sequence: MarketStructureSequence) -> Self {
use MarketStructureSequence::*;
match sequence {
LowerHigh | HigherHigh | EqualHigh | UnclassifiedHigh => PivotType::High,
HigherLow | LowerLow | EqualLow | UnclassifiedLow => PivotType::Low,
}
}
}
impl PivotType {
fn extract_price(self, candle: Ohlcv, source: PriceSource) -> Price {
match (source, self, candle.direction()) {
(PriceSource::HighLow, PivotType::High, _) => candle.high,
(PriceSource::HighLow, PivotType::Low, _) => candle.low,
(PriceSource::OpenClose, PivotType::High, CandleDirection::Bullish) => candle.close,
(PriceSource::OpenClose, PivotType::High, CandleDirection::Bearish) => candle.open,
(PriceSource::OpenClose, PivotType::High, CandleDirection::Doji) => candle.close,
(PriceSource::OpenClose, PivotType::Low, CandleDirection::Bullish) => candle.open,
(PriceSource::OpenClose, PivotType::Low, CandleDirection::Bearish) => candle.close,
(PriceSource::OpenClose, PivotType::Low, CandleDirection::Doji) => candle.close,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarketStructureSequence {
HigherHigh,
LowerHigh,
EqualHigh,
HigherLow,
LowerLow,
EqualLow,
UnclassifiedHigh,
UnclassifiedLow,
}
impl MarketStructureSequence {
pub fn as_pivot_type(&self) -> PivotType {
(*self).into()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlternationMode {
#[default]
Alternating,
Consecutive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarketStructureEvent {
BreakOfStructure,
MarketStructureShift,
NoChange,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ExtremeTiebreaker {
#[default]
Latest,
Earliest,
}
#[derive(Debug, Clone, Copy)]
pub struct PivotPoint {
pub indexed_candle: IndexedOhlcv,
pub price: Price,
pub price_source: PriceSource,
pub trend: MarketStructureSequence,
}
impl MarketEvent for PivotPoint {
fn point_in_time(&self) -> DateTime<Utc> {
self.indexed_candle.point_in_time()
}
}
impl PivotPoint {
pub fn pivot_type(&self) -> PivotType {
self.trend.into()
}
pub fn price_line_by_index(&self, target: &PivotPoint) -> impl Fn(usize) -> Price {
let p0 = self.price.0;
let p1 = target.price.0;
let x0 = self.indexed_candle.index as f64;
let x1 = target.indexed_candle.index as f64;
let dx = x1 - x0;
let m = if dx == 0.0 { 0.0 } else { (p1 - p0) / dx };
move |x: usize| -> Price {
let current_dx = (x as f64) - x0;
Price(p0 + m * current_dx)
}
}
pub fn price_line_by_point_in_time(
&self,
target: &PivotPoint,
) -> impl Fn(DateTime<Utc>) -> Price {
let p0 = self.price.0;
let p1 = target.price.0;
let t0 = self.point_in_time();
let t1 = target.point_in_time();
let dx = (t1 - t0).num_milliseconds() as f64;
let m = if dx == 0.0 { 0.0 } else { (p1 - p0) / dx };
move |t: DateTime<Utc>| -> Price {
let current_dx = (t - t0).num_milliseconds() as f64;
Price(p0 + m * current_dx)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ZigZagPeriod {
pub left_bars: u16,
pub right_bars: u16,
}
impl Default for ZigZagPeriod {
fn default() -> Self {
Self::symmetric(5)
}
}
impl ZigZagPeriod {
pub fn symmetric(bars: u16) -> Self {
Self {
left_bars: bars,
right_bars: bars,
}
}
fn buffer_size(&self) -> usize {
(self.left_bars + self.right_bars + 1) as usize
}
fn mid_index(&self) -> usize {
self.left_bars as usize
}
}
#[derive(Debug, Clone)]
pub struct StreamingHhll {
zig_zag_period: ZigZagPeriod,
price_source: PriceSource,
tiebreaker: ExtremeTiebreaker,
alternation_mode: AlternationMode,
buffer: VecDeque<IndexedOhlcv>,
active_pivot: Option<PivotPoint>,
anchor_high: Option<PivotPoint>,
anchor_low: Option<PivotPoint>,
history: Vec<PivotPoint>,
}
impl Default for StreamingHhll {
fn default() -> Self {
let zig_zag_period = ZigZagPeriod::default();
Self {
zig_zag_period,
price_source: PriceSource::default(),
tiebreaker: ExtremeTiebreaker::default(),
alternation_mode: AlternationMode::default(),
buffer: VecDeque::with_capacity(zig_zag_period.buffer_size()),
active_pivot: None,
anchor_high: None,
anchor_low: None,
history: Vec::new(),
}
}
}
impl StreamingHhll {
pub fn with_zig_zag_period(self, zig_zag_period: ZigZagPeriod) -> Self {
Self {
zig_zag_period,
buffer: VecDeque::with_capacity(zig_zag_period.buffer_size()),
..self
}
}
pub fn with_price_source(self, price_source: PriceSource) -> Self {
Self {
price_source,
..self
}
}
pub fn with_tiebreaker(self, tiebreaker: ExtremeTiebreaker) -> Self {
Self { tiebreaker, ..self }
}
pub fn with_alternation_mode(self, alternation_mode: AlternationMode) -> Self {
Self {
alternation_mode,
..self
}
}
pub fn active_pivot(&self) -> Option<PivotPoint> {
self.active_pivot
}
pub fn anchor_high(&self) -> Option<PivotPoint> {
self.anchor_high
}
pub fn anchor_low(&self) -> Option<PivotPoint> {
self.anchor_low
}
pub fn history(&self) -> &[PivotPoint] {
&self.history
}
}
impl StreamingHhll {
fn candidate(&self) -> IndexedOhlcv {
let mid_idx = self.zig_zag_period.mid_index();
self.buffer[mid_idx]
}
fn left_partition(&self) -> impl Iterator<Item = IndexedOhlcv> + '_ {
self.buffer
.iter()
.take(self.zig_zag_period.left_bars as usize)
.copied()
}
fn right_partition(&self) -> impl Iterator<Item = IndexedOhlcv> + '_ {
self.buffer
.iter()
.rev()
.take(self.zig_zag_period.right_bars as usize)
.copied()
}
fn check_extremum(&self, pivot_type: PivotType) -> bool {
let candidate = self.candidate();
let candidate_price = pivot_type.extract_price(candidate.candle, self.price_source);
let (strict_left, strict_right) = match self.tiebreaker {
ExtremeTiebreaker::Earliest => (true, false),
ExtremeTiebreaker::Latest => (false, true),
};
let is_valid = |neighbor: IndexedOhlcv, strict: bool| -> bool {
let neighbor_price = pivot_type.extract_price(neighbor.candle, self.price_source);
match (pivot_type, strict) {
(PivotType::High, true) => candidate_price > neighbor_price,
(PivotType::High, false) => candidate_price >= neighbor_price,
(PivotType::Low, true) => candidate_price < neighbor_price,
(PivotType::Low, false) => candidate_price <= neighbor_price,
}
};
self.left_partition().all(|c| is_valid(c, strict_left))
&& self.right_partition().all(|c| is_valid(c, strict_right))
}
#[tracing::instrument(skip(self), fields(ts = %self.candidate().candle.close_timestamp))]
fn process_high(&mut self) -> Option<(MarketStructureEvent, PivotPoint)> {
let candidate = self.candidate();
let current_high_price = PivotType::High.extract_price(candidate.candle, self.price_source);
let resolution = match self.active_pivot {
Some(active)
if self.alternation_mode == AlternationMode::Alternating
&& active.pivot_type() == PivotType::High =>
{
let overwrite = match self.tiebreaker {
ExtremeTiebreaker::Earliest => current_high_price > active.price,
ExtremeTiebreaker::Latest => current_high_price >= active.price,
};
if overwrite {
CandidateResolution::ReplaceActive
} else {
CandidateResolution::Discard
}
}
Some(_) | None => CandidateResolution::ConfirmActive,
};
match resolution {
CandidateResolution::Discard => return None,
CandidateResolution::ReplaceActive => {
}
CandidateResolution::ConfirmActive => {
if let Some(active) = self.active_pivot {
match active.pivot_type() {
PivotType::High => self.anchor_high = Some(active),
PivotType::Low => self.anchor_low = Some(active),
}
self.history.push(active);
}
}
}
let (trend, event) = match self.anchor_high {
Some(anchor) => match current_high_price.partial_cmp(&anchor.price) {
Some(Ordering::Greater) => {
use MarketStructureSequence::*;
let market_structure_event = match self.anchor_low.map(|l| l.trend) {
Some(LowerLow) => MarketStructureEvent::MarketStructureShift,
Some(HigherLow | EqualLow | UnclassifiedLow) | None => {
MarketStructureEvent::BreakOfStructure
}
Some(
invalid_trend @ (HigherHigh | LowerHigh | EqualHigh | UnclassifiedHigh),
) => {
tracing::error!(
reason = "corrupted_state",
anchor_low_trend = ?invalid_trend,
"anchor_low contains a High pivot sequence. Defaulting to BreakOfStructure."
);
MarketStructureEvent::BreakOfStructure
}
};
(HigherHigh, market_structure_event)
}
Some(Ordering::Less) => (
MarketStructureSequence::LowerHigh,
MarketStructureEvent::NoChange,
),
Some(Ordering::Equal) => (
MarketStructureSequence::EqualHigh,
MarketStructureEvent::NoChange,
),
None => {
tracing::warn!(
reason = "nan_detected",
candidate_price = ?current_high_price,
anchor_price = ?anchor.price,
"Invalid float (NaN) detected. Discarding pivot to prevent state poisoning."
);
return None;
}
},
None => (
MarketStructureSequence::UnclassifiedHigh,
MarketStructureEvent::NoChange,
),
};
let new_pivot = PivotPoint {
indexed_candle: candidate,
price: current_high_price,
price_source: self.price_source,
trend,
};
self.active_pivot = Some(new_pivot);
if event != MarketStructureEvent::NoChange {
tracing::debug!(
event = ?event,
trend = ?trend,
price = ?current_high_price,
"Market Structure Extracted"
);
}
Some((event, new_pivot))
}
#[tracing::instrument(skip(self), fields(ts = %self.candidate().candle.close_timestamp))]
fn process_low(&mut self) -> Option<(MarketStructureEvent, PivotPoint)> {
let candidate = self.candidate();
let current_low_price = PivotType::Low.extract_price(candidate.candle, self.price_source);
let resolution = match self.active_pivot {
Some(active)
if self.alternation_mode == AlternationMode::Alternating
&& active.pivot_type() == PivotType::Low =>
{
let overwrite = match self.tiebreaker {
ExtremeTiebreaker::Earliest => current_low_price < active.price,
ExtremeTiebreaker::Latest => current_low_price <= active.price,
};
if overwrite {
CandidateResolution::ReplaceActive
} else {
CandidateResolution::Discard
}
}
Some(_) | None => CandidateResolution::ConfirmActive,
};
match resolution {
CandidateResolution::Discard => return None,
CandidateResolution::ReplaceActive => {
}
CandidateResolution::ConfirmActive => {
if let Some(active) = self.active_pivot {
match active.pivot_type() {
PivotType::High => self.anchor_high = Some(active),
PivotType::Low => self.anchor_low = Some(active),
}
self.history.push(active);
}
}
}
let (trend, event) = match self.anchor_low {
Some(anchor) => match current_low_price.partial_cmp(&anchor.price) {
Some(Ordering::Less) => {
use MarketStructureSequence::*;
let market_structure_event = match self.anchor_high.map(|h| h.trend) {
Some(HigherHigh) => MarketStructureEvent::MarketStructureShift,
Some(LowerHigh | EqualHigh | UnclassifiedHigh) | None => {
MarketStructureEvent::BreakOfStructure
}
Some(
invalid_trend @ (HigherLow | LowerLow | EqualLow | UnclassifiedLow),
) => {
tracing::error!(
reason = "corrupted_state",
anchor_high_trend = ?invalid_trend,
"anchor_high contains a Low pivot sequence. Defaulting to BreakOfStructure."
);
MarketStructureEvent::BreakOfStructure
}
};
(LowerLow, market_structure_event)
}
Some(Ordering::Greater) => (
MarketStructureSequence::HigherLow,
MarketStructureEvent::NoChange,
),
Some(Ordering::Equal) => (
MarketStructureSequence::EqualLow,
MarketStructureEvent::NoChange,
),
None => {
tracing::warn!(
reason = "nan_detected",
candidate_price = ?current_low_price,
anchor_price = ?anchor.price,
"Invalid float (NaN) detected. Discarding pivot to prevent state poisoning."
);
return None;
}
},
None => (
MarketStructureSequence::UnclassifiedLow,
MarketStructureEvent::NoChange,
),
};
let new_pivot = PivotPoint {
indexed_candle: candidate,
price: current_low_price,
price_source: self.price_source,
trend,
};
self.active_pivot = Some(new_pivot);
if event != MarketStructureEvent::NoChange {
tracing::debug!(
event = ?event,
trend = ?trend,
price = ?current_low_price,
"Market Structure Extracted"
);
}
Some((event, new_pivot))
}
}
impl StreamingIndicator for StreamingHhll {
type Input = IndexedOhlcv;
type Output<'a> = Option<(MarketStructureEvent, PivotPoint)>;
fn update(&mut self, indexed_candle: Self::Input) -> Self::Output<'_> {
let window_size = self.zig_zag_period.buffer_size();
self.buffer.push_back(indexed_candle);
if self.buffer.len() < window_size {
return None;
}
if self.buffer.len() > window_size {
self.buffer.pop_front();
}
let is_swing_high = self.check_extremum(PivotType::High);
let is_swing_low = self.check_extremum(PivotType::Low);
match (is_swing_high, is_swing_low) {
(true, true) => {
let candidate = self.candidate();
match candidate.candle.direction() {
CandleDirection::Bullish => self.process_high(),
CandleDirection::Bearish => self.process_low(),
CandleDirection::Doji => {
match self.active_pivot.map(|p| p.pivot_type()) {
Some(PivotType::Low) => self.process_low(),
Some(PivotType::High) => self.process_high(),
None => None, }
}
}
}
(true, false) => self.process_high(),
(false, true) => self.process_low(),
(false, false) => None,
}
}
fn reset(&mut self) {
self.buffer.clear();
self.active_pivot = None;
self.anchor_high = None;
self.anchor_low = None;
self.history.clear();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CandidateResolution {
Discard,
ReplaceActive,
ConfirmActive,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::domain::Quantity;
fn ts(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
fn candle(
index: usize,
time: &str,
open: f64,
high: f64,
low: f64,
close: f64,
) -> IndexedOhlcv {
IndexedOhlcv {
index,
candle: Ohlcv {
open_timestamp: ts(time),
close_timestamp: ts(time),
open: Price(open),
high: Price(high),
low: Price(low),
close: Price(close),
volume: Quantity(100.0),
quote_asset_volume: None,
number_of_trades: None,
taker_buy_base_asset_volume: None,
taker_buy_quote_asset_volume: None,
},
}
}
fn assert_f64_eq(a: f64, b: f64) {
assert!(
(a - b).abs() < f64::EPSILON,
"Expected {} to equal {}",
a,
b
);
}
fn create_indicator(left: u16, right: u16, tiebreaker: ExtremeTiebreaker) -> StreamingHhll {
let indicator = StreamingHhll::default()
.with_zig_zag_period(ZigZagPeriod {
left_bars: left,
right_bars: right,
})
.with_tiebreaker(tiebreaker)
.with_alternation_mode(AlternationMode::Alternating)
.with_price_source(PriceSource::HighLow);
assert!(indicator.active_pivot().is_none());
assert!(indicator.anchor_high().is_none());
assert!(indicator.anchor_low().is_none());
assert!(indicator.history().is_empty());
assert!(indicator.buffer.is_empty());
indicator
}
#[test]
fn test_pivot_type_extract_price() {
let bullish_candle = candle(0, "2026-05-24T10:00:00Z", 10., 20., 5., 15.).candle;
let bearish_candle = candle(1, "2026-05-24T10:01:00Z", 15., 20., 5., 10.).candle;
let doji_candle = candle(2, "2026-05-24T10:02:00Z", 15., 20., 5., 15.).candle;
assert_f64_eq(
PivotType::High
.extract_price(bullish_candle, PriceSource::HighLow)
.0,
20.,
);
assert_f64_eq(
PivotType::Low
.extract_price(bullish_candle, PriceSource::HighLow)
.0,
5.,
);
assert_f64_eq(
PivotType::High
.extract_price(bullish_candle, PriceSource::OpenClose)
.0,
15.,
);
assert_f64_eq(
PivotType::Low
.extract_price(bullish_candle, PriceSource::OpenClose)
.0,
10.,
);
assert_f64_eq(
PivotType::High
.extract_price(bearish_candle, PriceSource::OpenClose)
.0,
15.,
);
assert_f64_eq(
PivotType::Low
.extract_price(bearish_candle, PriceSource::OpenClose)
.0,
10.,
);
assert_f64_eq(
PivotType::High
.extract_price(doji_candle, PriceSource::OpenClose)
.0,
15.,
);
assert_f64_eq(
PivotType::Low
.extract_price(doji_candle, PriceSource::OpenClose)
.0,
15.,
);
}
#[test]
fn test_market_structure_sequence_to_pivot_type() {
use MarketStructureSequence::*;
let highs = vec![HigherHigh, LowerHigh, EqualHigh, UnclassifiedHigh];
for h in highs {
assert_eq!(h.as_pivot_type(), PivotType::High);
assert_eq!(PivotType::from(h), PivotType::High);
}
let lows = vec![HigherLow, LowerLow, EqualLow, UnclassifiedLow];
for l in lows {
assert_eq!(l.as_pivot_type(), PivotType::Low);
assert_eq!(PivotType::from(l), PivotType::Low);
}
}
#[test]
fn test_initial_classification_bos_vs_nochange() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
assert!(
hhll.update(candle(0, "2026-05-24T10:00:00Z", 10., 10., 10., 10.))
.is_none()
);
let event_1 = hhll.update(candle(1, "2026-05-24T10:01:00Z", 20., 20., 20., 20.));
assert!(event_1.is_none(), "Window is not full, must not emit");
let (e1, p1) = hhll
.update(candle(2, "2026-05-24T10:02:00Z", 10., 10., 10., 10.))
.unwrap();
assert_eq!(e1, MarketStructureEvent::NoChange);
assert_eq!(p1.trend, MarketStructureSequence::UnclassifiedHigh);
hhll.update(candle(3, "2026-05-24T10:03:00Z", 5., 5., 5., 5.));
let (e2, p2) = hhll
.update(candle(4, "2026-05-24T10:04:00Z", 10., 10., 10., 10.))
.unwrap();
assert_eq!(e2, MarketStructureEvent::NoChange);
assert_eq!(p2.trend, MarketStructureSequence::UnclassifiedLow);
hhll.update(candle(5, "2026-05-24T10:05:00Z", 30., 30., 30., 30.));
let (e3, p3) = hhll
.update(candle(6, "2026-05-24T10:06:00Z", 10., 10., 10., 10.))
.unwrap();
assert_eq!(e3, MarketStructureEvent::BreakOfStructure);
assert_eq!(p3.trend, MarketStructureSequence::HigherHigh);
hhll.update(candle(7, "2026-05-24T10:07:00Z", 2., 2., 2., 2.));
let (e4, p4) = hhll
.update(candle(8, "2026-05-24T10:08:00Z", 10., 10., 10., 10.))
.unwrap();
assert_eq!(e4, MarketStructureEvent::MarketStructureShift);
assert_eq!(p4.trend, MarketStructureSequence::LowerLow);
}
#[test]
fn test_pivot_point_interpolation() {
let p1 = PivotPoint {
indexed_candle: candle(10, "2026-05-24T15:00:00Z", 100., 100., 100., 100.),
price: Price(100.0),
price_source: PriceSource::HighLow,
trend: MarketStructureSequence::LowerLow,
};
let p2 = PivotPoint {
indexed_candle: candle(20, "2026-05-24T15:10:00Z", 150., 150., 150., 150.),
price: Price(150.0),
price_source: PriceSource::HighLow,
trend: MarketStructureSequence::HigherLow,
};
let line_by_idx = p1.price_line_by_index(&p2);
assert_f64_eq(line_by_idx(10).0, 100.0); assert_f64_eq(line_by_idx(15).0, 125.0); assert_f64_eq(line_by_idx(20).0, 150.0); assert_f64_eq(line_by_idx(25).0, 175.0);
let line_by_time = p1.price_line_by_point_in_time(&p2);
assert_f64_eq(line_by_time(ts("2026-05-24T15:00:00Z")).0, 100.0); assert_f64_eq(line_by_time(ts("2026-05-24T15:05:00Z")).0, 125.0); assert_f64_eq(line_by_time(ts("2026-05-24T15:10:00Z")).0, 150.0); assert_f64_eq(line_by_time(ts("2026-05-24T15:20:00Z")).0, 200.0); }
#[test]
fn test_pivot_point_flat_line_and_zero_division() {
let p1 = PivotPoint {
indexed_candle: candle(5, "2026-05-24T15:00:00Z", 100., 100., 100., 100.),
price: Price(100.0),
price_source: PriceSource::HighLow,
trend: MarketStructureSequence::LowerLow,
};
let p2 = PivotPoint {
indexed_candle: candle(5, "2026-05-24T15:00:00Z", 100., 100., 100., 100.),
price: Price(100.0),
price_source: PriceSource::HighLow,
trend: MarketStructureSequence::LowerLow,
};
let line = p1.price_line_by_index(&p2);
assert_f64_eq(line(10).0, 100.0);
}
#[test]
fn test_streaming_hhll_partitions_and_candidate() {
let mut hhll = create_indicator(2, 2, ExtremeTiebreaker::Latest);
for i in 0..5 {
hhll.buffer
.push_back(candle(i, "2026-05-24T10:00:00Z", 10., 10., 10., 10.));
}
assert_eq!(hhll.candidate().index, 2);
let left = hhll.left_partition().map(|c| c.index).collect::<Vec<_>>();
assert_eq!(left, vec![0, 1]);
let right = hhll.right_partition().map(|c| c.index).collect::<Vec<_>>();
assert_eq!(right, vec![4, 3]);
}
#[test]
fn test_check_extremum_isolated() {
{
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.buffer
.push_back(candle(0, "2026-05-24T10:00:00Z", 10., 10., 10., 10.));
hhll.buffer
.push_back(candle(1, "2026-05-24T10:01:00Z", 20., 20., 20., 20.));
hhll.buffer
.push_back(candle(2, "2026-05-24T10:02:00Z", 10., 10., 10., 10.));
assert!(
hhll.check_extremum(PivotType::High),
"Clear peak should be High"
);
assert!(
!hhll.check_extremum(PivotType::Low),
"Clear peak is not a Low"
);
}
{
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.buffer
.push_back(candle(0, "2026-05-24T10:00:00Z", 20., 20., 20., 20.));
hhll.buffer
.push_back(candle(1, "2026-05-24T10:01:00Z", 10., 10., 10., 10.));
hhll.buffer
.push_back(candle(2, "2026-05-24T10:02:00Z", 20., 20., 20., 20.));
assert!(
hhll.check_extremum(PivotType::Low),
"Clear trough should be Low"
);
assert!(
!hhll.check_extremum(PivotType::High),
"Clear trough is not a High"
);
}
{
let mut hhll_latest = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll_latest
.buffer
.push_back(candle(0, "2026-05-24T10:00:00Z", 20., 20., 20., 20.));
hhll_latest
.buffer
.push_back(candle(1, "2026-05-24T10:01:00Z", 20., 20., 20., 20.));
hhll_latest
.buffer
.push_back(candle(2, "2026-05-24T10:02:00Z", 10., 10., 10., 10.));
assert!(
hhll_latest.check_extremum(PivotType::High),
"Latest should pass on flat left side"
);
let mut hhll_earliest = create_indicator(1, 1, ExtremeTiebreaker::Earliest);
hhll_earliest
.buffer
.push_back(candle(0, "2026-05-24T10:00:00Z", 20., 20., 20., 20.));
hhll_earliest
.buffer
.push_back(candle(1, "2026-05-24T10:01:00Z", 20., 20., 20., 20.));
hhll_earliest
.buffer
.push_back(candle(2, "2026-05-24T10:02:00Z", 10., 10., 10., 10.));
assert!(
!hhll_earliest.check_extremum(PivotType::High),
"Earliest should fail on flat left side"
);
}
}
#[test]
fn test_sliding_window_plateaus_earliest() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Earliest);
hhll.update(candle(0, "2026-05-24T10:00:00Z", 10., 10., 10., 10.)); hhll.update(candle(1, "2026-05-24T10:01:00Z", 15., 15., 15., 15.));
let event_a = hhll.update(candle(2, "2026-05-24T10:02:00Z", 15., 15., 15., 15.));
assert!(
event_a.is_some(),
"Earliest policy should emit the first peak (A)."
);
assert_eq!(event_a.unwrap().1.indexed_candle.index, 1);
let event_b = hhll.update(candle(3, "2026-05-24T10:03:00Z", 15., 15., 15., 15.));
assert!(
event_b.is_none(),
"Earliest policy must discard middle plateau bars (B)."
);
let event_c = hhll.update(candle(4, "2026-05-24T10:04:00Z", 10., 10., 10., 10.));
assert!(
event_c.is_none(),
"Earliest policy must discard final plateau bars (C)."
);
}
#[test]
fn test_sliding_window_plateaus_earliest_low() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Earliest);
hhll.update(candle(0, "2026-05-24T10:00:00Z", 20., 20., 20., 20.)); hhll.update(candle(1, "2026-05-24T10:01:00Z", 10., 10., 10., 10.));
let event_a = hhll.update(candle(2, "2026-05-24T10:02:00Z", 10., 10., 10., 10.));
assert!(
event_a.is_some(),
"Earliest policy should emit the first trough (A)."
);
assert_eq!(event_a.unwrap().1.indexed_candle.index, 1);
let event_b = hhll.update(candle(3, "2026-05-24T10:03:00Z", 10., 10., 10., 10.));
assert!(event_b.is_none());
let event_c = hhll.update(candle(4, "2026-05-24T10:04:00Z", 20., 20., 20., 20.));
assert!(event_c.is_none());
}
#[test]
fn test_sliding_window_plateaus_latest() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.update(candle(0, "2026-05-24T10:00:00Z", 10., 10., 10., 10.)); hhll.update(candle(1, "2026-05-24T10:01:00Z", 15., 15., 15., 15.));
let event_a = hhll.update(candle(2, "2026-05-24T10:02:00Z", 15., 15., 15., 15.));
assert!(
event_a.is_none(),
"Latest policy must discard early plateau bars (A)."
);
let event_b = hhll.update(candle(3, "2026-05-24T10:03:00Z", 15., 15., 15., 15.));
assert!(
event_b.is_none(),
"Latest policy must discard middle plateau bars (B)."
);
let event_c = hhll.update(candle(4, "2026-05-24T10:04:00Z", 10., 10., 10., 10.));
assert!(
event_c.is_some(),
"Latest policy should emit the final peak (C)."
);
assert_eq!(event_c.unwrap().1.indexed_candle.index, 3);
}
#[test]
fn test_sliding_window_plateaus_latest_low() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.update(candle(0, "2026-05-24T10:00:00Z", 20., 20., 20., 20.)); hhll.update(candle(1, "2026-05-24T10:01:00Z", 10., 10., 10., 10.));
let event_a = hhll.update(candle(2, "2026-05-24T10:02:00Z", 10., 10., 10., 10.));
assert!(
event_a.is_none(),
"Latest policy must discard early plateau bars (A)."
);
let event_b = hhll.update(candle(3, "2026-05-24T10:03:00Z", 10., 10., 10., 10.));
assert!(event_b.is_none());
let event_c = hhll.update(candle(4, "2026-05-24T10:04:00Z", 20., 20., 20., 20.));
assert!(
event_c.is_some(),
"Latest policy should emit the final trough (C)."
);
assert_eq!(event_c.unwrap().1.indexed_candle.index, 3);
}
#[test]
fn test_mega_bar_bullish_and_bearish_routing() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.update(candle(0, "2026-05-24T10:00:00Z", 20., 20., 20., 20.));
hhll.update(candle(1, "2026-05-24T10:01:00Z", 10., 30., 5., 25.));
let event_bullish = hhll.update(candle(2, "2026-05-24T10:02:00Z", 20., 20., 20., 20.));
assert!(event_bullish.is_some());
assert_eq!(event_bullish.unwrap().1.pivot_type(), PivotType::High);
assert_eq!(event_bullish.unwrap().1.price.0, 30.0);
hhll.reset();
hhll.update(candle(0, "2026-05-24T10:00:00Z", 20., 20., 20., 20.));
hhll.update(candle(1, "2026-05-24T10:01:00Z", 25., 30., 5., 10.));
let event_bearish = hhll.update(candle(2, "2026-05-24T10:02:00Z", 20., 20., 20., 20.));
assert!(event_bearish.is_some());
assert_eq!(event_bearish.unwrap().1.pivot_type(), PivotType::Low);
assert_eq!(event_bearish.unwrap().1.price.0, 5.0);
}
#[test]
fn test_basic_swing_high_detection() {
let mut hhll = create_indicator(2, 2, ExtremeTiebreaker::Latest);
let trajectory = vec![
candle(0, "2026-05-24T15:01:00Z", 10., 10., 10., 10.), candle(1, "2026-05-24T15:02:00Z", 15., 15., 15., 15.), candle(2, "2026-05-24T15:03:00Z", 20., 20., 20., 20.), candle(3, "2026-05-24T15:04:00Z", 15., 15., 15., 15.), ];
for c in trajectory {
assert!(
hhll.update(c).is_none(),
"Should not emit before right window is full"
);
}
let event = hhll
.update(candle(4, "2026-05-24T15:05:00Z", 10., 10., 10., 10.))
.unwrap();
assert_eq!(event.1.pivot_type(), PivotType::High);
assert_eq!(event.1.price.0, 20.0);
assert_eq!(event.1.point_in_time(), ts("2026-05-24T15:03:00Z"));
}
#[test]
fn test_tiebreaker_double_top() {
let trajectory = vec![
candle(0, "2026-05-24T15:00:00Z", 10., 10., 10., 10.),
candle(1, "2026-05-24T15:01:00Z", 10., 10., 10., 10.),
candle(2, "2026-05-24T15:02:00Z", 20., 20., 20., 20.), candle(3, "2026-05-24T15:03:00Z", 20., 20., 20., 20.), candle(4, "2026-05-24T15:04:00Z", 10., 10., 10., 10.),
candle(5, "2026-05-24T15:05:00Z", 10., 10., 10., 10.),
];
let mut hhll_early = create_indicator(2, 2, ExtremeTiebreaker::Earliest);
let mut early_result = None;
for &c in &trajectory {
if let Some(res) = hhll_early.update(c) {
early_result = Some(res);
}
}
assert_eq!(
early_result.unwrap().1.point_in_time(),
ts("2026-05-24T15:02:00Z")
);
let mut hhll_late = create_indicator(2, 2, ExtremeTiebreaker::Latest);
let mut late_result = None;
for &c in &trajectory {
if let Some(res) = hhll_late.update(c) {
late_result = Some(res);
}
}
assert_eq!(
late_result.unwrap().1.point_in_time(),
ts("2026-05-24T15:03:00Z")
);
}
#[test]
fn test_tiebreaker_double_bottom() {
let trajectory = vec![
candle(0, "2026-05-24T15:00:00Z", 20., 50., 20., 20.),
candle(1, "2026-05-24T15:01:00Z", 20., 45., 20., 20.),
candle(2, "2026-05-24T15:02:00Z", 10., 40., 10., 10.), candle(3, "2026-05-24T15:03:00Z", 10., 35., 10., 10.), candle(4, "2026-05-24T15:04:00Z", 20., 30., 20., 20.),
candle(5, "2026-05-24T15:05:00Z", 20., 25., 20., 20.),
];
let mut hhll_early = create_indicator(2, 2, ExtremeTiebreaker::Earliest);
let mut early_result = None;
for &c in &trajectory {
if let Some(res) = hhll_early.update(c) {
early_result = Some(res);
}
}
assert_eq!(
early_result.unwrap().1.point_in_time(),
ts("2026-05-24T15:02:00Z")
);
let mut hhll_late = create_indicator(2, 2, ExtremeTiebreaker::Latest);
let mut late_result = None;
for &c in &trajectory {
if let Some(res) = hhll_late.update(c) {
late_result = Some(res);
}
}
assert_eq!(
late_result.unwrap().1.point_in_time(),
ts("2026-05-24T15:03:00Z")
);
}
#[test]
fn test_alternation_filter_overwrites_noise() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.update(candle(0, "2026-05-24T10:00:00Z", 10., 10., 1., 10.));
let p1 = hhll.update(candle(1, "2026-05-24T10:01:00Z", 20., 20., 2., 20.));
assert!(p1.is_none());
let p1_confirmed = hhll
.update(candle(2, "2026-05-24T10:02:00Z", 15., 15., 3., 15.))
.unwrap()
.1;
assert_eq!(p1_confirmed.price.0, 20.0);
assert_eq!(p1_confirmed.pivot_type(), PivotType::High);
assert_eq!(hhll.active_pivot().unwrap().price.0, 20.0);
assert!(
hhll.history().is_empty(),
"History must be empty before confirmation"
);
hhll.update(candle(3, "2026-05-24T10:03:00Z", 15., 15., 4., 15.));
let p2 = hhll.update(candle(4, "2026-05-24T10:04:00Z", 30., 30., 5., 30.));
assert!(
p2.is_none(),
"Candidate is the intermediate dip, must not emit"
);
let p2_confirmed = hhll
.update(candle(5, "2026-05-24T10:05:00Z", 10., 10., 6., 10.))
.unwrap()
.1;
assert_eq!(p2_confirmed.price.0, 30.0);
assert_eq!(p2_confirmed.pivot_type(), PivotType::High);
assert_eq!(hhll.history().len(), 0);
assert_eq!(hhll.active_pivot().unwrap().price.0, 30.0);
}
#[test]
fn test_macro_tiebreaker_equal_peaks_earliest() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Earliest);
let trajectory = vec![
candle(0, "2026-05-24T10:00:00Z", 10., 10., 1., 10.),
candle(1, "2026-05-24T10:01:00Z", 20., 20., 2., 20.), candle(2, "2026-05-24T10:02:00Z", 15., 15., 3., 15.),
candle(3, "2026-05-24T10:03:00Z", 15., 15., 4., 15.), candle(4, "2026-05-24T10:04:00Z", 20., 20., 5., 20.), candle(5, "2026-05-24T10:05:00Z", 10., 10., 6., 10.),
];
let mut events = Vec::new();
for c in trajectory {
if let Some(event) = hhll.update(c) {
events.push(event);
}
}
assert_eq!(events.len(), 1);
assert_eq!(events[0].1.point_in_time(), ts("2026-05-24T10:01:00Z"));
assert_eq!(
hhll.active_pivot.unwrap().point_in_time(),
ts("2026-05-24T10:01:00Z")
);
}
#[test]
fn test_macro_tiebreaker_equal_peaks_latest() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
let trajectory = vec![
candle(0, "2026-05-24T10:00:00Z", 10., 10., 1., 10.),
candle(1, "2026-05-24T10:01:00Z", 20., 20., 2., 20.), candle(2, "2026-05-24T10:02:00Z", 15., 15., 3., 15.),
candle(3, "2026-05-24T10:03:00Z", 15., 15., 4., 15.), candle(4, "2026-05-24T10:04:00Z", 20., 20., 5., 20.), candle(5, "2026-05-24T10:05:00Z", 10., 10., 6., 10.),
];
let mut events = Vec::new();
for c in trajectory {
if let Some(event) = hhll.update(c) {
events.push(event);
}
}
assert_eq!(events.len(), 2);
assert_eq!(events[0].1.point_in_time(), ts("2026-05-24T10:01:00Z")); assert_eq!(events[1].1.point_in_time(), ts("2026-05-24T10:04:00Z"));
assert_eq!(
hhll.active_pivot.unwrap().point_in_time(),
ts("2026-05-24T10:04:00Z")
);
}
#[test]
fn test_history_invariant_alternating_mode() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
let trajectory = vec![
candle(0, "2026-05-24T10:00:00Z", 10., 10., 1., 10.),
candle(1, "2026-05-24T10:01:00Z", 20., 20., 2., 20.), candle(2, "2026-05-24T10:02:00Z", 15., 15., 3., 15.), candle(3, "2026-05-24T10:03:00Z", 30., 30., 4., 30.), candle(4, "2026-05-24T10:04:00Z", 10., 10., 5., 10.),
];
for c in trajectory {
let _ = hhll.update(c);
}
assert_eq!(hhll.active_pivot.unwrap().price.0, 30.0);
assert_eq!(
hhll.history().len(),
0,
"History should not contain overwritten pivots"
);
let _ = hhll.update(candle(5, "2026-05-24T10:05:00Z", 10., 10., -10., 10.)); let _ = hhll.update(candle(6, "2026-05-24T10:06:00Z", 15., 15., -5., 15.));
assert_eq!(hhll.history().len(), 1);
assert_eq!(hhll.history()[0].price.0, 30.0);
assert_eq!(hhll.history()[0].pivot_type(), PivotType::High);
}
#[test]
fn test_history_invariant_consecutive_mode() {
let mut hhll = StreamingHhll::default()
.with_zig_zag_period(ZigZagPeriod {
left_bars: 1,
right_bars: 1,
})
.with_alternation_mode(AlternationMode::Consecutive) .with_tiebreaker(ExtremeTiebreaker::Latest)
.with_price_source(PriceSource::HighLow);
let trajectory = vec![
candle(0, "2026-05-24T10:00:00Z", 10., 10., 1., 10.),
candle(1, "2026-05-24T10:01:00Z", 20., 20., 2., 20.), candle(2, "2026-05-24T10:02:00Z", 15., 15., 3., 15.),
candle(3, "2026-05-24T10:03:00Z", 30., 30., 4., 30.), candle(4, "2026-05-24T10:04:00Z", 10., 10., 5., 10.),
candle(5, "2026-05-24T10:05:00Z", 40., 40., 6., 40.), candle(6, "2026-05-24T10:06:00Z", 10., 10., 7., 10.),
];
for c in trajectory {
let _ = hhll.update(c);
}
assert_eq!(hhll.active_pivot.unwrap().price.0, 40.0);
assert_eq!(
hhll.history().len(),
2,
"Consecutive mode should lock all previous peaks"
);
assert_eq!(hhll.history()[0].price.0, 20.0);
assert_eq!(hhll.history()[1].price.0, 30.0);
}
#[test]
fn test_mega_doji_extends_high() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.update(candle(0, "2026-05-24T13:59:00Z", 10., 10., 10., 10.));
hhll.update(candle(1, "2026-05-24T14:00:00Z", 50., 50., 10., 50.));
let p1 = hhll.update(candle(2, "2026-05-24T14:01:00Z", 20., 20., 10., 20.));
assert!(p1.is_some());
assert_eq!(hhll.active_pivot().unwrap().pivot_type(), PivotType::High);
assert_eq!(hhll.active_pivot().unwrap().price.0, 50.0);
assert!(
hhll.update(candle(3, "2026-05-24T15:01:00Z", 20., 20., 10., 20.))
.is_none()
);
assert!(
hhll.update(candle(4, "2026-05-24T15:02:00Z", 25., 60., 5., 25.))
.is_none()
);
let event = hhll.update(candle(5, "2026-05-24T15:03:00Z", 20., 20., 10., 20.));
assert!(
event.is_some(),
"Expected Doji to successfully extend the High, but it was discarded"
);
let (_, pivot) = event.unwrap();
assert_eq!(pivot.pivot_type(), PivotType::High);
assert_eq!(pivot.price.0, 60.0);
}
#[test]
fn test_mega_doji_extends_low() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.update(candle(0, "2026-05-24T13:59:00Z", 50., 50., 50., 50.));
hhll.update(candle(1, "2026-05-24T14:00:00Z", 50., 50., 10., 50.));
let p1 = hhll.update(candle(2, "2026-05-24T14:01:00Z", 50., 50., 20., 50.));
assert!(p1.is_some());
assert_eq!(hhll.active_pivot().unwrap().pivot_type(), PivotType::Low);
assert_eq!(hhll.active_pivot().unwrap().price.0, 10.0);
assert!(
hhll.update(candle(3, "2026-05-24T15:01:00Z", 50., 50., 20., 50.))
.is_none()
);
assert!(
hhll.update(candle(4, "2026-05-24T15:02:00Z", 25., 60., 5., 25.))
.is_none()
);
let event = hhll.update(candle(5, "2026-05-24T15:03:00Z", 50., 50., 20., 50.));
assert!(event.is_some(), "Expected Doji to extend the Low");
let (_, pivot) = event.unwrap();
assert_eq!(pivot.pivot_type(), PivotType::Low);
assert_eq!(pivot.price.0, 5.0);
}
#[test]
fn test_mega_doji_discarded_as_noise() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.update(candle(0, "2026-05-24T13:59:00Z", 50., 50., 50., 50.));
hhll.update(candle(1, "2026-05-24T14:00:00Z", 50., 50., 1., 50.));
let p1 = hhll.update(candle(2, "2026-05-24T14:01:00Z", 50., 50., 20., 50.));
assert!(p1.is_some());
assert_eq!(hhll.active_pivot().unwrap().price.0, 1.0);
assert!(
hhll.update(candle(3, "2026-05-24T17:01:00Z", 50., 50., 20., 50.))
.is_none()
);
assert!(
hhll.update(candle(4, "2026-05-24T17:02:00Z", 25., 60., 5., 25.))
.is_none()
);
let event_noise = hhll.update(candle(5, "2026-05-24T17:03:00Z", 50., 50., 20., 50.));
assert!(
event_noise.is_none(),
"Expected Doji to be discarded as internal noise"
);
}
#[test]
fn test_mega_doji_extends_high_earliest() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Earliest);
hhll.update(candle(0, "2026-05-24T13:59:00Z", 10., 10., 10., 10.));
hhll.update(candle(1, "2026-05-24T14:00:00Z", 50., 50., 10., 50.));
let p1 = hhll.update(candle(2, "2026-05-24T14:01:00Z", 20., 20., 10., 20.));
assert!(p1.is_some());
assert!(
hhll.update(candle(3, "2026-05-24T15:01:00Z", 20., 20., 10., 20.))
.is_none()
);
assert!(
hhll.update(candle(4, "2026-05-24T15:02:00Z", 25., 60., 5., 25.))
.is_none()
);
let event = hhll.update(candle(5, "2026-05-24T15:03:00Z", 20., 20., 10., 20.));
assert!(
event.is_some(),
"Expected Doji to successfully extend the High under Earliest"
);
assert_eq!(event.unwrap().1.price.0, 60.0);
}
#[test]
fn test_mega_doji_exact_tie_earliest_discarded() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Earliest);
hhll.update(candle(0, "2026-05-24T13:59:00Z", 10., 10., 10., 10.));
hhll.update(candle(1, "2026-05-24T14:00:00Z", 50., 50., 10., 50.));
hhll.update(candle(2, "2026-05-24T14:01:00Z", 20., 20., 10., 20.));
hhll.update(candle(3, "2026-05-24T15:01:00Z", 20., 20., 10., 20.));
hhll.update(candle(4, "2026-05-24T15:02:00Z", 25., 50., 5., 25.));
let event = hhll.update(candle(5, "2026-05-24T15:03:00Z", 20., 20., 10., 20.));
assert!(
event.is_none(),
"Expected exact tie Doji to be discarded under Earliest tiebreaker"
);
}
#[test]
fn test_mega_doji_exact_tie_latest_overwrites() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.update(candle(0, "2026-05-24T13:59:00Z", 10., 10., 10., 10.));
hhll.update(candle(1, "2026-05-24T14:00:00Z", 50., 50., 10., 50.));
hhll.update(candle(2, "2026-05-24T14:01:00Z", 20., 20., 10., 20.));
hhll.update(candle(3, "2026-05-24T15:01:00Z", 20., 20., 10., 20.));
hhll.update(candle(4, "2026-05-24T15:02:00Z", 25., 50., 5., 25.));
let event = hhll.update(candle(5, "2026-05-24T15:03:00Z", 20., 20., 10., 20.));
assert!(
event.is_some(),
"Expected exact tie Doji to overwrite under Latest tiebreaker"
);
let (_, pivot) = event.unwrap();
assert_eq!(pivot.price.0, 50.0);
assert_eq!(pivot.point_in_time(), ts("2026-05-24T15:02:00Z"));
}
#[test]
fn test_mega_doji_extends_low_earliest() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Earliest);
hhll.update(candle(0, "2026-05-24T13:59:00Z", 50., 50., 50., 50.));
hhll.update(candle(1, "2026-05-24T14:00:00Z", 50., 50., 10., 50.));
let p1 = hhll.update(candle(2, "2026-05-24T14:01:00Z", 50., 50., 20., 50.));
assert!(p1.is_some());
assert_eq!(hhll.active_pivot().unwrap().pivot_type(), PivotType::Low);
assert_eq!(hhll.active_pivot().unwrap().price.0, 10.0);
assert!(
hhll.update(candle(3, "2026-05-24T15:01:00Z", 50., 50., 20., 50.))
.is_none()
);
assert!(
hhll.update(candle(4, "2026-05-24T15:02:00Z", 25., 60., 5., 25.))
.is_none()
);
let event = hhll.update(candle(5, "2026-05-24T15:03:00Z", 50., 50., 20., 50.));
assert!(
event.is_some(),
"Expected Doji to successfully extend the Low under Earliest"
);
assert_eq!(event.unwrap().1.price.0, 5.0);
}
#[test]
fn test_mega_doji_exact_tie_earliest_discarded_low() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Earliest);
hhll.update(candle(0, "2026-05-24T13:59:00Z", 50., 50., 50., 50.));
hhll.update(candle(1, "2026-05-24T14:00:00Z", 50., 50., 10., 50.));
hhll.update(candle(2, "2026-05-24T14:01:00Z", 50., 50., 20., 50.));
hhll.update(candle(3, "2026-05-24T15:01:00Z", 50., 50., 20., 50.));
hhll.update(candle(4, "2026-05-24T15:02:00Z", 25., 60., 10., 25.));
let event = hhll.update(candle(5, "2026-05-24T15:03:00Z", 50., 50., 20., 50.));
assert!(
event.is_none(),
"Expected exact tie Doji to be discarded under Earliest tiebreaker"
);
}
#[test]
fn test_mega_doji_exact_tie_latest_overwrites_low() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.update(candle(0, "2026-05-24T13:59:00Z", 50., 50., 50., 50.));
hhll.update(candle(1, "2026-05-24T14:00:00Z", 50., 50., 10., 50.));
hhll.update(candle(2, "2026-05-24T14:01:00Z", 50., 50., 20., 50.));
hhll.update(candle(3, "2026-05-24T15:01:00Z", 50., 50., 20., 50.));
hhll.update(candle(4, "2026-05-24T15:02:00Z", 25., 60., 10., 25.));
let event = hhll.update(candle(5, "2026-05-24T15:03:00Z", 50., 50., 20., 50.));
assert!(
event.is_some(),
"Expected exact tie Doji to overwrite under Latest tiebreaker"
);
let (_, pivot) = event.unwrap();
assert_eq!(pivot.price.0, 10.0);
assert_eq!(pivot.point_in_time(), ts("2026-05-24T15:02:00Z"));
}
#[test]
fn test_initial_orphaned_mega_doji_safely_ignored() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
assert!(hhll.active_pivot().is_none());
hhll.update(candle(0, "2026-05-24T10:00:00Z", 20., 20., 20., 20.));
hhll.update(candle(1, "2026-05-24T10:01:00Z", 20., 30., 10., 20.));
let event = hhll.update(candle(2, "2026-05-24T10:02:00Z", 20., 20., 20., 20.));
assert!(
event.is_none(),
"Expected initial Mega Doji to be safely discarded, but it emitted an event."
);
}
#[test]
fn test_inside_bar_never_triggers() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.update(candle(0, "2026-05-24T10:00:00Z", 50., 100., 10., 50.));
hhll.update(candle(1, "2026-05-24T10:01:00Z", 50., 60., 40., 50.));
let event = hhll.update(candle(2, "2026-05-24T10:02:00Z", 50., 50., 50., 50.));
assert!(
event.is_none(),
"An inside bar should never emit a pivot event"
);
}
#[test]
fn test_nan_price_corruption_resistance() {
let mut hhll = create_indicator(1, 1, ExtremeTiebreaker::Latest);
hhll.update(candle(0, "2026-05-24T13:59:00Z", 10., 10., 10., 10.));
hhll.update(candle(1, "2026-05-24T14:00:00Z", 50., 50., 50., 50.));
hhll.update(candle(2, "2026-05-24T14:01:00Z", 10., 10., 10., 10.));
hhll.update(candle(3, "2026-05-24T14:02:00Z", 5., 5., 5., 5.));
hhll.update(candle(4, "2026-05-24T14:03:00Z", 10., 10., 10., 10.));
assert!(
hhll.anchor_high().is_some(),
"Anchor High must be securely established"
);
hhll.update(candle(5, "2026-05-24T15:01:00Z", 10., 10., 10., 10.));
hhll.update(candle(
6,
"2026-05-24T15:02:00Z",
f64::NAN,
f64::NAN,
f64::NAN,
f64::NAN,
));
let event = hhll.update(candle(7, "2026-05-24T15:03:00Z", 10., 10., 10., 10.));
assert!(
event.is_none(),
"Indicator failed to trap NaN price and allowed state corruption."
);
}
}