Skip to main content

kestrel_chartkit/indicator/
williams_r.rs

1use 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
9/// Williams %R on an **ascending** `0..=100` scale, smoothed, with a signal and a context line.
10///
11/// Raw: `100 * (close - lowest_low) / (highest_high - lowest_low)` over the last `wpr_len` bars,
12/// `50` for a window without range. The widely used form runs `-100..=0` with `0` at the high; this
13/// type puts `100` at the high instead — convert with `conventional = value - 100`. The registry
14/// defaults `oversold = 20` / `overbought = 80` are on this ascending scale.
15///
16/// **`value` is the line**: `Ema(avg_len)` over the raw value with the first-sample seed.
17/// `extra["signal"]` is `Ema(sig_len)` over the line; the context line runs over `ctx_len` bars.
18/// Alerts fire on line/signal crosses inside the extreme zones, on crosses of `mid_line` and on
19/// divergences.
20///
21/// First output: with the `wpr_len`-th bar. [`Indicator::reset`] clears windows and averages.
22pub 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}