kestrel_chartkit/analytics/
fear_greed.rs1#[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 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
53pub 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}