kestrel_chartkit/indicator/
vix_fix.rs1use std::collections::HashMap;
2
3use crate::indicator::smoothing::{ExtremeWindow, Sma};
4use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
5use crate::model::Bar;
6
7pub 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 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 #[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 assert!(declared_warmup - first_some_index <= 1);
154 }
155}