Skip to main content

ferric_fred/
series.rs

1use chrono::NaiveDate;
2use serde::{Deserialize, Serialize};
3
4use crate::{Frequency, SeasonalAdjustment, SeriesId};
5
6/// Metadata describing a FRED series (the `fred/series` endpoint).
7///
8/// This `series` metadata endpoint's own ALFRED fields (`realtime_start` /
9/// `realtime_end`) are still ignored on the wire (ADR-0005); point-in-time
10/// *observations* are supported via [`Observation`](crate::Observation) and
11/// [`ObservationsRequest::realtime`](crate::ObservationsRequest::realtime)
12/// (ADR-0024). `last_updated` is kept as FRED's raw string for now — FRED
13/// encodes it with a non-standard timezone offset (e.g. `2024-03-28 07:56:03-05`);
14/// a typed datetime is a later refinement.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
17pub struct Series {
18    /// The series identifier.
19    pub id: SeriesId,
20
21    /// Human-readable title, e.g. `"Real Gross National Product"`.
22    pub title: String,
23
24    /// Date of the earliest available observation.
25    pub observation_start: NaiveDate,
26
27    /// Date of the latest available observation.
28    pub observation_end: NaiveDate,
29
30    /// The series' native reporting frequency.
31    pub frequency: Frequency,
32
33    /// Whether/how the series is seasonally adjusted.
34    pub seasonal_adjustment: SeasonalAdjustment,
35
36    /// Free-form units description, e.g. `"Billions of Chained 2017 Dollars"`.
37    /// This is descriptive text, *not* the closed-vocabulary units transform
38    /// used in observation requests (modelled separately, later).
39    pub units: String,
40
41    /// FRED popularity score (0–100).
42    pub popularity: u32,
43
44    /// Editorial notes, when present.
45    #[serde(default)]
46    pub notes: Option<String>,
47
48    /// When FRED last updated the series, as FRED's raw timestamp string.
49    pub last_updated: String,
50}
51
52/// A page of `series/search` results: the matching series plus FRED's
53/// pagination metadata. `count` is the total number of matches across all
54/// pages, not just this one — use it with `offset`/`limit` to page.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
57pub struct SeriesSearchResults {
58    /// The matching series on this page. FRED names the array `seriess` (sic) on
59    /// the wire; we read that but emit the correctly-spelled `series` on output.
60    #[serde(rename(deserialize = "seriess", serialize = "series"))]
61    pub series: Vec<Series>,
62
63    /// Total number of matches across all pages.
64    pub count: u32,
65
66    /// Offset of this page into the full result set.
67    pub offset: u32,
68
69    /// Page-size limit that FRED applied.
70    pub limit: u32,
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    // A representative `seriess[0]` object, including fields we intentionally
78    // ignore (`realtime_*`, the `*_short` codes) to prove they don't break
79    // deserialization.
80    const GNPCA_JSON: &str = r#"{
81        "id": "GNPCA",
82        "realtime_start": "2024-01-01",
83        "realtime_end": "2024-01-01",
84        "title": "Real Gross National Product",
85        "observation_start": "1929-01-01",
86        "observation_end": "2023-01-01",
87        "frequency": "Annual",
88        "frequency_short": "A",
89        "units": "Billions of Chained 2017 Dollars",
90        "units_short": "Bil. of Chn. 2017 $",
91        "seasonal_adjustment": "Not Seasonally Adjusted",
92        "seasonal_adjustment_short": "NSA",
93        "last_updated": "2024-03-28 07:56:03-05",
94        "popularity": 76,
95        "notes": "BEA Account Code: A001RX"
96    }"#;
97
98    #[test]
99    fn deserializes_series_metadata() {
100        let series: Series = serde_json::from_str(GNPCA_JSON).unwrap();
101
102        assert_eq!(series.id, SeriesId::new("GNPCA"));
103        assert_eq!(series.title, "Real Gross National Product");
104        assert_eq!(
105            series.observation_start,
106            NaiveDate::from_ymd_opt(1929, 1, 1).unwrap()
107        );
108        assert_eq!(series.frequency, Frequency::Annual);
109        assert_eq!(
110            series.seasonal_adjustment,
111            SeasonalAdjustment::NotSeasonallyAdjusted
112        );
113        assert_eq!(series.units, "Billions of Chained 2017 Dollars");
114        assert_eq!(series.popularity, 76);
115        assert_eq!(series.notes.as_deref(), Some("BEA Account Code: A001RX"));
116    }
117
118    #[test]
119    fn notes_default_to_none_when_absent() {
120        let json = r#"{
121            "id": "X",
122            "title": "T",
123            "observation_start": "2000-01-01",
124            "observation_end": "2001-01-01",
125            "frequency": "Monthly",
126            "seasonal_adjustment": "Seasonally Adjusted",
127            "units": "Percent",
128            "popularity": 0,
129            "last_updated": "2024-01-01 00:00:00-05"
130        }"#;
131        let series: Series = serde_json::from_str(json).unwrap();
132        assert_eq!(series.notes, None);
133    }
134
135    #[test]
136    fn deserializes_search_results_with_pagination() {
137        let json = format!(
138            r#"{{
139                "order_by": "search_rank",
140                "sort_order": "desc",
141                "count": 1,
142                "offset": 0,
143                "limit": 1000,
144                "seriess": [{GNPCA_JSON}]
145            }}"#
146        );
147        let results: SeriesSearchResults = serde_json::from_str(&json).unwrap();
148        assert_eq!(results.count, 1);
149        assert_eq!(results.offset, 0);
150        assert_eq!(results.limit, 1000);
151        assert_eq!(results.series.len(), 1);
152        assert_eq!(results.series[0].id, SeriesId::new("GNPCA"));
153    }
154
155    #[test]
156    fn serializes_with_typed_enum_labels_and_clean_keys() {
157        let series: Series = serde_json::from_str(GNPCA_JSON).unwrap();
158        let value = serde_json::to_value(&series).unwrap();
159        assert_eq!(value["id"], "GNPCA");
160        assert_eq!(value["frequency"], "Annual");
161        assert_eq!(value["seasonal_adjustment"], "Not Seasonally Adjusted");
162
163        let results = SeriesSearchResults {
164            series: vec![series],
165            count: 1,
166            offset: 0,
167            limit: 1,
168        };
169        let results_value = serde_json::to_value(&results).unwrap();
170        // The output uses the correctly-spelled `series`, not FRED's `seriess`.
171        assert!(results_value.get("series").is_some());
172        assert!(results_value.get("seriess").is_none());
173    }
174}