Skip to main content

kestrel_chartkit/indicator/
pivots_structure.rs

1use std::collections::HashMap;
2
3use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5
6/// Market Structure & Pivot Points Engine.
7/// Detects pivot highs/lows (HH/HL/LH/LL) and computes bounded structure score (-100..+100).
8pub struct PivotStructureEngine {
9    left_bars: usize,
10    right_bars: usize,
11    score_window: usize,
12    bars: Vec<Bar>,
13    pivot_scores: Vec<f64>,
14    last_high: Option<f64>,
15    last_low: Option<f64>,
16    prev_high: Option<f64>,
17    prev_low: Option<f64>,
18    alerts: Vec<IndicatorAlert>,
19}
20
21impl PivotStructureEngine {
22    pub fn new(left_bars: usize, right_bars: usize, score_window: usize) -> Self {
23        Self {
24            left_bars,
25            right_bars,
26            score_window,
27            bars: Vec::new(),
28            pivot_scores: Vec::new(),
29            last_high: None,
30            last_low: None,
31            prev_high: None,
32            prev_low: None,
33            alerts: Vec::new(),
34        }
35    }
36}
37
38impl Indicator for PivotStructureEngine {
39    fn name(&self) -> &str {
40        "pivots_structure"
41    }
42
43    fn warmup_period(&self) -> usize {
44        self.left_bars + self.right_bars + 1
45    }
46
47    fn reset(&mut self) {
48        self.bars.clear();
49        self.pivot_scores.clear();
50        self.last_high = None;
51        self.last_low = None;
52        self.prev_high = None;
53        self.prev_low = None;
54        self.alerts.clear();
55    }
56
57    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
58        self.bars.push(bar.clone());
59        let max_history = (self.left_bars + self.right_bars + 1) * 4;
60        if self.bars.len() > max_history {
61            self.bars.remove(0);
62        }
63
64        self.alerts.clear();
65
66        let req_len = self.left_bars + self.right_bars + 1;
67        if self.bars.len() < req_len {
68            return None;
69        }
70
71        // Pivot index candidate is `self.bars.len() - 1 - self.right_bars`
72        let candidate_idx = self.bars.len() - 1 - self.right_bars;
73        let cand_high = self.bars[candidate_idx].high;
74        let cand_low = self.bars[candidate_idx].low;
75
76        let mut is_pivot_high = true;
77        let mut is_pivot_low = true;
78
79        for i in (candidate_idx - self.left_bars)..=candidate_idx + self.right_bars {
80            if i == candidate_idx {
81                continue;
82            }
83            if self.bars[i].high >= cand_high {
84                is_pivot_high = false;
85            }
86            if self.bars[i].low <= cand_low {
87                is_pivot_low = false;
88            }
89        }
90
91        let mut cur_score = 0.0;
92        let mut found_pivot = false;
93
94        if is_pivot_high {
95            self.prev_high = self.last_high;
96            self.last_high = Some(cand_high);
97            if let Some(prev) = self.prev_high {
98                cur_score += if cand_high > prev { 2.0 } else { -1.0 };
99                found_pivot = true;
100            }
101        }
102
103        if is_pivot_low {
104            self.prev_low = self.last_low;
105            self.last_low = Some(cand_low);
106            if let Some(prev) = self.prev_low {
107                cur_score += if cand_low > prev { 1.0 } else { -2.0 };
108                found_pivot = true;
109            }
110        }
111
112        if found_pivot {
113            self.pivot_scores.push(cur_score);
114            if self.pivot_scores.len() > self.score_window {
115                self.pivot_scores.remove(0);
116            }
117        }
118
119        let score = if !self.pivot_scores.is_empty() {
120            let sum: f64 = self.pivot_scores.iter().sum();
121            let max_possible = (self.score_window as f64) * 2.0;
122            let raw = (sum / max_possible) * 100.0;
123            raw.clamp(-100.0, 100.0)
124        } else {
125            0.0
126        };
127
128        if score >= 50.0 {
129            self.alerts.push(IndicatorAlert::new(
130                "structure_bullish_bias",
131                format!("Strong Bullish Market Structure Bias (+{:.0} Score)", score),
132                0.85,
133            ));
134        } else if score <= -50.0 {
135            self.alerts.push(IndicatorAlert::new(
136                "structure_bearish_bias",
137                format!("Strong Bearish Market Structure Bias ({:.0} Score)", score),
138                0.85,
139            ));
140        }
141
142        Some(IndicatorOutput::new(score))
143    }
144
145    fn alerts(&self) -> Vec<IndicatorAlert> {
146        self.alerts.clone()
147    }
148}
149
150pub fn build_pivots_structure(params: &HashMap<String, f64>) -> PivotStructureEngine {
151    let left_bars = params.get("left_bars").copied().unwrap_or(5.0) as usize;
152    let right_bars = params.get("right_bars").copied().unwrap_or(5.0) as usize;
153    let score_window = params.get("score_window").copied().unwrap_or(10.0) as usize;
154    PivotStructureEngine::new(left_bars, right_bars, score_window)
155}