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        Self::from_entry(entry)
78    }
79
80    /// Parse every entry in the response (current + historical), newest
81    /// first — matching the API's own ordering. Used for `limit > 1` requests.
82    pub(crate) fn vec_from_response(
83        resp: FearAndGreedApiResponse,
84    ) -> Result<Vec<Self>, crate::error::FinanceError> {
85        resp.data.into_iter().map(Self::from_entry).collect()
86    }
87
88    fn from_entry(entry: FearAndGreedEntry) -> Result<Self, crate::error::FinanceError> {
89        let value = entry.value.parse::<u8>().map_err(|_| {
90            crate::error::FinanceError::ResponseStructureError {
91                field: "value".to_string(),
92                context: format!("Cannot parse '{}' as u8", entry.value),
93            }
94        })?;
95
96        let classification = parse_classification(&entry.value_classification)?;
97
98        let timestamp = entry.timestamp.parse::<i64>().map_err(|_| {
99            crate::error::FinanceError::ResponseStructureError {
100                field: "timestamp".to_string(),
101                context: format!("Cannot parse '{}' as i64", entry.timestamp),
102            }
103        })?;
104
105        Ok(Self {
106            value,
107            classification,
108            timestamp,
109        })
110    }
111}
112
113pub(crate) fn parse_classification(s: &str) -> Result<FearGreedLabel, crate::error::FinanceError> {
114    match s {
115        "Extreme Fear" => Ok(FearGreedLabel::ExtremeFear),
116        "Fear" => Ok(FearGreedLabel::Fear),
117        "Neutral" => Ok(FearGreedLabel::Neutral),
118        "Greed" => Ok(FearGreedLabel::Greed),
119        "Extreme Greed" => Ok(FearGreedLabel::ExtremeGreed),
120        other => Err(crate::error::FinanceError::ResponseStructureError {
121            field: "value_classification".to_string(),
122            context: format!("Unknown classification '{other}'"),
123        }),
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_parse_classification() {
133        assert_eq!(
134            parse_classification("Extreme Fear").unwrap(),
135            FearGreedLabel::ExtremeFear
136        );
137        assert_eq!(parse_classification("Fear").unwrap(), FearGreedLabel::Fear);
138        assert_eq!(
139            parse_classification("Neutral").unwrap(),
140            FearGreedLabel::Neutral
141        );
142        assert_eq!(
143            parse_classification("Greed").unwrap(),
144            FearGreedLabel::Greed
145        );
146        assert_eq!(
147            parse_classification("Extreme Greed").unwrap(),
148            FearGreedLabel::ExtremeGreed
149        );
150        assert!(parse_classification("unknown").is_err());
151    }
152
153    #[test]
154    fn test_fear_greed_from_response() {
155        let resp = FearAndGreedApiResponse {
156            data: vec![FearAndGreedEntry {
157                value: "25".to_string(),
158                value_classification: "Fear".to_string(),
159                timestamp: "1700000000".to_string(),
160            }],
161        };
162        let fg = FearAndGreed::from_response(resp).unwrap();
163        assert_eq!(fg.value, 25);
164        assert_eq!(fg.classification, FearGreedLabel::Fear);
165        assert_eq!(fg.timestamp, 1700000000);
166    }
167
168    #[test]
169    fn test_empty_data_returns_error() {
170        let resp = FearAndGreedApiResponse { data: vec![] };
171        assert!(FearAndGreed::from_response(resp).is_err());
172    }
173}