finance_query_core/streaming/
mod.rs1use 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
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 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 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
86pub struct SingleQuoteStream;
88
89impl SingleQuoteStream {
90 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
120pub struct IndexStream;
122
123impl IndexStream {
124 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 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 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(), "^DJI".to_string(), "^IXIC".to_string(), ];
201 Self::create(client, symbols, poll_interval)
202 }
203}
204
205fn 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
212fn 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
221fn 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
231fn 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
270pub struct MoversStream;
272
273impl MoversStream {
274 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 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 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
354fn 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 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 use crate::models::MoverCount;
436
437 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}