Skip to main content

kestrel_chartkit/analytics/
fear_gauge.rs

1//! Dual Williams VIX Fix snapshot with population-deviation bands and an absorption heuristic.
2//! Uses full 22-close windows, 20-sample bands and a simple trailing ATR for absorption.
3//! It is distinct from the single-sided streaming VIX Fix indicator.
4
5#[cfg(feature = "serde")]
6use serde::Serialize;
7
8use crate::Bar;
9
10const WVF_LEN: usize = 22;
11const BAND_LEN: usize = 20;
12const BAND_MULT: f64 = 2.0;
13const STALL_LEN: usize = 5;
14const STALL_FLAT_ATR: f64 = 0.5;
15const STALL_WVF_MIN: f64 = 2.0;
16const ATR_LEN: usize = 14;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[cfg_attr(feature = "serde", derive(Serialize))]
20#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
21pub enum FearGaugeState {
22    /// `wvf` (sell-off gauge) is currently at/above its StdDev spike band —
23    /// historically a bottom-proximity context, not a standalone signal.
24    FearSpike,
25    /// `bwvf` (the inverted, rally gauge) is currently at/above its band —
26    /// top-proximity context.
27    ComplacencySpike,
28    Neutral,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq)]
32#[cfg_attr(feature = "serde", derive(Serialize))]
33pub struct FearGaugeReading {
34    pub state: FearGaugeState,
35    pub wvf: f64,
36    pub bwvf: f64,
37    /// True when the spike coincides with a near-flat price move over the
38    /// last `STALL_LEN` bars — likely a thin wick rather than genuine
39    /// fear/complacency (the "stall/absorption" heuristic). `false` when
40    /// `state` is `Neutral`.
41    pub absorbed: bool,
42}
43
44/// Reads the current fear/complacency state from `bars` (oldest first,
45/// current bar last). `None` until enough bars exist to seed the WVF window
46/// and its spike band (`WVF_LEN + BAND_LEN - 1`).
47///
48/// With `hc`/`lc` the highest/lowest close of the 22 bars ending at bar `i`,
49/// `wvf_i = 100 · (hc - low_i) / hc` and `bwvf_i = 100 · (high_i - lc) / lc` (0 for a zero
50/// extreme). A gauge spikes when its current value reaches `mean + 2 · sd` of its last 20
51/// values, `sd` the population standard deviation; a fear spike takes precedence over a
52/// complacency spike.
53///
54/// `absorbed` flags a spike on a stalled price: `wvf` rose by more than 2 over the last 5 bars
55/// while `|close - close 5 bars ago|` stayed below half the mean true range of the last 14 bars,
56/// each against its previous close (a zero mean counts as no move). Always `false` for
57/// [`FearGaugeState::Neutral`].
58pub fn fear_gauge_reading(bars: &[Bar]) -> Option<FearGaugeReading> {
59    if bars.len() < WVF_LEN + BAND_LEN - 1 {
60        return None;
61    }
62    let last = bars.len() - 1;
63
64    // wvf/bwvf for the trailing `BAND_LEN` bars — all the spike band's
65    // rolling mean/stdev needs; each value requires its own `WVF_LEN`-bar
66    // close window.
67    let mut wvf_band = Vec::with_capacity(BAND_LEN);
68    let mut bwvf_band = Vec::with_capacity(BAND_LEN);
69    for i in (last + 1 - BAND_LEN)..=last {
70        let (wvf, bwvf) = wvf_at(bars, i);
71        wvf_band.push(wvf);
72        bwvf_band.push(bwvf);
73    }
74
75    let wvf = *wvf_band.last().expect("band window is non-empty");
76    let bwvf = *bwvf_band.last().expect("band window is non-empty");
77    let bull_active = wvf >= spike_band(&wvf_band);
78    let bear_active = bwvf >= spike_band(&bwvf_band);
79
80    // Stall/absorption: WVF surged over `STALL_LEN` bars but price barely
81    // moved — likely a thin wick, not genuine fear. Needs `STALL_LEN` bars
82    // of wvf/close history before the current one.
83    let absorbed = if last >= STALL_LEN + WVF_LEN - 1 {
84        let (wvf_then, _) = wvf_at(bars, last - STALL_LEN);
85        let close_then = bars[last - STALL_LEN].close;
86        let atr = simple_atr(bars, last, ATR_LEN);
87        let wvf_chg = wvf - wvf_then;
88        let price_chg = atr
89            .filter(|a| *a != 0.0)
90            .map_or(0.0, |a| (bars[last].close - close_then).abs() / a);
91        wvf_chg > STALL_WVF_MIN && price_chg < STALL_FLAT_ATR
92    } else {
93        false
94    };
95
96    let state = if bull_active {
97        FearGaugeState::FearSpike
98    } else if bear_active {
99        FearGaugeState::ComplacencySpike
100    } else {
101        FearGaugeState::Neutral
102    };
103
104    Some(FearGaugeReading {
105        state,
106        wvf,
107        bwvf,
108        absorbed: state != FearGaugeState::Neutral && absorbed,
109    })
110}
111
112/// `wvf`/`bwvf` at bar `i`: `100 · (hc - low_i) / hc` and `100 · (high_i - lc) / lc`,
113/// with `hc`/`lc` the highest/lowest close of the `WVF_LEN` bars ending at `i`.
114/// Requires `i >= WVF_LEN - 1`.
115fn wvf_at(bars: &[Bar], i: usize) -> (f64, f64) {
116    let window = &bars[i + 1 - WVF_LEN..=i];
117    let hc = window
118        .iter()
119        .map(|b| b.close)
120        .fold(f64::NEG_INFINITY, f64::max);
121    let lc = window.iter().map(|b| b.close).fold(f64::INFINITY, f64::min);
122    let wvf = if hc != 0.0 {
123        (hc - bars[i].low) / hc * 100.0
124    } else {
125        0.0
126    };
127    let bwvf = if lc != 0.0 {
128        (bars[i].high - lc) / lc * 100.0
129    } else {
130        0.0
131    };
132    (wvf, bwvf)
133}
134
135/// `mean(x, BAND_LEN) + BAND_MULT * stdev(x, BAND_LEN)`, with the population (biased) standard
136/// deviation.
137fn spike_band(values: &[f64]) -> f64 {
138    let mean = values.iter().sum::<f64>() / values.len() as f64;
139    let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
140    mean + BAND_MULT * variance.sqrt()
141}
142
143/// A plain trailing-average true range ending at bar `end`, deliberately not a
144/// Wilder average seeded from the start of history: it only feeds the soft
145/// stall/absorption qualifier of a display-only read-out.
146fn simple_atr(bars: &[Bar], end: usize, len: usize) -> Option<f64> {
147    if end < len {
148        return None;
149    }
150    let mut sum = 0.0;
151    let mut prev_close = bars[end - len].close;
152    for bar in &bars[end - len + 1..=end] {
153        let tr = super::true_range(bar, Some(prev_close));
154        sum += tr;
155        prev_close = bar.close;
156    }
157    Some(sum / len as f64)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn bar(c: f64, h: f64, l: f64) -> Bar {
165        Bar {
166            timestamp: 0,
167            open: c,
168            high: h,
169            low: l,
170            close: c,
171            volume: 0.0,
172        }
173    }
174
175    /// A calm market still has bar-to-bar jitter — a perfectly flat/constant
176    /// close series is a degenerate edge case (zero-variance spike band, so
177    /// `wvf >= band` trivially holds by equality) that never occurs on real
178    /// bars, so this deliberately avoids it, same deterministic-jitter
179    /// approach as `atr_parity.rs`'s `synthetic_bars`.
180    fn calm_bars(n: usize) -> Vec<Bar> {
181        let mut state: u64 = 12345;
182        let mut price = 100.0_f64;
183        (0..n)
184            .map(|_| {
185                state ^= state << 13;
186                state ^= state >> 7;
187                state ^= state << 17;
188                price += ((state % 21) as f64 - 10.0) / 200.0; // +-0.05 jitter
189                bar(price, price + 0.5, price - 0.5)
190            })
191            .collect()
192    }
193
194    #[test]
195    fn insufficient_bars_is_none() {
196        let bars = calm_bars(10);
197        assert!(fear_gauge_reading(&bars).is_none());
198    }
199
200    #[test]
201    fn calm_flat_market_reads_neutral() {
202        let bars = calm_bars(60);
203        let r = fear_gauge_reading(&bars).expect("enough bars");
204        assert_eq!(r.state, FearGaugeState::Neutral);
205    }
206
207    /// A calm run followed by one sharp sell-off wick (low far below the
208    /// recent close range, close recovers) is exactly what `wvf` is built
209    /// to flag — it should spike above its own StdDev band.
210    #[test]
211    fn sharp_selloff_wick_reads_as_fear_spike() {
212        let mut bars = calm_bars(59);
213        bars.push(bar(100.0, 100.5, 80.0));
214        let r = fear_gauge_reading(&bars).expect("enough bars");
215        assert_eq!(r.state, FearGaugeState::FearSpike);
216        assert!(r.wvf > r.bwvf);
217    }
218
219    /// The mirrored case: a sharp rally wick (high far above the recent
220    /// close range) should flag the inverted `bwvf` gauge instead.
221    #[test]
222    fn sharp_rally_wick_reads_as_complacency_spike() {
223        let mut bars = calm_bars(59);
224        bars.push(bar(100.0, 120.0, 99.5));
225        let r = fear_gauge_reading(&bars).expect("enough bars");
226        assert_eq!(r.state, FearGaugeState::ComplacencySpike);
227        assert!(r.bwvf > r.wvf);
228    }
229}