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        Box::pin(stream! {
158            let mut ticker = interval(poll_interval);
159            
160            loop {
161                ticker.tick().await;
162                debug!("Fetching index data for {:?}", index_symbols);
163                
164                let symbol_refs: Vec<&str> = index_symbols.iter().map(|s| s.as_str()).collect();
165                
166                match client.get_simple_quotes(&symbol_refs).await {
167                    Ok(data) => {
168                        let indices = parse_market_indices(&data);
169                        yield Ok(indices);
170                    }
171                    Err(e) => {
172                        error!("Failed to fetch index data: {}", e);
173                        yield Err(e);
174                    }
175                }
176            }
177        })
178    }
179
180    /// Create an index stream with a default 5-second interval.
181    pub fn with_default_interval(
182        client: Arc<YahooFinanceClient>,
183        index_symbols: Vec<String>,
184    ) -> Pin<Box<dyn Stream<Item = Result<Vec<crate::models::MarketIndex>, YahooError>> + Send>> {
185        Self::create(client, index_symbols, Duration::from_secs(5))
186    }
187
188    /// Create a stream for major US indices (S&P 500, Dow Jones, NASDAQ).
189    pub fn us_major_indices(
190        client: Arc<YahooFinanceClient>,
191        poll_interval: Duration,
192    ) -> Pin<Box<dyn Stream<Item = Result<Vec<crate::models::MarketIndex>, YahooError>> + Send>> {
193        let symbols = vec![
194            "^GSPC".to_string(),  // S&P 500
195            "^DJI".to_string(),   // Dow Jones
196            "^IXIC".to_string(),  // NASDAQ
197        ];
198        Self::create(client, symbols, poll_interval)
199    }
200}
201
202/// Helper to extract quote results array from Yahoo Finance API response.
203fn get_quote_results(data: &Value) -> Option<&Vec<Value>> {
204    data.get("quoteResponse")
205        .and_then(|qr| qr.get("result"))
206        .and_then(|r| r.as_array())
207}
208
209/// Helper to extract string field from JSON, with fallback.
210fn get_string_field(result: &Value, field: &str, fallback: &str) -> String {
211    result.get(field)
212        .and_then(|s| s.as_str())
213        .unwrap_or(fallback)
214        .to_string()
215}
216
217/// Helper to extract name field (tries longName, then shortName).
218fn get_name_field(result: &Value) -> String {
219    result.get("longName")
220        .or_else(|| result.get("shortName"))
221        .and_then(|n| n.as_str())
222        .unwrap_or("")
223        .to_string()
224}
225
226/// Parse simple quotes from Yahoo Finance API response.
227fn parse_simple_quotes(data: &Value) -> Vec<SimpleQuote> {
228    let Some(results) = get_quote_results(data) else {
229        return Vec::new();
230    };
231    
232    results.iter().map(|result| {
233        SimpleQuote {
234            symbol: get_string_field(result, "symbol", ""),
235            name: get_name_field(result),
236            price: result.get("regularMarketPrice")
237                .and_then(|p| p.as_f64())
238                .map(|p| format!("{:.2}", p))
239                .unwrap_or_else(|| "0.00".to_string()),
240            pre_market_price: result.get("preMarketPrice")
241                .and_then(|p| p.as_f64())
242                .map(|p| format!("{:.2}", p)),
243            after_hours_price: result.get("postMarketPrice")
244                .and_then(|p| p.as_f64())
245                .map(|p| format!("{:.2}", p)),
246            change: result.get("regularMarketChange")
247                .and_then(|c| c.as_f64())
248                .map(|c| format!("{:+.2}", c))
249                .unwrap_or_else(|| "0.00".to_string()),
250            percent_change: result.get("regularMarketChangePercent")
251                .and_then(|p| p.as_f64())
252                .map(|p| format!("{:+.2}%", p))
253                .unwrap_or_else(|| "0.00%".to_string()),
254            logo: None,
255        }
256    }).collect()
257}
258
259/// A stream that yields market movers (actives, gainers, losers) at regular intervals.
260pub struct MoversStream;
261
262impl MoversStream {
263    /// Create a new movers stream that fetches market movers at the specified interval.
264    ///
265    /// # Arguments
266    /// * `client` - The Yahoo Finance client to use for fetching data
267    /// * `count` - Number of movers to return (25, 50, or 100)
268    /// * `poll_interval` - How often to fetch new data
269    ///
270    /// # Example
271    /// ```rust,ignore
272    /// use finance_query_core::{MoversStream, YahooFinanceClient, MoverCount};
273    /// use std::time::Duration;
274    /// use futures_util::StreamExt;
275    ///
276    /// let stream = MoversStream::create(&client, MoverCount::Fifty, Duration::from_secs(5));
277    /// 
278    /// while let Some(result) = stream.next().await {
279    ///     match result {
280    ///         Ok(update) => {
281    ///             println!("Actives: {}", update.actives.len());
282    ///             println!("Gainers: {}", update.gainers.len());
283    ///             println!("Losers: {}", update.losers.len());
284    ///         }
285    ///         Err(e) => eprintln!("Error: {}", e),
286    ///     }
287    /// }
288    /// ```
289    pub fn create(
290        client: Arc<YahooFinanceClient>,
291        count: crate::models::MoverCount,
292        poll_interval: Duration,
293    ) -> Pin<Box<dyn Stream<Item = Result<crate::websocket::MoversUpdate, YahooError>> + Send>> {
294        Box::pin(stream! {
295            let mut ticker = interval(poll_interval);
296            
297            loop {
298                ticker.tick().await;
299                debug!("Fetching market movers (count: {})", count.as_str());
300                
301                match client.get_movers(count).await {
302                    Ok((actives, gainers, losers)) => {
303                        let update = crate::websocket::MoversUpdate::with_timestamp(
304                            actives,
305                            gainers,
306                            losers,
307                            Utc::now()
308                        );
309                        yield Ok(update);
310                    }
311                    Err(e) => {
312                        error!("Failed to fetch movers: {}", e);
313                        yield Err(e);
314                    }
315                }
316            }
317        })
318    }
319
320    /// Create a movers stream with a default 5-second interval.
321    pub fn with_default_interval(
322        client: Arc<YahooFinanceClient>,
323        count: crate::models::MoverCount,
324    ) -> Pin<Box<dyn Stream<Item = Result<crate::websocket::MoversUpdate, YahooError>> + Send>> {
325        Self::create(client, count, Duration::from_secs(5))
326    }
327
328    /// Create a movers stream with default count (50) and interval (5 seconds).
329    pub fn with_defaults(
330        client: Arc<YahooFinanceClient>,
331    ) -> Pin<Box<dyn Stream<Item = Result<crate::websocket::MoversUpdate, YahooError>> + Send>> {
332        Self::create(client, crate::models::MoverCount::default(), Duration::from_secs(5))
333    }
334}
335
336/// Parse market indices from Yahoo Finance API response.
337fn parse_market_indices(data: &Value) -> Vec<crate::models::MarketIndex> {
338    let Some(results) = get_quote_results(data) else {
339        return Vec::new();
340    };
341    
342    results.iter().map(|result| {
343        let value = result.get("regularMarketPrice")
344            .and_then(|p| p.as_f64())
345            .unwrap_or(0.0);
346        
347        let change = result.get("regularMarketChange")
348            .and_then(|c| c.as_f64())
349            .map(|c| format!("{:+.2}", c))
350            .unwrap_or_else(|| "0.00".to_string());
351        
352        let percent_change = result.get("regularMarketChangePercent")
353            .and_then(|p| p.as_f64())
354            .map(|p| format!("{:+.2}%", p))
355            .unwrap_or_else(|| "0.00%".to_string());
356        
357        // TODO: Yahoo Finance API doesn't provide historical return data in quote responses.
358        // These would need to be calculated from historical price data or fetched separately.
359        crate::models::MarketIndex {
360            name: get_name_field(result),
361            value,
362            change,
363            percent_change,
364            five_days_return: None,
365            one_month_return: None,
366            three_month_return: None,
367            six_month_return: None,
368            ytd_return: None,
369            year_return: None,
370            three_year_return: None,
371            five_year_return: None,
372            ten_year_return: None,
373            max_return: None,
374        }
375    }).collect()
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn test_parse_simple_quotes() {
384        let data = serde_json::json!({
385            "quoteResponse": {
386                "result": [
387                    {
388                        "symbol": "AAPL",
389                        "longName": "Apple Inc.",
390                        "regularMarketPrice": 175.50,
391                        "regularMarketChange": 2.50,
392                        "regularMarketChangePercent": 1.45
393                    }
394                ]
395            }
396        });
397
398        let quotes = parse_simple_quotes(&data);
399        assert_eq!(quotes.len(), 1);
400        assert_eq!(quotes[0].symbol, "AAPL");
401        assert_eq!(quotes[0].name, "Apple Inc.");
402        assert_eq!(quotes[0].price, "175.50");
403        assert_eq!(quotes[0].change, "+2.50");
404        assert_eq!(quotes[0].percent_change, "+1.45%");
405    }
406
407    #[test]
408    fn test_movers_stream_creation() {
409        // Test that we can create a MoversStream with different configurations
410        // This is a compile-time test to ensure the API is correct
411        use crate::models::MoverCount;
412        
413        // These would require a real client to actually run, but we can verify
414        // the types compile correctly
415        let _count_25 = MoverCount::TwentyFive;
416        let _count_50 = MoverCount::Fifty;
417        let _count_100 = MoverCount::Hundred;
418        
419        assert_eq!(_count_25.as_str(), "25");
420        assert_eq!(_count_50.as_str(), "50");
421        assert_eq!(_count_100.as_str(), "100");
422    }
423}