1use crate::client::{ClientConfig, YahooClient};
6use crate::constants::{Interval, TimeRange};
7use crate::error::{Result, YahooError};
8use crate::models::chart::Chart;
9use crate::models::chart::response::ChartResponse;
10use crate::models::chart::result::ChartResult;
11use crate::models::quote::Quote;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::Duration;
16use tokio::sync::RwLock;
17
18type ChartCacheKey = (String, Interval, TimeRange);
20
21type ChartCache = Arc<RwLock<HashMap<ChartCacheKey, ChartResult>>>;
23
24type QuoteCache = Arc<RwLock<HashMap<String, Quote>>>;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30#[non_exhaustive]
31pub struct BatchQuotesResponse {
32 pub quotes: HashMap<String, Quote>,
34 pub errors: HashMap<String, String>,
36}
37
38impl BatchQuotesResponse {
39 pub(crate) fn new() -> Self {
40 Self {
41 quotes: HashMap::new(),
42 errors: HashMap::new(),
43 }
44 }
45
46 pub fn success_count(&self) -> usize {
48 self.quotes.len()
49 }
50
51 pub fn error_count(&self) -> usize {
53 self.errors.len()
54 }
55
56 pub fn all_successful(&self) -> bool {
58 self.errors.is_empty()
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65#[non_exhaustive]
66pub struct BatchChartsResponse {
67 pub charts: HashMap<String, Chart>,
69 pub errors: HashMap<String, String>,
71}
72
73impl BatchChartsResponse {
74 pub(crate) fn new() -> Self {
75 Self {
76 charts: HashMap::new(),
77 errors: HashMap::new(),
78 }
79 }
80
81 pub fn success_count(&self) -> usize {
83 self.charts.len()
84 }
85
86 pub fn error_count(&self) -> usize {
88 self.errors.len()
89 }
90
91 pub fn all_successful(&self) -> bool {
93 self.errors.is_empty()
94 }
95}
96
97pub struct TickersBuilder {
99 symbols: Vec<String>,
100 config: ClientConfig,
101}
102
103impl TickersBuilder {
104 fn new<S, I>(symbols: I) -> Self
105 where
106 S: Into<String>,
107 I: IntoIterator<Item = S>,
108 {
109 Self {
110 symbols: symbols.into_iter().map(|s| s.into()).collect(),
111 config: ClientConfig::default(),
112 }
113 }
114
115 pub fn region(mut self, region: crate::constants::Region) -> Self {
117 self.config.lang = region.lang().to_string();
118 self.config.region = region.region().to_string();
119 self
120 }
121
122 pub fn lang(mut self, lang: impl Into<String>) -> Self {
124 self.config.lang = lang.into();
125 self
126 }
127
128 pub fn region_code(mut self, region: impl Into<String>) -> Self {
130 self.config.region = region.into();
131 self
132 }
133
134 pub fn timeout(mut self, timeout: Duration) -> Self {
136 self.config.timeout = timeout;
137 self
138 }
139
140 pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
142 self.config.proxy = Some(proxy.into());
143 self
144 }
145
146 pub fn config(mut self, config: ClientConfig) -> Self {
148 self.config = config;
149 self
150 }
151
152 pub async fn build(self) -> Result<Tickers> {
154 let client = Arc::new(YahooClient::new(self.config).await?);
155
156 Ok(Tickers {
157 symbols: self.symbols,
158 client,
159 quote_cache: Arc::new(RwLock::new(HashMap::new())),
160 chart_cache: Arc::new(RwLock::new(HashMap::new())),
161 })
162 }
163}
164
165pub struct Tickers {
196 symbols: Vec<String>,
197 client: Arc<YahooClient>,
198 quote_cache: QuoteCache,
199 chart_cache: ChartCache,
200}
201
202impl Tickers {
203 pub async fn new<S, I>(symbols: I) -> Result<Self>
220 where
221 S: Into<String>,
222 I: IntoIterator<Item = S>,
223 {
224 Self::builder(symbols).build().await
225 }
226
227 pub fn builder<S, I>(symbols: I) -> TickersBuilder
229 where
230 S: Into<String>,
231 I: IntoIterator<Item = S>,
232 {
233 TickersBuilder::new(symbols)
234 }
235
236 pub fn symbols(&self) -> &[String] {
238 &self.symbols
239 }
240
241 pub fn len(&self) -> usize {
243 self.symbols.len()
244 }
245
246 pub fn is_empty(&self) -> bool {
248 self.symbols.is_empty()
249 }
250
251 pub async fn quotes(&self, include_logo: bool) -> Result<BatchQuotesResponse> {
260 {
262 let cache = self.quote_cache.read().await;
263 if self.symbols.iter().all(|s| cache.contains_key(s)) {
264 let mut response = BatchQuotesResponse::new();
265 for symbol in &self.symbols {
266 if let Some(quote) = cache.get(symbol) {
267 response.quotes.insert(symbol.clone(), quote.clone());
268 }
269 }
270 return Ok(response);
271 }
272 }
273
274 let symbols_ref: Vec<&str> = self.symbols.iter().map(|s| s.as_str()).collect();
276
277 let (json, logos) = if include_logo {
280 let quote_future = crate::endpoints::quotes::fetch_with_fields(
281 &self.client,
282 &symbols_ref,
283 None, true, false, );
287 let logo_future = crate::endpoints::quotes::fetch_with_fields(
288 &self.client,
289 &symbols_ref,
290 Some(&["logoUrl", "companyLogoUrl"]), true,
292 true, );
294 let (quote_result, logo_result) = tokio::join!(quote_future, logo_future);
295 (quote_result?, logo_result.ok())
296 } else {
297 let json = crate::endpoints::quotes::fetch_with_fields(
298 &self.client,
299 &symbols_ref,
300 None,
301 true,
302 false,
303 )
304 .await?;
305 (json, None)
306 };
307
308 let logo_map: std::collections::HashMap<String, (Option<String>, Option<String>)> = logos
310 .and_then(|l| l.get("quoteResponse")?.get("result")?.as_array().cloned())
311 .map(|results| {
312 results
313 .iter()
314 .filter_map(|r| {
315 let symbol = r.get("symbol")?.as_str()?.to_string();
316 let logo_url = r
317 .get("logoUrl")
318 .and_then(|v| v.as_str())
319 .map(|s| s.to_string());
320 let company_logo_url = r
321 .get("companyLogoUrl")
322 .and_then(|v| v.as_str())
323 .map(|s| s.to_string());
324 Some((symbol, (logo_url, company_logo_url)))
325 })
326 .collect()
327 })
328 .unwrap_or_default();
329
330 let mut response = BatchQuotesResponse::new();
332
333 if let Some(quote_response) = json.get("quoteResponse") {
334 if let Some(results) = quote_response.get("result").and_then(|r| r.as_array()) {
335 let mut cache = self.quote_cache.write().await;
336
337 for result in results {
338 if let Some(symbol) = result.get("symbol").and_then(|s| s.as_str()) {
339 match Quote::from_batch_response(result) {
340 Ok(mut quote) => {
341 if let Some((logo_url, company_logo_url)) = logo_map.get(symbol) {
343 if quote.logo_url.is_none() {
344 quote.logo_url = logo_url.clone();
345 }
346 if quote.company_logo_url.is_none() {
347 quote.company_logo_url = company_logo_url.clone();
348 }
349 }
350 cache.insert(symbol.to_string(), quote.clone());
351 response.quotes.insert(symbol.to_string(), quote);
352 }
353 Err(e) => {
354 response.errors.insert(symbol.to_string(), e.to_string());
355 }
356 }
357 }
358 }
359 }
360
361 for symbol in &self.symbols {
363 if !response.quotes.contains_key(symbol) && !response.errors.contains_key(symbol) {
364 response
365 .errors
366 .insert(symbol.clone(), "Symbol not found in response".to_string());
367 }
368 }
369 }
370
371 Ok(response)
372 }
373
374 pub async fn quote(&self, symbol: &str, include_logo: bool) -> Result<Quote> {
376 {
377 let cache = self.quote_cache.read().await;
378 if let Some(quote) = cache.get(symbol) {
379 return Ok(quote.clone());
380 }
381 }
382
383 let response = self.quotes(include_logo).await?;
384
385 response
386 .quotes
387 .get(symbol)
388 .cloned()
389 .ok_or_else(|| YahooError::SymbolNotFound {
390 symbol: Some(symbol.to_string()),
391 context: response
392 .errors
393 .get(symbol)
394 .cloned()
395 .unwrap_or_else(|| "Symbol not found".to_string()),
396 })
397 }
398
399 pub async fn charts(
404 &self,
405 interval: Interval,
406 range: TimeRange,
407 ) -> Result<BatchChartsResponse> {
408 {
410 let cache = self.chart_cache.read().await;
411 if self
412 .symbols
413 .iter()
414 .all(|s| cache.contains_key(&(s.clone(), interval, range)))
415 {
416 let mut response = BatchChartsResponse::new();
417 for symbol in &self.symbols {
418 if let Some(result) = cache.get(&(symbol.clone(), interval, range)) {
419 response.charts.insert(
420 symbol.clone(),
421 Chart {
422 symbol: symbol.clone(),
423 meta: result.meta.clone(),
424 candles: result.to_candles(),
425 interval: Some(interval.as_str().to_string()),
426 range: Some(range.as_str().to_string()),
427 },
428 );
429 }
430 }
431 return Ok(response);
432 }
433 }
434
435 let futures: Vec<_> = self
437 .symbols
438 .iter()
439 .map(|symbol| {
440 let client = Arc::clone(&self.client);
441 let symbol = symbol.clone();
442 async move {
443 let result = client.get_chart(&symbol, interval, range).await;
444 (symbol, result)
445 }
446 })
447 .collect();
448
449 let results = futures::future::join_all(futures).await;
450
451 let mut response = BatchChartsResponse::new();
452 let mut cache = self.chart_cache.write().await;
453
454 for (symbol, result) in results {
455 match result {
456 Ok(json) => match ChartResponse::from_json(json) {
457 Ok(chart_response) => {
458 if let Some(mut chart_results) = chart_response.chart.result {
459 if let Some(chart_result) = chart_results.pop() {
460 let chart = Chart {
461 symbol: symbol.clone(),
462 meta: chart_result.meta.clone(),
463 candles: chart_result.to_candles(),
464 interval: Some(interval.as_str().to_string()),
465 range: Some(range.as_str().to_string()),
466 };
467 cache.insert((symbol.clone(), interval, range), chart_result);
468 response.charts.insert(symbol, chart);
469 } else {
470 response
471 .errors
472 .insert(symbol, "Empty chart response".to_string());
473 }
474 } else {
475 response
476 .errors
477 .insert(symbol, "No chart data in response".to_string());
478 }
479 }
480 Err(e) => {
481 response.errors.insert(symbol, e.to_string());
482 }
483 },
484 Err(e) => {
485 response.errors.insert(symbol, e.to_string());
486 }
487 }
488 }
489
490 Ok(response)
491 }
492
493 pub async fn chart(&self, symbol: &str, interval: Interval, range: TimeRange) -> Result<Chart> {
495 {
496 let cache = self.chart_cache.read().await;
497 if let Some(result) = cache.get(&(symbol.to_string(), interval, range)) {
498 return Ok(Chart {
499 symbol: symbol.to_string(),
500 meta: result.meta.clone(),
501 candles: result.to_candles(),
502 interval: Some(interval.as_str().to_string()),
503 range: Some(range.as_str().to_string()),
504 });
505 }
506 }
507
508 let response = self.charts(interval, range).await?;
509
510 response
511 .charts
512 .get(symbol)
513 .cloned()
514 .ok_or_else(|| YahooError::SymbolNotFound {
515 symbol: Some(symbol.to_string()),
516 context: response
517 .errors
518 .get(symbol)
519 .cloned()
520 .unwrap_or_else(|| "Symbol not found".to_string()),
521 })
522 }
523
524 pub async fn clear_cache(&self) {
526 self.quote_cache.write().await.clear();
527 self.chart_cache.write().await.clear();
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 #[tokio::test]
536 #[ignore] async fn test_tickers_quotes() {
538 let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await.unwrap();
539 let result = tickers.quotes(false).await.unwrap();
540
541 assert!(result.success_count() > 0);
542 }
543
544 #[tokio::test]
545 #[ignore] async fn test_tickers_charts() {
547 let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
548 let result = tickers
549 .charts(Interval::OneDay, TimeRange::FiveDays)
550 .await
551 .unwrap();
552
553 assert!(result.success_count() > 0);
554 }
555}