Skip to main content

finance_query/models/sentiment/
response.rs

1//! Fear & Greed Index Response Model
2//!
3//! Represents market sentiment from the Alternative.me Fear & Greed Index.
4
5use serde::{Deserialize, Serialize};
6
7/// Classification label for the Fear & Greed Index value.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[non_exhaustive]
10pub enum FearGreedLabel {
11    /// 0–24: Extreme Fear
12    #[serde(rename = "Extreme Fear")]
13    ExtremeFear,
14    /// 25–44: Fear
15    Fear,
16    /// 45–55: Neutral
17    Neutral,
18    /// 56–75: Greed
19    Greed,
20    /// 76–100: Extreme Greed
21    #[serde(rename = "Extreme Greed")]
22    ExtremeGreed,
23}
24
25impl FearGreedLabel {
26    /// Returns a human-readable string for the label.
27    pub fn as_str(&self) -> &'static str {
28        match self {
29            Self::ExtremeFear => "Extreme Fear",
30            Self::Fear => "Fear",
31            Self::Neutral => "Neutral",
32            Self::Greed => "Greed",
33            Self::ExtremeGreed => "Extreme Greed",
34        }
35    }
36}
37
38/// The current CNN Fear & Greed Index reading from Alternative.me.
39///
40/// Scale: 0 (Extreme Fear) → 100 (Extreme Greed).
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[non_exhaustive]
43pub struct FearAndGreed {
44    /// Index value (0–100)
45    pub value: u8,
46    /// Human-readable classification of the value
47    pub classification: FearGreedLabel,
48    /// Unix timestamp (seconds) when this reading was recorded
49    pub timestamp: i64,
50}
51
52// ---- Internal deserialization wrappers (not public) ----
53
54#[derive(Debug, Deserialize)]
55pub(crate) struct FearAndGreedApiResponse {
56    pub data: Vec<FearAndGreedEntry>,
57}
58
59#[derive(Debug, Deserialize)]
60pub(crate) struct FearAndGreedEntry {
61    pub value: String,
62    pub value_classification: String,
63    pub timestamp: String,
64}
65
66impl FearAndGreed {
67    pub(crate) fn from_response(
68        resp: FearAndGreedApiResponse,
69    ) -> Result<Self, crate::error::FinanceError> {
70        let entry = resp.data.into_iter().next().ok_or_else(|| {
71            crate::error::FinanceError::ResponseStructureError {
72                field: "data".to_string(),
73                context: "Alternative.me API returned empty data array".to_string(),
74            }
75        })?;
76
77        let value = entry.value.parse::<u8>().map_err(|_| {
78            crate::error::FinanceError::ResponseStructureError {
79                field: "value".to_string(),
80                context: format!("Cannot parse '{}' as u8", entry.value),
81            }
82        })?;
83
84        let classification = parse_classification(&entry.value_classification)?;
85
86        let timestamp = entry.timestamp.parse::<i64>().map_err(|_| {
87            crate::error::FinanceError::ResponseStructureError {
88                field: "timestamp".to_string(),
89                context: format!("Cannot parse '{}' as i64", entry.timestamp),
90            }
91        })?;
92
93        Ok(Self {
94            value,
95            classification,
96            timestamp,
97        })
98    }
99}
100
101pub(crate) fn parse_classification(s: &str) -> Result<FearGreedLabel, crate::error::FinanceError> {
102    match s {
103        "Extreme Fear" => Ok(FearGreedLabel::ExtremeFear),
104        "Fear" => Ok(FearGreedLabel::Fear),
105        "Neutral" => Ok(FearGreedLabel::Neutral),
106        "Greed" => Ok(FearGreedLabel::Greed),
107        "Extreme Greed" => Ok(FearGreedLabel::ExtremeGreed),
108        other => Err(crate::error::FinanceError::ResponseStructureError {
109            field: "value_classification".to_string(),
110            context: format!("Unknown classification '{other}'"),
111        }),
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_parse_classification() {
121        assert_eq!(
122            parse_classification("Extreme Fear").unwrap(),
123            FearGreedLabel::ExtremeFear
124        );
125        assert_eq!(parse_classification("Fear").unwrap(), FearGreedLabel::Fear);
126        assert_eq!(
127            parse_classification("Neutral").unwrap(),
128            FearGreedLabel::Neutral
129        );
130        assert_eq!(
131            parse_classification("Greed").unwrap(),
132            FearGreedLabel::Greed
133        );
134        assert_eq!(
135            parse_classification("Extreme Greed").unwrap(),
136            FearGreedLabel::ExtremeGreed
137        );
138        assert!(parse_classification("unknown").is_err());
139    }
140
141    #[test]
142    fn test_fear_greed_from_response() {
143        let resp = FearAndGreedApiResponse {
144            data: vec![FearAndGreedEntry {
145                value: "25".to_string(),
146                value_classification: "Fear".to_string(),
147                timestamp: "1700000000".to_string(),
148            }],
149        };
150        let fg = FearAndGreed::from_response(resp).unwrap();
151        assert_eq!(fg.value, 25);
152        assert_eq!(fg.classification, FearGreedLabel::Fear);
153        assert_eq!(fg.timestamp, 1700000000);
154    }
155
156    #[test]
157    fn test_empty_data_returns_error() {
158        let resp = FearAndGreedApiResponse { data: vec![] };
159        assert!(FearAndGreed::from_response(resp).is_err());
160    }
161}