Skip to main content

kestrel_chartkit/analytics/
fear_greed.rs

1//! Heuristic instrument sentiment from explicit component readings.
2//! Missing readings contribute neutral 50; score is not a calibrated probability.
3
4#[cfg(feature = "serde")]
5use serde::Serialize;
6
7use super::{
8    FearGaugeReading, FearGaugeState, PriceSummary, RegimeReading, RegimeState,
9    TrendPersistenceReading,
10};
11use crate::Bar;
12
13const FLOW_WINDOW: usize = 34;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[cfg_attr(feature = "serde", derive(Serialize))]
17#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
18pub enum FearGreedState {
19    ExtremeFear,
20    Fear,
21    Neutral,
22    Greed,
23    ExtremeGreed,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[cfg_attr(feature = "serde", derive(Serialize))]
28#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
29pub enum FearGreedDriver {
30    FearGauge,
31    Volatility,
32    Flow,
33    Persistence,
34    Regime,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
38#[cfg_attr(feature = "serde", derive(Serialize))]
39pub struct FearGreedReading {
40    /// Composite 0..100: low means stress/fear, high means greed or
41    /// complacency. Components are exposed so the UI can explain the score.
42    pub score: f64,
43    pub state: FearGreedState,
44    pub fear_gauge_score: f64,
45    pub volatility_score: f64,
46    pub flow_score: f64,
47    pub persistence_score: f64,
48    pub regime_score: f64,
49    pub driver: FearGreedDriver,
50    pub drag: FearGreedDriver,
51}
52
53/// Instrument sentiment `0..=100` from five component scores, each `0..=100` and 50 while its
54/// reading is missing:
55///
56/// - fear gauge (weight 0.30): 15 in a fear spike, 85 in a complacency spike, otherwise
57///   `50 + 1.25 · clamp(bwvf - wvf, -20, 20)`; an absorbed spike is pulled halfway to 50;
58/// - volatility (0.20): `100 · (1 - atr_percentile)`;
59/// - flow (0.20), always from `bars`: `50 · (m + 1)`, `m` the mean close location value
60///   `((close - low) - (high - close)) / (high - low)`, clamped to `-1..=1`, over the last 34
61///   bars, weighted by volume, or equally when no bar in that window has volume; bars without
62///   range or weight are skipped, and it is 50 when none remain;
63/// - trend persistence (0.20): its `score`;
64/// - regime (0.10): `55 + 25 · votes / 3` when trending, `45 + 15 · votes / 3` when ranging.
65///
66/// `score` is the weighted sum, clamped to `0..=100`. States: extreme fear below 25, fear below
67/// 45, neutral below 55, greed below 75, else extreme greed. `driver`/`drag` name the highest and
68/// lowest component; on a tie the driver is the later and the drag the earlier in the order
69/// above. `None` for fewer than 2 bars.
70pub fn fear_greed_reading(
71    bars: &[Bar],
72    regime: Option<&RegimeReading>,
73    price: Option<&PriceSummary>,
74    fear_gauge: Option<&FearGaugeReading>,
75    trend_persistence: Option<&TrendPersistenceReading>,
76) -> Option<FearGreedReading> {
77    if bars.len() < 2 {
78        return None;
79    }
80
81    let fear_gauge_score = fear_gauge.map_or(50.0, fear_gauge_component);
82    let volatility_score = price.map_or(50.0, |p| 100.0 * (1.0 - p.atr_percentile));
83    let flow_score = flow_component(bars)?;
84    let persistence_score = trend_persistence.map_or(50.0, |t| t.score);
85    let regime_score = regime.map_or(50.0, regime_component);
86
87    let score = (fear_gauge_score * 0.30
88        + volatility_score * 0.20
89        + flow_score * 0.20
90        + persistence_score * 0.20
91        + regime_score * 0.10)
92        .clamp(0.0, 100.0);
93    let state = classify(score);
94    let (driver, drag) = driver_and_drag(
95        fear_gauge_score,
96        volatility_score,
97        flow_score,
98        persistence_score,
99        regime_score,
100    );
101
102    Some(FearGreedReading {
103        score,
104        state,
105        fear_gauge_score,
106        volatility_score,
107        flow_score,
108        persistence_score,
109        regime_score,
110        driver,
111        drag,
112    })
113}
114
115fn fear_gauge_component(f: &FearGaugeReading) -> f64 {
116    let base = match f.state {
117        FearGaugeState::FearSpike => 15.0,
118        FearGaugeState::ComplacencySpike => 85.0,
119        FearGaugeState::Neutral => {
120            let spread = (f.bwvf - f.wvf).clamp(-20.0, 20.0);
121            50.0 + spread * 1.25
122        }
123    };
124    if f.absorbed {
125        (base + 50.0) / 2.0
126    } else {
127        base
128    }
129}
130
131fn regime_component(r: &RegimeReading) -> f64 {
132    let trendiness = r.trend_votes as f64 / 3.0;
133    match r.state {
134        RegimeState::Trending => 55.0 + trendiness * 25.0,
135        RegimeState::Ranging => 45.0 + trendiness * 15.0,
136    }
137}
138
139fn flow_component(bars: &[Bar]) -> Option<f64> {
140    let start = bars.len().saturating_sub(FLOW_WINDOW);
141    let window = &bars[start..];
142    if window.is_empty() {
143        return None;
144    }
145
146    let use_volume = window.iter().any(|b| b.volume > 0.0);
147    let mut weighted = 0.0;
148    let mut weight_sum = 0.0;
149    for bar in window {
150        let span = bar.high - bar.low;
151        if span <= 0.0 {
152            continue;
153        }
154        let clv = (((bar.close - bar.low) - (bar.high - bar.close)) / span).clamp(-1.0, 1.0);
155        let weight = if use_volume { bar.volume.max(0.0) } else { 1.0 };
156        if weight == 0.0 {
157            continue;
158        }
159        weighted += clv * weight;
160        weight_sum += weight;
161    }
162    if weight_sum == 0.0 {
163        return Some(50.0);
164    }
165    Some(((weighted / weight_sum) + 1.0) * 50.0)
166}
167
168fn classify(score: f64) -> FearGreedState {
169    if score < 25.0 {
170        FearGreedState::ExtremeFear
171    } else if score < 45.0 {
172        FearGreedState::Fear
173    } else if score < 55.0 {
174        FearGreedState::Neutral
175    } else if score < 75.0 {
176        FearGreedState::Greed
177    } else {
178        FearGreedState::ExtremeGreed
179    }
180}
181
182fn driver_and_drag(
183    fear_gauge: f64,
184    volatility: f64,
185    flow: f64,
186    persistence: f64,
187    regime: f64,
188) -> (FearGreedDriver, FearGreedDriver) {
189    use FearGreedDriver::*;
190    let scores = [
191        (FearGauge, fear_gauge),
192        (Volatility, volatility),
193        (Flow, flow),
194        (Persistence, persistence),
195        (Regime, regime),
196    ];
197    let driver = scores
198        .iter()
199        .max_by(|a, b| a.1.total_cmp(&b.1))
200        .expect("scores is non-empty")
201        .0;
202    let drag = scores
203        .iter()
204        .min_by(|a, b| a.1.total_cmp(&b.1))
205        .expect("scores is non-empty")
206        .0;
207    (driver, drag)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn bar(c: f64, h: f64, l: f64, volume: f64) -> Bar {
215        Bar {
216            timestamp: 0,
217            open: c,
218            high: h,
219            low: l,
220            close: c,
221            volume,
222        }
223    }
224
225    #[test]
226    fn close_near_high_pushes_flow_toward_greed() {
227        let bars: Vec<Bar> = (0..40).map(|_| bar(109.0, 110.0, 100.0, 10.0)).collect();
228        let r = fear_greed_reading(&bars, None, None, None, None).expect("reading");
229        assert!(r.flow_score > 80.0);
230        assert!(r.score > 50.0);
231    }
232
233    #[test]
234    fn fear_spike_pulls_score_down() {
235        let bars: Vec<Bar> = (0..40).map(|_| bar(101.0, 110.0, 100.0, 10.0)).collect();
236        let fear = FearGaugeReading {
237            state: FearGaugeState::FearSpike,
238            wvf: 20.0,
239            bwvf: 1.0,
240            absorbed: false,
241        };
242        let r = fear_greed_reading(&bars, None, None, Some(&fear), None).expect("reading");
243        assert!(r.fear_gauge_score < 20.0);
244        assert!(r.score < 50.0);
245    }
246}