kestrel_chartkit/analytics/
fear_gauge.rs1#[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 FearSpike,
25 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 pub absorbed: bool,
42}
43
44pub 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 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 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
112fn 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
135fn 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
143fn 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 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; 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 #[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 #[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}