1use 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
20const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(900);
22
23const CHANNEL_CAPACITY: usize = 128;
25
26const POLL_CONCURRENCY: usize = 8;
29
30#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
32#[serde(rename_all = "camelCase")]
33#[non_exhaustive]
34pub struct SeriesUpdate {
35 pub series_id: String,
37 pub date: String,
39 pub value: Option<f64>,
41 pub previous_value: Option<f64>,
44 pub revision: bool,
46 pub observed_at: i64,
48}
49
50#[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 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 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
110pub struct EconomicStreamBuilder {
112 series: Vec<String>,
113 poll_interval: Duration,
114}
115
116impl EconomicStreamBuilder {
117 pub fn new() -> Self {
119 Self {
120 series: Vec::new(),
121 poll_interval: DEFAULT_POLL_INTERVAL,
122 }
123 }
124
125 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 pub fn poll_interval(mut self, interval: Duration) -> Self {
137 self.poll_interval = interval;
138 self
139 }
140
141 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
157struct DefaultReleaseSource;
159
160#[async_trait::async_trait]
161impl ReleaseSource for DefaultReleaseSource {
162 async fn latest(&self, series_id: &str) -> Option<(String, Option<f64>)> {
163 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#[derive(Clone)]
177struct LastSeen {
178 date: String,
179 value: Option<f64>,
180}
181
182async 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
191async 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
258fn 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 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}