use crate::model::Bar;
use super::zigzag_advanced::ZigZagNode;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TrendLine {
pub start: (i64, f64),
pub end: (i64, f64),
}
impl TrendLine {
pub fn from_nodes(a: &ZigZagNode, b: &ZigZagNode) -> Self {
Self {
start: (a.timestamp, a.price),
end: (b.timestamp, b.price),
}
}
pub fn slope(&self) -> f64 {
let dt = (self.end.0 - self.start.0) as f64;
if dt == 0.0 {
return 0.0;
}
(self.end.1 - self.start.1) / dt
}
pub fn value_at(&self, timestamp: i64) -> f64 {
self.start.1 + self.slope() * (timestamp - self.start.0) as f64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PatternState {
Forming,
Confirmed,
Invalidated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChartPatternKind {
Triangle,
RisingWedge,
FallingWedge,
ReversalOneTwoThree,
WolfeWave,
AutoTrendline,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChartPatternCandidate {
pub kind: ChartPatternKind,
pub nodes: Vec<ZigZagNode>,
pub upper_line: Option<TrendLine>,
pub lower_line: Option<TrendLine>,
pub state: PatternState,
pub confidence: f64,
}
impl ChartPatternCandidate {
fn formed_at(&self) -> i64 {
self.nodes.last().map(|n| n.timestamp).unwrap_or(0)
}
fn node_range(&self) -> (i64, i64) {
let start = self.nodes.first().map(|n| n.timestamp).unwrap_or(0);
let end = self.formed_at();
(start, end)
}
pub fn update_state(&mut self, bar: &Bar) -> PatternState {
if self.state != PatternState::Forming {
return self.state;
}
self.state = match self.kind {
ChartPatternKind::Triangle
| ChartPatternKind::RisingWedge
| ChartPatternKind::FallingWedge => match (&self.upper_line, &self.lower_line) {
(Some(upper), Some(lower)) => {
if bar.close > upper.value_at(bar.timestamp)
|| bar.close < lower.value_at(bar.timestamp)
{
PatternState::Confirmed
} else {
PatternState::Forming
}
}
_ => PatternState::Forming,
},
ChartPatternKind::AutoTrendline => match self.upper_line.or(self.lower_line) {
Some(line) => {
let is_resistance = self.upper_line.is_some();
let broke = if is_resistance {
bar.close > line.value_at(bar.timestamp)
} else {
bar.close < line.value_at(bar.timestamp)
};
if broke {
PatternState::Confirmed
} else {
PatternState::Forming
}
}
None => PatternState::Forming,
},
ChartPatternKind::ReversalOneTwoThree => {
let (n2, n3) = (&self.nodes[1], &self.nodes[2]);
let bearish = n2.is_high;
if bearish {
if bar.close < n3.price {
PatternState::Confirmed
} else if bar.close > n2.price {
PatternState::Invalidated
} else {
PatternState::Forming
}
} else if bar.close > n3.price {
PatternState::Confirmed
} else if bar.close < n2.price {
PatternState::Invalidated
} else {
PatternState::Forming
}
}
ChartPatternKind::WolfeWave => {
let n5 = self.nodes[4];
let target_line = TrendLine::from_nodes(&self.nodes[0], &self.nodes[3]); let target = target_line.value_at(bar.timestamp);
let reverting_toward_target = if n5.is_high {
bar.close < n5.price && bar.close >= target.min(n5.price)
} else {
bar.close > n5.price && bar.close <= target.max(n5.price)
};
let continuing_past_five = if n5.is_high {
bar.close > n5.price
} else {
bar.close < n5.price
};
if reverting_toward_target {
PatternState::Confirmed
} else if continuing_past_five {
PatternState::Invalidated
} else {
PatternState::Forming
}
}
};
self.state
}
}
pub struct ChartPatternDetector {
pub tolerance_pct: f64,
}
impl ChartPatternDetector {
pub fn new(tolerance_pct: f64) -> Self {
Self {
tolerance_pct: tolerance_pct.max(0.001),
}
}
pub fn scan(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
let mut candidates = Vec::new();
candidates.extend(self.scan_triangles_and_wedges(nodes));
candidates.extend(self.scan_reversal_one_two_three(nodes));
candidates.extend(self.scan_wolfe_waves(nodes));
if let Some(trendline) = self.auto_trendline(nodes, true) {
candidates.push(trendline);
}
if let Some(trendline) = self.auto_trendline(nodes, false) {
candidates.push(trendline);
}
self.evict_overlaps(candidates)
}
fn evict_overlaps(&self, candidates: Vec<ChartPatternCandidate>) -> Vec<ChartPatternCandidate> {
let mut by_kind: Vec<(ChartPatternKind, Vec<ChartPatternCandidate>)> = Vec::new();
for candidate in candidates {
match by_kind.iter_mut().find(|(k, _)| *k == candidate.kind) {
Some((_, group)) => group.push(candidate),
None => by_kind.push((candidate.kind, vec![candidate])),
}
}
let mut kept: Vec<ChartPatternCandidate> = Vec::new();
for (_, mut group) in by_kind {
group.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
'outer: for candidate in group {
let (c_start, c_end) = candidate.node_range();
for existing in &kept {
if existing.kind != candidate.kind {
continue;
}
let (e_start, e_end) = existing.node_range();
let overlaps = c_start <= e_end && e_start <= c_end;
if overlaps {
continue 'outer;
}
}
kept.push(candidate);
}
}
kept.sort_by_key(|c| c.formed_at());
kept
}
fn scan_triangles_and_wedges(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
let mut out = Vec::new();
for window in nodes.windows(4) {
let alternating = window.windows(2).all(|p| p[0].is_high != p[1].is_high);
if !alternating {
continue;
}
let highs: Vec<&ZigZagNode> = window.iter().filter(|n| n.is_high).collect();
let lows: Vec<&ZigZagNode> = window.iter().filter(|n| !n.is_high).collect();
if highs.len() != 2 || lows.len() != 2 {
continue;
}
let upper = TrendLine::from_nodes(highs[0], highs[1]);
let lower = TrendLine::from_nodes(lows[0], lows[1]);
let (start_ts, end_ts) = (
window.first().unwrap().timestamp,
window.last().unwrap().timestamp,
);
let gap_start = upper.value_at(start_ts) - lower.value_at(start_ts);
let gap_end = upper.value_at(end_ts) - lower.value_at(end_ts);
if gap_start <= 0.0 || gap_end <= 0.0 || gap_end >= gap_start {
continue; }
let convergence = 1.0 - (gap_end / gap_start);
let flat_tol = self.tolerance_pct / 100.0;
let upper_flat = upper.slope().abs() / gap_start.max(1e-9) < flat_tol;
let lower_flat = lower.slope().abs() / gap_start.max(1e-9) < flat_tol;
let kind = if upper.slope() > 0.0 && lower.slope() > 0.0 {
ChartPatternKind::RisingWedge
} else if upper.slope() < 0.0 && lower.slope() < 0.0 {
ChartPatternKind::FallingWedge
} else if (upper.slope() <= 0.0 || upper_flat) && (lower.slope() >= 0.0 || lower_flat) {
ChartPatternKind::Triangle
} else {
continue;
};
out.push(ChartPatternCandidate {
kind,
nodes: window.to_vec(),
upper_line: Some(upper),
lower_line: Some(lower),
state: PatternState::Forming,
confidence: convergence.clamp(0.0, 1.0),
});
}
out
}
fn scan_reversal_one_two_three(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
let mut out = Vec::new();
for window in nodes.windows(3) {
let (n1, n2, n3) = (&window[0], &window[1], &window[2]);
if n1.is_high == n2.is_high || n2.is_high == n3.is_high {
continue;
}
let bearish = !n1.is_high && n2.is_high && !n3.is_high && n3.price < n1.price;
let bullish = n1.is_high && !n2.is_high && n3.is_high && n3.price > n1.price;
if !bearish && !bullish {
continue;
}
let magnitude = (n3.price - n1.price).abs() / n1.price.abs().max(1e-9);
out.push(ChartPatternCandidate {
kind: ChartPatternKind::ReversalOneTwoThree,
nodes: window.to_vec(),
upper_line: None,
lower_line: None,
state: PatternState::Forming,
confidence: magnitude.min(1.0),
});
}
out
}
fn scan_wolfe_waves(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
let mut out = Vec::new();
for window in nodes.windows(5) {
let alternating = window.windows(2).all(|p| p[0].is_high != p[1].is_high);
if !alternating {
continue;
}
let (n1, n2, n3, n4, n5) = (&window[0], &window[1], &window[2], &window[3], &window[4]);
let line13 = TrendLine::from_nodes(n1, n3);
let line24 = TrendLine::from_nodes(n2, n4);
let scale = (n1.price.abs() + n3.price.abs()).max(1e-9);
let slope_diff = (line13.slope() - line24.slope()).abs() / scale;
let parallel_tol = self.tolerance_pct / 100.0 * 5.0;
if slope_diff > parallel_tol {
continue;
}
let projected13_at5 = line13.value_at(n5.timestamp);
let pierces = if n1.is_high {
!n5.is_high && n5.price < projected13_at5
} else {
n5.is_high && n5.price > projected13_at5
};
if !pierces {
continue;
}
let confidence = (1.0 - slope_diff / parallel_tol.max(1e-9)).clamp(0.0, 1.0);
out.push(ChartPatternCandidate {
kind: ChartPatternKind::WolfeWave,
nodes: window.to_vec(),
upper_line: Some(if n1.is_high { line24 } else { line13 }),
lower_line: Some(if n1.is_high { line13 } else { line24 }),
state: PatternState::Forming,
confidence,
});
}
out
}
fn auto_trendline(
&self,
nodes: &[ZigZagNode],
for_highs: bool,
) -> Option<ChartPatternCandidate> {
let same_type: Vec<&ZigZagNode> = nodes.iter().filter(|n| n.is_high == for_highs).collect();
if same_type.len() < 2 {
return None;
}
let first = *same_type.first().unwrap();
let last = *same_type.last().unwrap();
let line = TrendLine::from_nodes(first, last);
let violated = same_type.iter().any(|n| {
let projected = line.value_at(n.timestamp);
if for_highs {
n.price > projected * (1.0 + self.tolerance_pct / 100.0)
} else {
n.price < projected * (1.0 - self.tolerance_pct / 100.0)
}
});
if violated {
return None;
}
let touches = same_type.len();
let confidence = ((touches as f64 - 2.0) / 4.0 + 0.5).clamp(0.0, 1.0);
Some(ChartPatternCandidate {
kind: ChartPatternKind::AutoTrendline,
nodes: same_type.into_iter().copied().collect(),
upper_line: for_highs.then_some(line),
lower_line: (!for_highs).then_some(line),
state: PatternState::Forming,
confidence,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn node(ts: i64, price: f64, is_high: bool) -> ZigZagNode {
ZigZagNode {
timestamp: ts,
price,
is_high,
confirmed: true,
}
}
#[test]
fn test_trendline_value_at_interpolates() {
let a = node(0, 100.0, true);
let b = node(100, 200.0, true);
let line = TrendLine::from_nodes(&a, &b);
assert!((line.value_at(50) - 150.0).abs() < 1e-9);
}
#[test]
fn test_detects_converging_triangle() {
let nodes = vec![
node(0, 110.0, true),
node(10, 90.0, false),
node(20, 105.0, true),
node(30, 95.0, false),
];
let detector = ChartPatternDetector::new(50.0);
let candidates = detector.scan(&nodes);
assert!(candidates
.iter()
.any(|c| c.kind == ChartPatternKind::Triangle));
}
#[test]
fn test_detects_bearish_one_two_three_reversal() {
let nodes = vec![
node(0, 100.0, false),
node(10, 110.0, true),
node(20, 95.0, false),
];
let detector = ChartPatternDetector::new(1.0);
let candidates = detector.scan(&nodes);
assert!(candidates
.iter()
.any(|c| c.kind == ChartPatternKind::ReversalOneTwoThree));
}
#[test]
fn test_auto_trendline_rejects_violated_support() {
let nodes = vec![
node(0, 100.0, false),
node(5, 105.0, true),
node(10, 90.0, false),
node(15, 102.0, true),
node(20, 95.0, false),
];
let detector = ChartPatternDetector::new(0.1);
let candidates = detector.scan(&nodes);
assert!(!candidates.iter().any(
|c| c.kind == ChartPatternKind::AutoTrendline && c.nodes.iter().all(|n| !n.is_high)
));
}
#[test]
fn test_evict_overlaps_keeps_only_highest_confidence_within_same_kind() {
let nodes = vec![
node(0, 130.0, true),
node(10, 70.0, false),
node(20, 120.0, true),
node(30, 80.0, false),
node(40, 110.0, true),
node(50, 90.0, false),
];
let detector = ChartPatternDetector::new(50.0);
let candidates = detector.scan(&nodes);
for kind in [
ChartPatternKind::Triangle,
ChartPatternKind::RisingWedge,
ChartPatternKind::FallingWedge,
] {
let same_kind: Vec<&ChartPatternCandidate> =
candidates.iter().filter(|c| c.kind == kind).collect();
for (i, a) in same_kind.iter().enumerate() {
for b in same_kind.iter().skip(i + 1) {
let (a_start, a_end) = a.node_range();
let (b_start, b_end) = b.node_range();
assert!(
a_end < b_start || b_end < a_start,
"overlapping candidates of the same kind must have been evicted"
);
}
}
}
assert!(!candidates.is_empty());
}
#[test]
fn test_triangle_confirms_on_breakout() {
let nodes = vec![
node(0, 110.0, true),
node(10, 90.0, false),
node(20, 105.0, true),
node(30, 95.0, false),
];
let detector = ChartPatternDetector::new(50.0);
let mut candidates = detector.scan(&nodes);
let triangle = candidates
.iter_mut()
.find(|c| c.kind == ChartPatternKind::Triangle)
.unwrap();
let inside = Bar::new(35, 100.0, 100.5, 99.5, 100.0, 1.0);
assert_eq!(triangle.update_state(&inside), PatternState::Forming);
let breakout = Bar::new(40, 130.0, 130.5, 129.5, 130.0, 1.0);
assert_eq!(triangle.update_state(&breakout), PatternState::Confirmed);
let after = Bar::new(50, 50.0, 50.5, 49.5, 50.0, 1.0);
assert_eq!(triangle.update_state(&after), PatternState::Confirmed);
}
#[test]
fn test_reversal_one_two_three_confirms_and_invalidates() {
let confirm_nodes = vec![
node(0, 100.0, false),
node(10, 110.0, true),
node(20, 95.0, false),
];
let mut confirm_candidate = ChartPatternDetector::new(1.0)
.scan(&confirm_nodes)
.into_iter()
.find(|c| c.kind == ChartPatternKind::ReversalOneTwoThree)
.unwrap();
let breaks_below_n3 = Bar::new(30, 90.0, 90.5, 89.5, 90.0, 1.0);
assert_eq!(
confirm_candidate.update_state(&breaks_below_n3),
PatternState::Confirmed
);
let mut invalidate_candidate = ChartPatternDetector::new(1.0)
.scan(&confirm_nodes)
.into_iter()
.find(|c| c.kind == ChartPatternKind::ReversalOneTwoThree)
.unwrap();
let reclaims_above_n2 = Bar::new(30, 115.0, 115.5, 114.5, 115.0, 1.0);
assert_eq!(
invalidate_candidate.update_state(&reclaims_above_n2),
PatternState::Invalidated
);
}
}