finance_query_core/streaming/
mod.rs1use 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
21pub struct QuoteStream;
23
24impl QuoteStream {
25 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 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
87pub struct SingleQuoteStream;
89
90impl SingleQuoteStream {
91 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
122pub struct IndexStream;
124
125impl IndexStream {
126 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 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 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(), "^DJI".to_string(), "^IXIC".to_string(), ];
203 Self::create(client, symbols, poll_interval)
204 }
205}
206
207fn 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
214fn 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
223fn 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
233async 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("e.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
312pub struct MoversStream;
314
315impl MoversStream {
316 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 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 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
396fn 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 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 use crate::models::MoverCount;
478
479 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}