Skip to main content

ferric_fred/
observation.rs

1use chrono::NaiveDate;
2use serde::{Deserialize, Deserializer, Serialize};
3
4/// A single observation in a FRED series: a calendar date, its value, and the
5/// real-time period that value was current for (ALFRED — ADR-0024).
6///
7/// FRED transmits the value as a string and encodes a *missing* value as the
8/// sentinel `"."`, which maps to `None`. Any other value parses to `Some(f64)`;
9/// a non-`"."` value that fails to parse is a deserialization error, not a
10/// silent `None` (see ADR-0004 and ADR-0005).
11///
12/// [`realtime_start`](Observation::realtime_start) /
13/// [`realtime_end`](Observation::realtime_end) bound the period the value was the
14/// current one — the ALFRED dimension. For a plain latest query FRED returns
15/// today for both; a point-in-time or `vintage_dates` query returns the period
16/// the archived value held.
17///
18/// On *serialization* the value is emitted as a JSON number or `null` — typed
19/// JSON for consumers, not FRED's stringly-typed `"."` wire format.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
22pub struct Observation {
23    /// Start of the real-time period this value was current for (ALFRED). Equal
24    /// to today for a latest query; the archived vintage's start otherwise.
25    pub realtime_start: NaiveDate,
26
27    /// End of the real-time period this value was current for (ALFRED). Equal to
28    /// today for a latest query; the archived vintage's end otherwise.
29    pub realtime_end: NaiveDate,
30
31    /// The observation date. FRED dates are calendar dates with no time or zone,
32    /// which [`NaiveDate`] models exactly.
33    pub date: NaiveDate,
34
35    /// The observation value; `None` when FRED reports it as missing (`"."`).
36    #[serde(deserialize_with = "deserialize_value")]
37    pub value: Option<f64>,
38}
39
40/// Deserialize a FRED observation value: `"."` → `None`, otherwise parse the
41/// string as `f64`.
42fn deserialize_value<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
43where
44    D: Deserializer<'de>,
45{
46    let raw = String::deserialize(deserializer)?;
47    if raw == "." {
48        return Ok(None);
49    }
50    raw.parse::<f64>()
51        .map(Some)
52        .map_err(serde::de::Error::custom)
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn missing_value_maps_to_none() {
61        let obs: Observation = serde_json::from_str(
62            r#"{"realtime_start":"2026-07-06","realtime_end":"2026-07-06","date":"1930-01-01","value":"."}"#,
63        )
64        .unwrap();
65        assert_eq!(obs.value, None);
66        assert_eq!(obs.date, NaiveDate::from_ymd_opt(1930, 1, 1).unwrap());
67    }
68
69    #[test]
70    fn numeric_value_parses() {
71        let obs: Observation = serde_json::from_str(
72            r#"{"realtime_start":"2026-07-06","realtime_end":"2026-07-06","date":"1929-01-01","value":"1065.9"}"#,
73        )
74        .unwrap();
75        assert_eq!(obs.value, Some(1065.9));
76    }
77
78    #[test]
79    fn realtime_period_deserializes() {
80        // A point-in-time (vintage) row: the value carries the archived period.
81        let obs: Observation = serde_json::from_str(
82            r#"{"realtime_start":"2020-03-26","realtime_end":"2021-03-25","date":"1929-01-01","value":"1120.076"}"#,
83        )
84        .unwrap();
85        assert_eq!(
86            obs.realtime_start,
87            NaiveDate::from_ymd_opt(2020, 3, 26).unwrap()
88        );
89        assert_eq!(
90            obs.realtime_end,
91            NaiveDate::from_ymd_opt(2021, 3, 25).unwrap()
92        );
93        assert_eq!(obs.value, Some(1120.076));
94    }
95
96    #[test]
97    fn unparseable_value_is_an_error() {
98        let parsed: Result<Observation, _> = serde_json::from_str(
99            r#"{"realtime_start":"2026-07-06","realtime_end":"2026-07-06","date":"1929-01-01","value":"not a number"}"#,
100        );
101        assert!(parsed.is_err());
102    }
103
104    #[test]
105    fn serializes_value_as_number_or_null() {
106        let today = NaiveDate::from_ymd_opt(2026, 7, 6).unwrap();
107        let present = Observation {
108            realtime_start: today,
109            realtime_end: today,
110            date: NaiveDate::from_ymd_opt(1929, 1, 1).unwrap(),
111            value: Some(1065.9),
112        };
113        assert_eq!(
114            serde_json::to_value(&present).unwrap(),
115            serde_json::json!({
116                "realtime_start": "2026-07-06", "realtime_end": "2026-07-06",
117                "date": "1929-01-01", "value": 1065.9
118            })
119        );
120
121        let missing = Observation {
122            realtime_start: today,
123            realtime_end: today,
124            date: NaiveDate::from_ymd_opt(1930, 1, 1).unwrap(),
125            value: None,
126        };
127        assert_eq!(
128            serde_json::to_value(&missing).unwrap(),
129            serde_json::json!({
130                "realtime_start": "2026-07-06", "realtime_end": "2026-07-06",
131                "date": "1930-01-01", "value": null
132            })
133        );
134    }
135}