Skip to main content

finance_query_core/streaming/
mod.rs

1//! Streaming functionality for real-time data.
2//!
3//! This module provides async streams for continuously fetching financial data
4//! at configurable intervals. These streams can be used with any async runtime
5//! and integrated into WebSocket servers or other streaming applications.
6
7use crate::client::error::YahooError;
8use crate::client::YahooFinanceClient;
9use crate::models::{LogoFetcher, SimpleQuote};
10use crate::websocket::QuotesUpdate;
11use async_stream::stream;
12use chrono::Utc;
13use futures_util::{future::join_all, Stream};
14use serde_json::Value;
15use std::pin::Pin;
16use std::sync::Arc;
17use std::time::Duration;
18use tokio::time::interval;
19use tracing::{debug, error};
20
21/// A stream that yields quote updates at regular intervals.
22pub struct QuoteStream;
23
24impl QuoteStream {
25    /// Create a new quote stream that fetches quotes for the given symbols
26    /// at the specified interval.
27    ///
28    /// # Arguments
29    /// * `client` - The Yahoo Finance client to use for fetching quotes
30    /// * `symbols` - List of stock symbols to track
31    /// * `poll_interval` - How often to fetch new quotes
32    ///
33    /// # Example
34    /// ```rust,ignore
35    /// use finance_query_core::{QuoteStream, YahooFinanceClient};
36    /// use std::time::Duration;
37    /// use futures_util::StreamExt;
38    ///
39    /// let stream = QuoteStream::create(&client, vec!["AAPL", "GOOGL"], Duration::from_secs(5));
40    ///
41    /// while let Some(result) = stream.next().await {
42    ///     match result {
43    ///         Ok(update) => println!("Got {} quotes", update.len()),
44    ///         Err(e) => eprintln!("Error: {}", e),
45    ///     }
46    /// }
47    /// ```
48    pub fn create(
49        client: Arc<YahooFinanceClient>,
50        symbols: Vec<String>,
51        poll_interval: Duration,
52    ) -> Pin<Box<dyn Stream<Item = Result<QuotesUpdate, YahooError>> + Send>> {
53        let logo_fetcher = Arc::new(LogoFetcher::new(client.fetch_client()));
54        Box::pin(stream! {
55            let mut ticker = interval(poll_interval);
56
57            loop {
58                ticker.tick().await;
59                debug!("Fetching quotes for {:?}", symbols);
60
61                let symbol_refs: Vec<&str> = symbols.iter().map(|s| s.as_str()).collect();
62
63                match client.get_simple_quotes(&symbol_refs).await {
64                    Ok(data) => {
65                        let quotes = parse_simple_quotes(&data, Some(logo_fetcher.clone())).await;
66                        let update = QuotesUpdate::with_timestamp(quotes, Utc::now());
67                        yield Ok(update);
68                    }
69                    Err(e) => {
70                        error!("Failed to fetch quotes: {}", e);
71                        yield Err(e);
72                    }
73                }
74            }
75        })
76    }
77
78    /// Create a quote stream with a default 5-second interval.
79    pub fn with_default_interval(
80        client: Arc<YahooFinanceClient>,
81        symbols: Vec<String>,
82    ) -> Pin<Box<dyn Stream<Item = Result<QuotesUpdate, YahooError>> + Send>> {
83        Self::create(client, symbols, Duration::from_secs(5))
84    }
85}
86
87/// A stream that yields a single quote update for one symbol.
88pub struct SingleQuoteStream;
89
90impl SingleQuoteStream {
91    /// Create a stream for a single symbol.
92    pub fn create(
93        client: Arc<YahooFinanceClient>,
94        symbol: String,
95        poll_interval: Duration,
96    ) -> Pin<Box<dyn Stream<Item = Result<SimpleQuote, YahooError>> + Send>> {
97        let logo_fetcher = Arc::new(LogoFetcher::new(client.fetch_client()));
98        Box::pin(stream! {
99            let mut ticker = interval(poll_interval);
100
101            loop {
102                ticker.tick().await;
103                debug!("Fetching quote for {}", symbol);
104
105                match client.get_simple_quotes(&[symbol.as_str()]).await {
106                    Ok(data) => {
107                        let quotes = parse_simple_quotes(&data, Some(logo_fetcher.clone())).await;
108                        if let Some(quote) = quotes.into_iter().next() {
109                            yield Ok(quote);
110                        }
111                    }
112                    Err(e) => {
113                        error!("Failed to fetch quote for {}: {}", symbol, e);
114                        yield Err(e);
115                    }
116                }
117            }
118        })
119    }
120}
121
122/// A stream that yields market index updates at regular intervals.
123pub struct IndexStream;
124
125impl IndexStream {
126    /// Create a new index stream that fetches index data at the specified interval.
127    ///
128    /// # Arguments
129    /// * `client` - The Yahoo Finance client to use for fetching data
130    /// * `index_symbols` - List of index symbols to track (e.g., "^GSPC", "^DJI", "^IXIC")
131    /// * `poll_interval` - How often to fetch new data
132    ///
133    /// # Example
134    /// ```rust,ignore
135    /// use finance_query_core::{IndexStream, YahooFinanceClient};
136    /// use std::time::Duration;
137    /// use futures_util::StreamExt;
138    ///
139    /// // Stream S&P 500, Dow Jones, and NASDAQ
140    /// let symbols = vec!["^GSPC".to_string(), "^DJI".to_string(), "^IXIC".to_string()];
141    /// let stream = IndexStream::create(&client, symbols, Duration::from_secs(5));
142    ///
143    /// while let Some(result) = stream.next().await {
144    ///     match result {
145    ///         Ok(indices) => {
146    ///             for index in indices {
147    ///                 println!("{}: {} ({:+})", index.name, index.value, index.percent_change);
148    ///             }
149    ///         }
150    ///         Err(e) => eprintln!("Error: {}", e),
151    ///     }
152    /// }
153    /// ```
154    pub fn create(
155        client: Arc<YahooFinanceClient>,
156        index_symbols: Vec<String>,
157        poll_interval: Duration,
158    ) -> Pin<Box<dyn Stream<Item = Result<Vec<crate::models::MarketIndex>, YahooError>> + Send>>
159    {
160        Box::pin(stream! {
161            let mut ticker = interval(poll_interval);
162
163            loop {
164                ticker.tick().await;
165                debug!("Fetching index data for {:?}", index_symbols);
166
167                let symbol_refs: Vec<&str> = index_symbols.iter().map(|s| s.as_str()).collect();
168
169                match client.get_simple_quotes(&symbol_refs).await {
170                    Ok(data) => {
171                        let indices = parse_market_indices(&data);
172                        yield Ok(indices);
173                    }
174                    Err(e) => {
175                        error!("Failed to fetch index data: {}", e);
176                        yield Err(e);
177                    }
178                }
179            }
180        })
181    }
182
183    /// Create an index stream with a default 5-second interval.
184    pub fn with_default_interval(
185        client: Arc<YahooFinanceClient>,
186        index_symbols: Vec<String>,
187    ) -> Pin<Box<dyn Stream<Item = Result<Vec<crate::models::MarketIndex>, YahooError>> + Send>>
188    {
189        Self::create(client, index_symbols, Duration::from_secs(5))
190    }
191
192    /// Create a stream for major US indices (S&P 500, Dow Jones, NASDAQ).
193    pub fn us_major_indices(
194        client: Arc<YahooFinanceClient>,
195        poll_interval: Duration,
196    ) -> Pin<Box<dyn Stream<Item = Result<Vec<crate::models::MarketIndex>, YahooError>> + Send>>
197    {
198        let symbols = vec![
199            "^GSPC".to_string(), // S&P 500
200            "^DJI".to_string(),  // Dow Jones
201            "^IXIC".to_string(), // NASDAQ
202        ];
203        Self::create(client, symbols, poll_interval)
204    }
205}
206
207/// Helper to extract quote results array from Yahoo Finance API response.
208fn get_quote_results(data: &Value) -> Option<&Vec<Value>> {
209    data.get("quoteResponse")
210        .and_then(|qr| qr.get("result"))
211        .and_then(|r| r.as_array())
212}
213
214/// Helper to extract string field from JSON, with fallback.
215fn get_string_field(result: &Value, field: &str, fallback: &str) -> String {
216    result
217        .get(field)
218        .and_then(|s| s.as_str())
219        .unwrap_or(fallback)
220        .to_string()
221}
222
223/// Helper to extract name field (tries longName, then shortName).
224fn get_name_field(result: &Value) -> String {
225    result
226        .get("longName")
227        .or_else(|| result.get("shortName"))
228        .and_then(|n| n.as_str())
229        .unwrap_or("")
230        .to_string()
231}
232
233/// Parse simple quotes from Yahoo Finance API response.
234async fn parse_simple_quotes(
235    data: &Value,
236    logo_fetcher: Option<Arc<LogoFetcher>>,
237) -> Vec<SimpleQuote> {
238    let Some(results) = get_quote_results(data) else {
239        return Vec::new();
240    };
241
242    let mut quotes_with_meta = Vec::with_capacity(results.len());
243
244    for result in results {
245        let symbol = get_string_field(result, "symbol", "");
246        let name = get_name_field(result);
247        let price = result
248            .get("regularMarketPrice")
249            .and_then(|p| p.as_f64())
250            .map(|p| format!("{:.2}", p))
251            .unwrap_or_else(|| "0.00".to_string());
252
253        let pre_market_price = result
254            .get("preMarketPrice")
255            .and_then(|p| p.as_f64())
256            .map(|p| format!("{:.2}", p));
257
258        let after_hours_price = result
259            .get("postMarketPrice")
260            .and_then(|p| p.as_f64())
261            .map(|p| format!("{:.2}", p));
262
263        let change = result
264            .get("regularMarketChange")
265            .and_then(|c| c.as_f64())
266            .map(|c| format!("{:+.2}", c))
267            .unwrap_or_else(|| "0.00".to_string());
268
269        let percent_change = result
270            .get("regularMarketChangePercent")
271            .and_then(|p| p.as_f64())
272            .map(|p| format!("{:+.2}%", p))
273            .unwrap_or_else(|| "0.00%".to_string());
274
275        let website = result
276            .get("website")
277            .and_then(|w| w.as_str())
278            .map(|w| w.to_string());
279
280        let quote = SimpleQuote {
281            symbol,
282            name,
283            price,
284            pre_market_price,
285            after_hours_price,
286            change,
287            percent_change,
288            logo: None,
289        };
290
291        quotes_with_meta.push((quote, website));
292    }
293
294    if let Some(fetcher) = logo_fetcher {
295        let tasks = quotes_with_meta.into_iter().map(|(mut quote, website)| {
296            let fetcher = fetcher.clone();
297            async move {
298                quote.logo = fetcher.fetch_logo(&quote.symbol, website.as_deref()).await;
299                quote
300            }
301        });
302
303        join_all(tasks).await
304    } else {
305        quotes_with_meta
306            .into_iter()
307            .map(|(quote, _)| quote)
308            .collect()
309    }
310}
311
312/// A stream that yields market movers (actives, gainers, losers) at regular intervals.
313pub struct MoversStream;
314
315impl MoversStream {
316    /// Create a new movers stream that fetches market movers at the specified interval.
317    ///
318    /// # Arguments
319    /// * `client` - The Yahoo Finance client to use for fetching data
320    /// * `count` - Number of movers to return (25, 50, or 100)
321    /// * `poll_interval` - How often to fetch new data
322    ///
323    /// # Example
324    /// ```rust,ignore
325    /// use finance_query_core::{MoversStream, YahooFinanceClient, MoverCount};
326    /// use std::time::Duration;
327    /// use futures_util::StreamExt;
328    ///
329    /// let stream = MoversStream::create(&client, MoverCount::Fifty, Duration::from_secs(5));
330    ///
331    /// while let Some(result) = stream.next().await {
332    ///     match result {
333    ///         Ok(update) => {
334    ///             println!("Actives: {}", update.actives.len());
335    ///             println!("Gainers: {}", update.gainers.len());
336    ///             println!("Losers: {}", update.losers.len());
337    ///         }
338    ///         Err(e) => eprintln!("Error: {}", e),
339    ///     }
340    /// }
341    /// ```
342    pub fn create(
343        client: Arc<YahooFinanceClient>,
344        count: crate::models::MoverCount,
345        poll_interval: Duration,
346    ) -> Pin<Box<dyn Stream<Item = Result<crate::websocket::MoversUpdate, YahooError>> + Send>>
347    {
348        Box::pin(stream! {
349            let mut ticker = interval(poll_interval);
350
351            loop {
352                ticker.tick().await;
353                debug!("Fetching market movers (count: {})", count.as_str());
354
355                match client.get_movers(count).await {
356                    Ok((actives, gainers, losers)) => {
357                        let update = crate::websocket::MoversUpdate::with_timestamp(
358                            actives,
359                            gainers,
360                            losers,
361                            Utc::now()
362                        );
363                        yield Ok(update);
364                    }
365                    Err(e) => {
366                        error!("Failed to fetch movers: {}", e);
367                        yield Err(e);
368                    }
369                }
370            }
371        })
372    }
373
374    /// Create a movers stream with a default 5-second interval.
375    pub fn with_default_interval(
376        client: Arc<YahooFinanceClient>,
377        count: crate::models::MoverCount,
378    ) -> Pin<Box<dyn Stream<Item = Result<crate::websocket::MoversUpdate, YahooError>> + Send>>
379    {
380        Self::create(client, count, Duration::from_secs(5))
381    }
382
383    /// Create a movers stream with default count (50) and interval (5 seconds).
384    pub fn with_defaults(
385        client: Arc<YahooFinanceClient>,
386    ) -> Pin<Box<dyn Stream<Item = Result<crate::websocket::MoversUpdate, YahooError>> + Send>>
387    {
388        Self::create(
389            client,
390            crate::models::MoverCount::default(),
391            Duration::from_secs(5),
392        )
393    }
394}
395
396/// Parse market indices from Yahoo Finance API response.
397fn parse_market_indices(data: &Value) -> Vec<crate::models::MarketIndex> {
398    let Some(results) = get_quote_results(data) else {
399        return Vec::new();
400    };
401
402    results
403        .iter()
404        .map(|result| {
405            let value = result
406                .get("regularMarketPrice")
407                .and_then(|p| p.as_f64())
408                .unwrap_or(0.0);
409
410            let change = result
411                .get("regularMarketChange")
412                .and_then(|c| c.as_f64())
413                .map(|c| format!("{:+.2}", c))
414                .unwrap_or_else(|| "0.00".to_string());
415
416            let percent_change = result
417                .get("regularMarketChangePercent")
418                .and_then(|p| p.as_f64())
419                .map(|p| format!("{:+.2}%", p))
420                .unwrap_or_else(|| "0.00%".to_string());
421
422            // TODO: Yahoo Finance API doesn't provide historical return data in quote responses.
423            // These would need to be calculated from historical price data or fetched separately.
424            crate::models::MarketIndex {
425                name: get_name_field(result),
426                value,
427                change,
428                percent_change,
429                five_days_return: None,
430                one_month_return: None,
431                three_month_return: None,
432                six_month_return: None,
433                ytd_return: None,
434                year_return: None,
435                three_year_return: None,
436                five_year_return: None,
437                ten_year_return: None,
438                max_return: None,
439            }
440        })
441        .collect()
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[tokio::test]
449    async fn test_parse_simple_quotes() {
450        let data = serde_json::json!({
451            "quoteResponse": {
452                "result": [
453                    {
454                        "symbol": "AAPL",
455                        "longName": "Apple Inc.",
456                        "regularMarketPrice": 175.50,
457                        "regularMarketChange": 2.50,
458                        "regularMarketChangePercent": 1.45
459                    }
460                ]
461            }
462        });
463
464        let quotes = parse_simple_quotes(&data, None).await;
465        assert_eq!(quotes.len(), 1);
466        assert_eq!(quotes[0].symbol, "AAPL");
467        assert_eq!(quotes[0].name, "Apple Inc.");
468        assert_eq!(quotes[0].price, "175.50");
469        assert_eq!(quotes[0].change, "+2.50");
470        assert_eq!(quotes[0].percent_change, "+1.45%");
471    }
472
473    #[test]
474    fn test_movers_stream_creation() {
475        // Test that we can create a MoversStream with different configurations
476        // This is a compile-time test to ensure the API is correct
477        use crate::models::MoverCount;
478
479        // These would require a real client to actually run, but we can verify
480        // the types compile correctly
481        let _count_25 = MoverCount::TwentyFive;
482        let _count_50 = MoverCount::Fifty;
483        let _count_100 = MoverCount::Hundred;
484
485        assert_eq!(_count_25.as_str(), "25");
486        assert_eq!(_count_50.as_str(), "50");
487        assert_eq!(_count_100.as_str(), "100");
488    }
489}