kestrel_chartkit/indicator/
williams_r.rs1use std::collections::{HashMap, VecDeque};
2
3use crate::model::Bar;
4
5use super::divergence::SlopeDivergence;
6use super::smoothing::{crossed_over, crossed_under, Ema, ExtremeWindow};
7use super::{Indicator, IndicatorAlert, IndicatorOutput};
8
9pub struct WilliamsR {
10 wpr_len: usize,
11 mid_line: f64,
12 oversold: f64,
13 overbought: f64,
14 require_extreme_zone: bool,
15 ctx_len: usize,
16
17 hl_window: VecDeque<(f64, f64)>,
18 avg: Ema,
19 signal_avg: Ema,
20 extreme_window: ExtremeWindow,
21 prev_wpr_line: Option<f64>,
22 prev_signal: Option<f64>,
23 bars_seen: usize,
24
25 ctx_hl_window: VecDeque<(f64, f64)>,
26 ctx_avg: Ema,
27 divergence: SlopeDivergence,
28
29 alerts: WilliamsRAlerts,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Default)]
33pub struct WilliamsRAlerts {
34 pub bull_extreme: bool,
35 pub bear_extreme: bool,
36 pub bull_mid_cross: bool,
37 pub bear_mid_cross: bool,
38 pub bull_divergence: bool,
39 pub bear_divergence: bool,
40 pub extreme_strength: f64,
41 pub divergence_strength: f64,
42}
43
44impl WilliamsR {
45 #[allow(clippy::too_many_arguments)]
46 pub fn new(
47 wpr_len: usize,
48 avg_len: usize,
49 sig_len: usize,
50 mid_line: f64,
51 overbought: f64,
52 oversold: f64,
53 lookback_extreme: usize,
54 require_extreme_zone: bool,
55 ctx_len: usize,
56 div_len: usize,
57 div_min: f64,
58 ) -> Self {
59 Self {
60 wpr_len,
61 mid_line,
62 oversold,
63 overbought,
64 require_extreme_zone,
65 ctx_len,
66 hl_window: VecDeque::with_capacity(wpr_len),
67 avg: Ema::new(avg_len),
68 signal_avg: Ema::new(sig_len),
69 extreme_window: ExtremeWindow::new(lookback_extreme),
70 prev_wpr_line: None,
71 prev_signal: None,
72 bars_seen: 0,
73 ctx_hl_window: VecDeque::with_capacity(ctx_len),
74 ctx_avg: Ema::new(avg_len),
75 divergence: SlopeDivergence::new(div_len, div_min),
76 alerts: WilliamsRAlerts::default(),
77 }
78 }
79
80 pub fn with_defaults() -> Self {
81 Self::new(14, 3, 3, 50.0, 80.0, 20.0, 5, true, 50, 4, 10.0)
82 }
83}
84
85impl Indicator for WilliamsR {
86 fn name(&self) -> &str {
87 "williams_r"
88 }
89
90 fn warmup_period(&self) -> usize {
91 self.wpr_len.max(self.ctx_len)
92 }
93
94 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
95 self.alerts = WilliamsRAlerts::default();
96 self.bars_seen += 1;
97
98 if self.ctx_hl_window.len() == self.ctx_len {
99 self.ctx_hl_window.pop_front();
100 }
101 self.ctx_hl_window.push_back((bar.high, bar.low));
102 let ctx_line = if self.ctx_hl_window.len() == self.ctx_len {
103 let ctx_highest_high = self
104 .ctx_hl_window
105 .iter()
106 .map(|(h, _)| *h)
107 .fold(f64::NEG_INFINITY, f64::max);
108 let ctx_lowest_low = self
109 .ctx_hl_window
110 .iter()
111 .map(|(_, l)| *l)
112 .fold(f64::INFINITY, f64::min);
113 let ctx_range = ctx_highest_high - ctx_lowest_low;
114 let ctx_raw = if ctx_range != 0.0 {
115 100.0 * (bar.close - ctx_lowest_low) / ctx_range
116 } else {
117 50.0
118 };
119 Some(self.ctx_avg.update(ctx_raw))
120 } else {
121 None
122 };
123
124 if self.hl_window.len() == self.wpr_len {
125 self.hl_window.pop_front();
126 }
127 self.hl_window.push_back((bar.high, bar.low));
128 if self.hl_window.len() < self.wpr_len {
129 return None;
130 }
131
132 let highest_high = self
133 .hl_window
134 .iter()
135 .map(|(h, _)| *h)
136 .fold(f64::NEG_INFINITY, f64::max);
137 let lowest_low = self
138 .hl_window
139 .iter()
140 .map(|(_, l)| *l)
141 .fold(f64::INFINITY, f64::min);
142 let range = highest_high - lowest_low;
143 let wpr_raw = if range != 0.0 {
144 100.0 * (bar.close - lowest_low) / range
145 } else {
146 50.0
147 };
148
149 let wpr_line = self.avg.update(wpr_raw);
150 let signal = self.signal_avg.update(wpr_line);
151
152 let extreme = self.extreme_window.push(wpr_line);
153 let was_oversold = extreme
154 .map(|(low, _)| low <= self.oversold)
155 .unwrap_or(false);
156 let was_overbought = extreme
157 .map(|(_, high)| high >= self.overbought)
158 .unwrap_or(false);
159
160 if let (Some(prev_wpr), Some(prev_sig)) = (self.prev_wpr_line, self.prev_signal) {
161 let bull_cross = crossed_over(prev_wpr, prev_sig, wpr_line, signal);
162 let bear_cross = crossed_under(prev_wpr, prev_sig, wpr_line, signal);
163 self.alerts.bull_extreme = bull_cross && (!self.require_extreme_zone || was_oversold);
164 self.alerts.bear_extreme = bear_cross && (!self.require_extreme_zone || was_overbought);
165 self.alerts.bull_mid_cross =
166 crossed_over(prev_wpr, self.mid_line, wpr_line, self.mid_line);
167 self.alerts.bear_mid_cross =
168 crossed_under(prev_wpr, self.mid_line, wpr_line, self.mid_line);
169
170 self.alerts.extreme_strength = if let Some((low, high)) = extreme {
171 if self.alerts.bull_extreme {
172 ((self.oversold - low) / self.oversold.abs()).clamp(0.0, 1.0)
173 } else if self.alerts.bear_extreme {
174 ((high - self.overbought) / self.overbought.abs()).clamp(0.0, 1.0)
175 } else {
176 0.0
177 }
178 } else {
179 0.0
180 };
181 }
182 self.prev_wpr_line = Some(wpr_line);
183 self.prev_signal = Some(signal);
184
185 let mut extra = HashMap::new();
186 extra.insert("signal".to_string(), signal);
187 if let Some(ctx_line) = ctx_line {
188 let div = self.divergence.update(wpr_line, ctx_line);
189 self.alerts.bull_divergence = div.bull;
190 self.alerts.bear_divergence = div.bear;
191 self.alerts.divergence_strength = if div.bull || div.bear {
192 ((div.fast_dir.abs() - self.divergence.div_min()) / self.divergence.div_min())
193 .clamp(0.0, 1.0)
194 } else {
195 0.0
196 };
197 extra.insert("ctx".to_string(), ctx_line);
198 }
199
200 Some(IndicatorOutput::with_extra(wpr_line, extra))
201 }
202
203 fn reset(&mut self) {
204 self.hl_window.clear();
205 self.avg.reset();
206 self.signal_avg.reset();
207 self.extreme_window.reset();
208 self.prev_wpr_line = None;
209 self.prev_signal = None;
210 self.bars_seen = 0;
211 self.ctx_hl_window.clear();
212 self.ctx_avg.reset();
213 self.divergence.reset();
214 self.alerts = WilliamsRAlerts::default();
215 }
216
217 fn alerts(&self) -> Vec<IndicatorAlert> {
218 let a = self.alerts;
219 let mut out = Vec::new();
220 if a.bull_extreme {
221 out.push(IndicatorAlert {
222 kind: "bull_extreme".to_string(),
223 note: "WPR · BULL CROSS OVERSOLD".to_string(),
224 strength: a.extreme_strength,
225 });
226 }
227 if a.bear_extreme {
228 out.push(IndicatorAlert {
229 kind: "bear_extreme".to_string(),
230 note: "WPR · BEAR CROSS OVERBOUGHT".to_string(),
231 strength: a.extreme_strength,
232 });
233 }
234 if a.bull_mid_cross {
235 out.push(IndicatorAlert {
236 kind: "bull_mid_cross".to_string(),
237 note: "WPR · CROSS ABOVE 50".to_string(),
238 strength: 1.0,
239 });
240 }
241 if a.bear_mid_cross {
242 out.push(IndicatorAlert {
243 kind: "bear_mid_cross".to_string(),
244 note: "WPR · CROSS BELOW 50".to_string(),
245 strength: 1.0,
246 });
247 }
248 if a.bull_divergence {
249 out.push(IndicatorAlert {
250 kind: "bull_divergence".to_string(),
251 note: "WILLIAMS %R · BULL DIVERGENCE".to_string(),
252 strength: a.divergence_strength,
253 });
254 }
255 if a.bear_divergence {
256 out.push(IndicatorAlert {
257 kind: "bear_divergence".to_string(),
258 note: "WILLIAMS %R · BEAR DIVERGENCE".to_string(),
259 strength: a.divergence_strength,
260 });
261 }
262 out
263 }
264}