Skip to main content

finance_query/streaming/
economic.rs

1//! Economic-release streaming.
2//!
3//! Macro data has no push transport — series are revised on a publication
4//! calendar — so this is a poll loop that emits a purpose-built
5//! [`SeriesUpdate`] only when a series' latest observation actually
6//! changes, rather than pushing an unrelated price-tick shape.
7
8use std::collections::HashMap;
9use std::sync::Arc;
10use std::time::Duration;
11
12use futures::StreamExt;
13use serde::{Deserialize, Serialize};
14use tokio::sync::{broadcast, mpsc};
15use tracing::warn;
16
17use super::handle::{SourceStream, stream_handle};
18use super::source::StreamCommand;
19
20/// Default interval between polls of all subscribed series.
21const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(900);
22
23/// Channel capacity — macro releases are rare compared to price ticks.
24const CHANNEL_CAPACITY: usize = 128;
25
26/// Concurrent FRED polls per tick — the adapter's own limiter still paces
27/// them, this only stops one slow series from serialising the rest.
28const POLL_CONCURRENCY: usize = 8;
29
30/// A newly published (or revised) observation for an economic series.
31#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
32#[serde(rename_all = "camelCase")]
33#[non_exhaustive]
34pub struct SeriesUpdate {
35    /// Series identifier (e.g. `"FEDFUNDS"`, `"CPIAUCSL"`).
36    pub series_id: String,
37    /// Observation date as `YYYY-MM-DD`.
38    pub date: String,
39    /// Newly published value, or `None` when the source reports a gap.
40    pub value: Option<f64>,
41    /// Value this release replaced: the prior observation, or the prior value
42    /// for the same date when the release is a revision.
43    pub previous_value: Option<f64>,
44    /// `true` when the same observation date was re-published with a new value.
45    pub revision: bool,
46    /// Unix timestamp (seconds) at which this release was observed.
47    pub observed_at: i64,
48}
49
50/// Fetches the latest observation for a series.
51///
52/// A trait rather than a direct FRED call so the poll loop can be exercised
53/// without a socket.
54#[async_trait::async_trait]
55pub(crate) trait ReleaseSource: Send + Sync + 'static {
56    async fn latest(&self, series_id: &str) -> Option<(String, Option<f64>)>;
57}
58
59stream_handle! {
60    /// A continuous subscription to economic-series releases.
61    ///
62    /// Polls each subscribed series on an interval (15 minutes by default) and
63    /// yields a [`SeriesUpdate`] only when the latest observation is new or
64    /// revised. Requires the `fred` feature and
65    /// [`fred::init`](crate::fred::init).
66    ///
67    /// # Example
68    ///
69    /// ```no_run
70    /// use finance_query::streaming::EconomicStream;
71    /// use futures::StreamExt;
72    ///
73    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
74    /// let mut stream = EconomicStream::subscribe(["FEDFUNDS", "CPIAUCSL"]).await;
75    ///
76    /// while let Some(release) = stream.next().await {
77    ///     println!("{} = {:?} ({})", release.series_id, release.value, release.date);
78    /// }
79    /// # Ok(())
80    /// # }
81    /// ```
82    EconomicStream(SeriesUpdate);
83    add: add_series = "Add series to the subscription.",
84    remove: remove_series = "Remove series from the subscription.",
85}
86
87impl EconomicStream {
88    /// Subscribe to the given series, polling every 15 minutes.
89    pub async fn subscribe<S, I>(series: I) -> Self
90    where
91        S: Into<String>,
92        I: IntoIterator<Item = S>,
93    {
94        EconomicStreamBuilder::new().series(series).build().await
95    }
96
97    pub(crate) fn start(
98        source: Arc<dyn ReleaseSource>,
99        series: Vec<String>,
100        poll_interval: Duration,
101    ) -> Self {
102        EconomicStream {
103            inner: SourceStream::spawn(CHANNEL_CAPACITY, move |broadcast_tx, command_rx| {
104                run_economic_loop(source, series, poll_interval, broadcast_tx, command_rx)
105            }),
106        }
107    }
108}
109
110/// Builder for an [`EconomicStream`] with a custom poll interval.
111pub struct EconomicStreamBuilder {
112    series: Vec<String>,
113    poll_interval: Duration,
114}
115
116impl EconomicStreamBuilder {
117    /// Create a builder with no series and the default 15-minute interval.
118    pub fn new() -> Self {
119        Self {
120            series: Vec::new(),
121            poll_interval: DEFAULT_POLL_INTERVAL,
122        }
123    }
124
125    /// Add series identifiers to poll.
126    pub fn series<S, I>(mut self, series: I) -> Self
127    where
128        S: Into<String>,
129        I: IntoIterator<Item = S>,
130    {
131        self.series.extend(series.into_iter().map(Into::into));
132        self
133    }
134
135    /// Set the interval between polls (default: 15 minutes).
136    pub fn poll_interval(mut self, interval: Duration) -> Self {
137        self.poll_interval = interval;
138        self
139    }
140
141    /// Start the stream.
142    pub async fn build(self) -> EconomicStream {
143        EconomicStream::start(
144            Arc::new(DefaultReleaseSource),
145            self.series,
146            self.poll_interval,
147        )
148    }
149}
150
151impl Default for EconomicStreamBuilder {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157/// FRED-backed release source.
158struct DefaultReleaseSource;
159
160#[async_trait::async_trait]
161impl ReleaseSource for DefaultReleaseSource {
162    async fn latest(&self, series_id: &str) -> Option<(String, Option<f64>)> {
163        // Only the newest observation matters here; the full series is decades
164        // of rows to discard.
165        match crate::adapters::fred::latest_observation(series_id).await {
166            Ok(observation) => observation.map(|o| (o.date, o.value)),
167            Err(e) => {
168                warn!("economic stream poll failed for {series_id}: {e}");
169                None
170            }
171        }
172    }
173}
174
175/// Last observation seen per series, used to detect new vs. revised releases.
176#[derive(Clone)]
177struct LastSeen {
178    date: String,
179    value: Option<f64>,
180}
181
182/// Poll one series, carrying its id through so results can be reordered.
183async fn poll_one(
184    source: Arc<dyn ReleaseSource>,
185    id: String,
186) -> (String, Option<(String, Option<f64>)>) {
187    let observation = source.latest(&id).await;
188    (id, observation)
189}
190
191/// Poll every subscribed series concurrently.
192///
193/// A serial loop would hold the poll arm for N round-trips, during which the
194/// loop cannot service subscribe/unsubscribe commands.
195async fn poll_all(
196    source: &Arc<dyn ReleaseSource>,
197    series: &[String],
198) -> Vec<(String, Option<(String, Option<f64>)>)> {
199    let mut polls = Vec::with_capacity(series.len());
200    for id in series {
201        polls.push(poll_one(Arc::clone(source), id.clone()));
202    }
203    futures::stream::iter(polls)
204        .buffer_unordered(POLL_CONCURRENCY)
205        .collect()
206        .await
207}
208
209async fn run_economic_loop(
210    source: Arc<dyn ReleaseSource>,
211    initial_series: Vec<String>,
212    poll_interval: Duration,
213    broadcast_tx: broadcast::Sender<SeriesUpdate>,
214    mut command_rx: mpsc::Receiver<StreamCommand>,
215) {
216    let mut series: Vec<String> = initial_series;
217    let mut seen: HashMap<String, LastSeen> = HashMap::new();
218
219    let mut ticker = tokio::time::interval(poll_interval);
220    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
221
222    loop {
223        tokio::select! {
224            _ = ticker.tick() => {
225                for (id, observation) in poll_all(&source, &series).await {
226                    let Some((date, value)) = observation else {
227                        continue;
228                    };
229                    let release = classify(&id, &date, value, seen.get(&id));
230                    seen.insert(id, LastSeen { date, value });
231                    if let Some(release) = release {
232                        let _ = broadcast_tx.send(release);
233                    }
234                }
235            }
236            cmd = command_rx.recv() => {
237                match cmd {
238                    Some(StreamCommand::Subscribe(added)) => {
239                        for id in added {
240                            if !series.contains(&id) {
241                                series.push(id);
242                            }
243                        }
244                    }
245                    Some(StreamCommand::Unsubscribe(removed)) => {
246                        series.retain(|id| !removed.contains(id));
247                        for id in removed {
248                            seen.remove(&id);
249                        }
250                    }
251                    Some(StreamCommand::Close) | None => break,
252                }
253            }
254        }
255    }
256}
257
258/// Decide whether an observation is worth emitting.
259///
260/// The first poll of a series only records a baseline — emitting there would
261/// report every subscribe as a fresh release.
262fn classify(
263    series_id: &str,
264    date: &str,
265    value: Option<f64>,
266    previous: Option<&LastSeen>,
267) -> Option<SeriesUpdate> {
268    let previous = previous?;
269    let revision = previous.date == date;
270    if revision && previous.value == value {
271        return None;
272    }
273    Some(SeriesUpdate {
274        series_id: series_id.to_string(),
275        date: date.to_string(),
276        value,
277        previous_value: previous.value,
278        revision,
279        observed_at: chrono::Utc::now().timestamp(),
280    })
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use std::sync::atomic::{AtomicUsize, Ordering};
287
288    /// Canned source: returns a scripted observation per poll, no network.
289    struct ScriptedSource {
290        observations: Vec<(String, Option<f64>)>,
291        calls: AtomicUsize,
292    }
293
294    #[async_trait::async_trait]
295    impl ReleaseSource for ScriptedSource {
296        async fn latest(&self, _series_id: &str) -> Option<(String, Option<f64>)> {
297            let idx = self.calls.fetch_add(1, Ordering::SeqCst);
298            self.observations.get(idx).cloned()
299        }
300    }
301
302    #[test]
303    fn first_observation_only_sets_a_baseline() {
304        assert!(classify("FEDFUNDS", "2026-01-01", Some(5.0), None).is_none());
305    }
306
307    #[test]
308    fn unchanged_observation_is_not_a_release() {
309        let last = LastSeen {
310            date: "2026-01-01".into(),
311            value: Some(5.0),
312        };
313        assert!(classify("FEDFUNDS", "2026-01-01", Some(5.0), Some(&last)).is_none());
314    }
315
316    #[test]
317    fn same_date_with_a_new_value_is_a_revision() {
318        let last = LastSeen {
319            date: "2026-01-01".into(),
320            value: Some(5.0),
321        };
322        let release = classify("FEDFUNDS", "2026-01-01", Some(5.25), Some(&last)).unwrap();
323        assert!(release.revision);
324        assert_eq!(release.previous_value, Some(5.0));
325        assert_eq!(release.value, Some(5.25));
326    }
327
328    #[test]
329    fn a_new_date_is_a_fresh_release() {
330        let last = LastSeen {
331            date: "2026-01-01".into(),
332            value: Some(5.0),
333        };
334        let release = classify("FEDFUNDS", "2026-02-01", Some(5.5), Some(&last)).unwrap();
335        assert!(!release.revision);
336        assert_eq!(release.date, "2026-02-01");
337    }
338
339    #[tokio::test]
340    async fn poll_loop_emits_only_changed_observations() {
341        let source = Arc::new(ScriptedSource {
342            observations: vec![
343                ("2026-01-01".into(), Some(5.0)),
344                ("2026-01-01".into(), Some(5.0)),
345                ("2026-02-01".into(), Some(5.5)),
346            ],
347            calls: AtomicUsize::new(0),
348        });
349
350        let mut stream = EconomicStream::start(
351            source,
352            vec!["FEDFUNDS".to_string()],
353            Duration::from_millis(10),
354        );
355
356        let release = tokio::time::timeout(Duration::from_secs(5), stream.next())
357            .await
358            .expect("timed out")
359            .expect("stream ended");
360        assert_eq!(release.date, "2026-02-01");
361        assert_eq!(release.previous_value, Some(5.0));
362        stream.close().await;
363    }
364
365    #[tokio::test]
366    async fn close_ends_the_stream() {
367        let source = Arc::new(ScriptedSource {
368            observations: Vec::new(),
369            calls: AtomicUsize::new(0),
370        });
371        let mut stream = EconomicStream::start(source, Vec::new(), Duration::from_millis(10));
372        stream.close().await;
373        let ended = tokio::time::timeout(Duration::from_secs(2), stream.next()).await;
374        assert!(matches!(ended, Ok(None)));
375    }
376}