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