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