Skip to main content

kestrel_chartkit/indicator/
vix_fix.rs

1use std::collections::HashMap;
2
3use crate::indicator::smoothing::{ExtremeWindow, Sma};
4use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
5use crate::model::Bar;
6
7/// Williams VIX Fix: how far the low sits below the recent highest close, in percent.
8///
9/// `WVF = (highest_close - low) / highest_close * 100`, `highest_close` over the last `pd` closes —
10/// the current close until `pd` closes have been seen. The spike band is
11/// `SMA(bband_len)(WVF) + mult * sd`, `sd` the population deviation of the last `bband_len` WVF
12/// values around that SMA; a WVF at or above it raises an alert.
13///
14/// `value`: the WVF; `secondary`: the upper band. First output: once the band exists, i.e. with the
15/// `2 * bband_len - 1`-th bar. [`Indicator::reset`] clears all windows.
16pub struct WilliamsVixFix {
17    bband_len: usize,
18    mult: f64,
19
20    close_window: ExtremeWindow,
21    wvf_window: Vec<f64>,
22    sma: Sma,
23    alerts: Vec<IndicatorAlert>,
24}
25
26impl WilliamsVixFix {
27    pub fn new(pd: usize, bband_len: usize, mult: f64) -> Self {
28        Self {
29            bband_len,
30            mult,
31            close_window: ExtremeWindow::new(pd),
32            wvf_window: Vec::with_capacity(bband_len),
33            sma: Sma::new(bband_len),
34            alerts: Vec::new(),
35        }
36    }
37}
38
39impl Indicator for WilliamsVixFix {
40    fn name(&self) -> &str {
41        "vix_fix"
42    }
43
44    fn warmup_period(&self) -> usize {
45        // Two nested `bband_len`-sized windows gate the first output: `self.sma` must fill
46        // first (bband_len calls), and only once it does does `wvf_window` start accumulating
47        // towards its own `bband_len` (see the two gates in `on_bar`) -- so the first non-`None`
48        // output only arrives after roughly twice `bband_len` bars, not after one.
49        self.bband_len.saturating_mul(2).saturating_sub(1)
50    }
51
52    fn reset(&mut self) {
53        self.close_window.reset();
54        self.wvf_window.clear();
55        self.sma.reset();
56        self.alerts.clear();
57    }
58
59    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
60        let highest_close = self
61            .close_window
62            .push(bar.close)
63            .map(|(_, high)| high)
64            .unwrap_or(bar.close);
65        self.alerts.clear();
66
67        let wvf = if highest_close > 0.0 {
68            ((highest_close - bar.low) / highest_close) * 100.0
69        } else {
70            0.0
71        };
72
73        let sma_val = self.sma.update(wvf)?;
74
75        self.wvf_window.push(wvf);
76        if self.wvf_window.len() > self.bband_len {
77            self.wvf_window.remove(0);
78        }
79
80        if self.wvf_window.len() < self.bband_len {
81            return None;
82        }
83
84        let mean = sma_val;
85        let variance = self
86            .wvf_window
87            .iter()
88            .map(|v| (v - mean).powi(2))
89            .sum::<f64>()
90            / self.bband_len as f64;
91        let std_dev = variance.sqrt();
92
93        let upper_band = mean + self.mult * std_dev;
94        let is_spike = wvf >= upper_band;
95
96        if is_spike {
97            self.alerts.push(IndicatorAlert::new(
98                "panic_bottom",
99                format!(
100                    "Williams VIX Fix Spike ({:.2} >= Upper Band {:.2}) - Market Bottom Zone",
101                    wvf, upper_band
102                ),
103                1.0,
104            ));
105        }
106
107        Some(IndicatorOutput::new(wvf).with_secondary(upper_band))
108    }
109
110    fn alerts(&self) -> Vec<IndicatorAlert> {
111        self.alerts.clone()
112    }
113}
114
115pub fn build_vix_fix(params: &HashMap<String, f64>) -> WilliamsVixFix {
116    let pd = params.get("pd").copied().unwrap_or(22.0) as usize;
117    let bband_len = params.get("bband_len").copied().unwrap_or(20.0) as usize;
118    let mult = params.get("mult").copied().unwrap_or(2.0);
119    WilliamsVixFix::new(pd, bband_len, mult)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::model::Bar;
126
127    /// `warmup_period()` previously defaulted to 0 (never overridden), which was silently wrong:
128    /// two nested `bband_len`-sized windows (`sma`, then `wvf_window`) must each fill before the
129    /// first output, so the real warmup is close to `2 * bband_len`. This was only caught once
130    /// `vix_fix` became reachable from `catalog()` (finding 04) and the generic warmup-contract
131    /// robustness test exercised it.
132    #[test]
133    fn test_warmup_period_matches_first_non_none_output() {
134        let bband_len = 5;
135        let mut ind = WilliamsVixFix::new(3, bband_len, 2.0);
136        let declared_warmup = ind.warmup_period();
137
138        let mut first_some_index = None;
139        for i in 0..40 {
140            let price = 100.0 + (i as f64 * 0.37).sin() * 3.0;
141            let bar = Bar::new(i, price + 1.0, price + 1.5, price - 1.5, price, 100.0);
142            if ind.on_bar(&bar).is_some() && first_some_index.is_none() {
143                first_some_index = Some(i as usize);
144            }
145        }
146
147        let first_some_index = first_some_index.expect("expected an output within 40 bars");
148        assert!(
149            declared_warmup >= first_some_index,
150            "declared warmup_period {declared_warmup} understates the actual first output at bar {first_some_index}"
151        );
152        // The declared warmup should be a tight bound, not a wildly conservative one.
153        assert!(declared_warmup - first_some_index <= 1);
154    }
155}