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 score from confirmed pivots, `-100..=100`.
7///
8/// The engine keeps the last `4 · (left_bars + right_bars + 1)` bars. On every bar the one
9/// `right_bars` before the newest is the candidate: a pivot high when its high is strictly above
10/// the high of every other bar from `left_bars` before to `right_bars` after it, a pivot low when
11/// its low is strictly below every other low there. Each pivot is compared with the previous one
12/// of its kind: a higher high scores +2, an equal or lower high -1, a higher low +1, an equal or
13/// lower low -2; the first pivot of each kind only seeds the comparison. A bar that scored adds
14/// the sum of its comparisons to a window of the last `score_window` such sums, and
15/// `value = clamp(100 · sum / (2 · score_window), -100, 100)` — 0 while nothing has scored.
16///
17/// Alerts at `value >= 50` (bullish bias) and `value <= -50` (bearish bias). First output with bar
18/// `left_bars + right_bars + 1`.
19pub struct PivotStructureEngine {
20    left_bars: usize,
21    right_bars: usize,
22    score_window: usize,
23    bars: Vec<Bar>,
24    pivot_scores: Vec<f64>,
25    last_high: Option<f64>,
26    last_low: Option<f64>,
27    prev_high: Option<f64>,
28    prev_low: Option<f64>,
29    alerts: Vec<IndicatorAlert>,
30}
31
32impl PivotStructureEngine {
33    pub fn new(left_bars: usize, right_bars: usize, score_window: usize) -> Self {
34        Self {
35            left_bars,
36            right_bars,
37            score_window,
38            bars: Vec::new(),
39            pivot_scores: Vec::new(),
40            last_high: None,
41            last_low: None,
42            prev_high: None,
43            prev_low: None,
44            alerts: Vec::new(),
45        }
46    }
47}
48
49impl Indicator for PivotStructureEngine {
50    fn name(&self) -> &str {
51        "pivots_structure"
52    }
53
54    fn warmup_period(&self) -> usize {
55        self.left_bars + self.right_bars + 1
56    }
57
58    fn reset(&mut self) {
59        self.bars.clear();
60        self.pivot_scores.clear();
61        self.last_high = None;
62        self.last_low = None;
63        self.prev_high = None;
64        self.prev_low = None;
65        self.alerts.clear();
66    }
67
68    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
69        self.bars.push(bar.clone());
70        let max_history = (self.left_bars + self.right_bars + 1) * 4;
71        if self.bars.len() > max_history {
72            self.bars.remove(0);
73        }
74
75        self.alerts.clear();
76
77        let req_len = self.left_bars + self.right_bars + 1;
78        if self.bars.len() < req_len {
79            return None;
80        }
81
82        // Pivot index candidate is `self.bars.len() - 1 - self.right_bars`
83        let candidate_idx = self.bars.len() - 1 - self.right_bars;
84        let cand_high = self.bars[candidate_idx].high;
85        let cand_low = self.bars[candidate_idx].low;
86
87        let mut is_pivot_high = true;
88        let mut is_pivot_low = true;
89
90        for i in (candidate_idx - self.left_bars)..=candidate_idx + self.right_bars {
91            if i == candidate_idx {
92                continue;
93            }
94            if self.bars[i].high >= cand_high {
95                is_pivot_high = false;
96            }
97            if self.bars[i].low <= cand_low {
98                is_pivot_low = false;
99            }
100        }
101
102        let mut cur_score = 0.0;
103        let mut found_pivot = false;
104
105        if is_pivot_high {
106            self.prev_high = self.last_high;
107            self.last_high = Some(cand_high);
108            if let Some(prev) = self.prev_high {
109                cur_score += if cand_high > prev { 2.0 } else { -1.0 };
110                found_pivot = true;
111            }
112        }
113
114        if is_pivot_low {
115            self.prev_low = self.last_low;
116            self.last_low = Some(cand_low);
117            if let Some(prev) = self.prev_low {
118                cur_score += if cand_low > prev { 1.0 } else { -2.0 };
119                found_pivot = true;
120            }
121        }
122
123        if found_pivot {
124            self.pivot_scores.push(cur_score);
125            if self.pivot_scores.len() > self.score_window {
126                self.pivot_scores.remove(0);
127            }
128        }
129
130        let score = if !self.pivot_scores.is_empty() {
131            let sum: f64 = self.pivot_scores.iter().sum();
132            let max_possible = (self.score_window as f64) * 2.0;
133            let raw = (sum / max_possible) * 100.0;
134            raw.clamp(-100.0, 100.0)
135        } else {
136            0.0
137        };
138
139        if score >= 50.0 {
140            self.alerts.push(IndicatorAlert::new(
141                "structure_bullish_bias",
142                format!("Strong Bullish Market Structure Bias (+{:.0} Score)", score),
143                0.85,
144            ));
145        } else if score <= -50.0 {
146            self.alerts.push(IndicatorAlert::new(
147                "structure_bearish_bias",
148                format!("Strong Bearish Market Structure Bias ({:.0} Score)", score),
149                0.85,
150            ));
151        }
152
153        Some(IndicatorOutput::new(score))
154    }
155
156    fn alerts(&self) -> Vec<IndicatorAlert> {
157        self.alerts.clone()
158    }
159}
160
161pub fn build_pivots_structure(params: &HashMap<String, f64>) -> PivotStructureEngine {
162    let left_bars = params.get("left_bars").copied().unwrap_or(5.0) as usize;
163    let right_bars = params.get("right_bars").copied().unwrap_or(5.0) as usize;
164    let score_window = params.get("score_window").copied().unwrap_or(10.0) as usize;
165    PivotStructureEngine::new(left_bars, right_bars, score_window)
166}