1use crate::client::error::YahooError;
6use crate::client::fetch_client::FetchClient;
7use crate::client::yahoo_auth::YahooAuthManager;
8use reqwest::cookie::CookieStore;
9use serde_json::Value;
10use std::sync::Arc;
11use tracing::{debug, error, info, warn};
12
13pub struct YahooFinanceClient {
18 auth_manager: Arc<YahooAuthManager>,
19 fetch_client: Arc<FetchClient>,
20}
21
22impl YahooFinanceClient {
23 pub fn new(auth_manager: Arc<YahooAuthManager>, fetch_client: Arc<FetchClient>) -> Self {
29 Self {
30 auth_manager,
31 fetch_client,
32 }
33 }
34
35 async fn yahoo_request(
36 &self,
37 url: &str,
38 params: Option<&[(&str, &str)]>,
39 ) -> Result<reqwest::Response, YahooError> {
40 match self.yahoo_request_inner(url, params).await {
42 Ok(response) => Ok(response),
43 Err(YahooError::AuthFailed(msg)) => {
44 warn!(
46 "Got 401 Unauthorized: {}. Forcing auth refresh and retrying once",
47 msg
48 );
49 self.auth_manager.refresh().await?;
50 info!("Auth refreshed, retrying request");
51 self.yahoo_request_inner(url, params).await
52 }
53 Err(e) => Err(e),
54 }
55 }
56
57
58 async fn yahoo_request_inner(
59 &self,
60 url: &str,
61 params: Option<&[(&str, &str)]>,
62 ) -> Result<reqwest::Response, YahooError> {
63 debug!("Getting crumb for Yahoo request");
64 let (cookie_jar, crumb) = self.auth_manager.get_or_refresh().await?;
65 debug!("Got crumb (length: {}): {}", crumb.len(), &crumb);
66
67 if let Ok(url_parsed) = url::Url::parse(url) {
69 if let Some(cookie_header) = cookie_jar.cookies(&url_parsed) {
70 if let Ok(cookie_str) = cookie_header.to_str() {
71 let cookie_count = cookie_str.split(';').count();
72 debug!(
73 "Using {} cookies for request to {}",
74 cookie_count,
75 url_parsed.host_str().unwrap_or("unknown")
76 );
77 debug!("Cookie header length: {} bytes", cookie_str.len());
78
79 for cookie in cookie_str.split(';') {
81 if let Some(name) = cookie.trim().split('=').next() {
82 debug!(" Cookie: {}", name);
83 }
84 }
85 } else {
86 warn!("Could not read cookie header");
87 }
88 } else {
89 warn!(
90 "No cookies found in jar for {}",
91 url_parsed.host_str().unwrap_or("unknown")
92 );
93 }
94 }
95
96 let mut builder = reqwest::ClientBuilder::new()
99 .timeout(std::time::Duration::from_secs(30))
100 .cookie_provider(cookie_jar.clone())
101 .redirect(reqwest::redirect::Policy::limited(10));
102
103 if let Some(proxy_url) = self.fetch_client.auth_proxy() {
105 debug!(
106 "Using proxy for Yahoo API request: {}...",
107 &proxy_url.chars().take(30).collect::<String>()
108 );
109 builder = builder
110 .proxy(reqwest::Proxy::all(proxy_url).map_err(YahooError::NetworkError)?)
111 .danger_accept_invalid_certs(true);
112 }
113
114 let client = builder.build().map_err(YahooError::NetworkError)?;
115
116 let mut request = client
117 .get(url)
118 .header(
119 "User-Agent",
120 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
121 )
122 .header("Accept", "application/json")
123 .header("Accept-Language", "en-US,en;q=0.9")
124 .header("Referer", "https://finance.yahoo.com/")
125 .query(&[("crumb", crumb.as_str())]);
126
127 if let Some(params) = params {
128 request = request.query(params);
129 }
130
131 debug!("Sending Yahoo request to: {}", url);
132 let response = request.send().await.map_err(YahooError::NetworkError)?;
133
134 let status = response.status();
135 debug!("Yahoo API response status: {}", status);
136
137 if status == 401 {
138 error!("Yahoo API returned 401 Unauthorized. Crumb may be invalid or expired.");
139 if let Ok(body) = response.text().await {
141 debug!(
142 "401 response body (first 200 chars): {}",
143 body.chars().take(200).collect::<String>()
144 );
145 }
146 return Err(YahooError::AuthFailed("Yahoo auth failed".to_string()));
147 }
148 if status == 404 {
149 return Err(YahooError::NotFound("Yahoo symbol not found".to_string()));
150 }
151 if status == 429 {
152 return Err(YahooError::RateLimited);
153 }
154 if !status.is_success() {
155 return Err(YahooError::HttpError(
156 status.as_u16(),
157 format!(
158 "HTTP {}: {}",
159 status,
160 response.status().canonical_reason().unwrap_or("Unknown")
161 ),
162 ));
163 }
164
165 Ok(response)
166 }
167
168
169 async fn json(
170 &self,
171 url: &str,
172 params: Option<&[(&str, &str)]>,
173 ) -> Result<Value, YahooError> {
174 debug!("Making JSON request to: {}", url);
175 if let Some(params) = params {
176 debug!("Request params: {:?}", params);
177 }
178 let response = self.yahoo_request(url, params).await?;
179 let status = response.status();
180 info!("Received response with status: {}", status);
181 let text = response.text().await.map_err(YahooError::NetworkError)?;
182 debug!("Response text length: {} bytes", text.len());
183 debug!(
184 "Response preview (first 500 chars): {}",
185 &text.chars().take(500).collect::<String>()
186 );
187 serde_json::from_str(&text).map_err(|e| {
188 error!(
189 "Failed to parse JSON response from {}: {}. Response text: {}",
190 url,
191 e,
192 &text.chars().take(200).collect::<String>()
193 );
194 YahooError::ParseError(format!("Failed to parse JSON response from {}: {}", url, e))
195 })
196 }
197
198 pub async fn get_quote(&self, symbol: &str) -> Result<Value, YahooError> {
200 let url = format!(
201 "https://query2.finance.yahoo.com/v10/finance/quoteSummary/{}",
202 symbol
203 );
204 let params = [(
205 "modules",
206 "assetProfile,price,summaryDetail,defaultKeyStatistics,calendarEvents,quoteUnadjustedPerformanceOverview",
207 )];
208 self.json(&url, Some(¶ms)).await
209 }
210
211 pub async fn get_simple_quotes(&self, symbols: &[&str]) -> Result<Value, YahooError> {
213 info!("Fetching simple quotes for symbols: {:?}", symbols);
214 let url = "https://query1.finance.yahoo.com/v7/finance/quote";
215 let symbols_str = symbols.join(",");
216 let params = [("symbols", symbols_str.as_str())];
217 let result = self.json(url, Some(¶ms)).await;
218 match &result {
219 Ok(data) => {
220 info!("Successfully received quote data");
221 debug!(
222 "Quote data keys: {:?}",
223 data.as_object().map(|o| o.keys().collect::<Vec<_>>())
224 );
225 }
226 Err(e) => {
227 error!("Failed to fetch simple quotes: {}", e);
228 }
229 }
230 result
231 }
232
233 pub async fn get_chart(
235 &self,
236 symbol: &str,
237 interval: &str,
238 range: &str,
239 ) -> Result<Value, YahooError> {
240 let url = format!(
241 "https://query1.finance.yahoo.com/v8/finance/chart/{}",
242 symbol
243 );
244 let params = [("interval", interval), ("range", range)];
245 self.json(url.as_str(), Some(¶ms)).await
246 }
247
248 pub async fn get_chart_with_periods(
250 &self,
251 symbol: &str,
252 interval: &str,
253 period1: i64,
254 period2: i64,
255 ) -> Result<Value, YahooError> {
256 let url = format!(
257 "https://query1.finance.yahoo.com/v8/finance/chart/{}",
258 symbol
259 );
260 let params = [
261 ("interval", interval),
262 ("period1", &period1.to_string()),
263 ("period2", &period2.to_string()),
264 ];
265 self.json(url.as_str(), Some(¶ms)).await
266 }
267
268 pub async fn search(&self, query: &str, hits: usize) -> Result<Value, YahooError> {
270 let url = "https://query1.finance.yahoo.com/v1/finance/search";
271 let params = [("q", query), ("quotesCount", &hits.to_string())];
272 self.json(url, Some(¶ms)).await
273 }
274
275 pub async fn get_similar_quotes(&self, symbol: &str, limit: usize) -> Result<Value, YahooError> {
277 let url = format!(
278 "https://query2.finance.yahoo.com/v6/finance/recommendationsbysymbol/{}",
279 symbol
280 );
281 let count_str = limit.to_string();
282 let params = [("count", count_str.as_str())];
283 self.json(&url, Some(¶ms)).await
284 }
285
286
287 pub async fn get_fundamentals_timeseries(
289 &self,
290 symbol: &str,
291 period1: i64,
292 period2: i64,
293 types: &[&str],
294 ) -> Result<Value, YahooError> {
295 let url = format!(
296 "https://query1.finance.yahoo.com/ws/fundamentals-timeseries/v1/finance/timeseries/{}",
297 symbol
298 );
299 let types_str = types.join(",");
300 let period1_str = period1.to_string();
301 let period2_str = period2.to_string();
302 let params = [
303 ("merge", "false"),
304 ("padTimeSeries", "true"),
305 ("period1", period1_str.as_str()),
306 ("period2", period2_str.as_str()),
307 ("type", types_str.as_str()),
308 ("lang", "en-US"),
309 ("region", "US"),
310 ];
311 self.json(&url, Some(¶ms)).await
312 }
313
314 pub async fn get_quote_summary(
316 &self,
317 symbol: &str,
318 modules: &[&str],
319 ) -> Result<Value, YahooError> {
320 let url = format!(
321 "https://query2.finance.yahoo.com/v10/finance/quoteSummary/{}",
322 symbol
323 );
324 let modules_str = modules.join(",");
325 let params = [
326 ("modules", modules_str.as_str()),
327 ("corsDomain", "finance.yahoo.com"),
328 ("formatted", "false"),
329 ];
330 self.json(&url, Some(¶ms)).await
331 }
332
333 pub async fn get_quote_type(&self, symbol: &str) -> Result<Value, YahooError> {
335 let url = format!(
336 "https://query1.finance.yahoo.com/v1/finance/quoteType/{}",
337 symbol
338 );
339 self.json(&url, None).await
340 }
341
342 pub async fn get_earnings_transcript(
344 &self,
345 event_id: &str,
346 company_id: &str,
347 ) -> Result<Value, YahooError> {
348 let url = "https://finance.yahoo.com/xhr/transcript";
349 let params = [
350 ("eventType", "earnings_call"),
351 ("quartrId", company_id),
352 ("eventId", event_id),
353 ("lang", "en-US"),
354 ("region", "US"),
355 ];
356 self.json(url, Some(¶ms)).await
357 }
358
359 pub async fn make_request(
362 &self,
363 url: &str,
364 params: Option<&[(&str, &str)]>,
365 ) -> Result<reqwest::Response, YahooError> {
366 self.yahoo_request(url, params).await
367 }
368
369 pub async fn get_actions(
381 &self,
382 symbol: &str,
383 period: &str,
384 ) -> Result<crate::models::ActionsResponse, YahooError> {
385 use crate::models::actions::{ActionsResponse, YahooEventsResponse};
386
387 let url = format!(
388 "https://query1.finance.yahoo.com/v8/finance/chart/{}",
389 symbol
390 );
391
392 let params = [
393 ("interval", "1d"),
394 ("range", period),
395 ("events", "div,split,capitalGains"),
396 ];
397
398 let response = self.yahoo_request(&url, Some(¶ms)).await?;
399 let text = response.text().await.map_err(YahooError::NetworkError)?;
400
401 let yahoo_response: YahooEventsResponse = serde_json::from_str(&text).map_err(|e| {
402 YahooError::ParseError(format!("Failed to parse actions response: {}", e))
403 })?;
404
405 ActionsResponse::from_yahoo_response(symbol.to_string(), yahoo_response)
406 }
407
408 pub async fn get_dividends(
410 &self,
411 symbol: &str,
412 period: &str,
413 ) -> Result<Vec<crate::models::Dividend>, YahooError> {
414 let actions = self.get_actions(symbol, period).await?;
415 Ok(actions.dividends)
416 }
417
418 pub async fn get_splits(
420 &self,
421 symbol: &str,
422 period: &str,
423 ) -> Result<Vec<crate::models::StockSplit>, YahooError> {
424 let actions = self.get_actions(symbol, period).await?;
425 Ok(actions.splits)
426 }
427
428 pub async fn get_capital_gains(
430 &self,
431 symbol: &str,
432 period: &str,
433 ) -> Result<Vec<crate::models::CapitalGain>, YahooError> {
434 let actions = self.get_actions(symbol, period).await?;
435 Ok(actions.capital_gains)
436 }
437
438 pub async fn get_option_chain(
450 &self,
451 symbol: &str,
452 date: Option<&str>,
453 ) -> Result<crate::models::OptionChain, YahooError> {
454 use crate::models::options::{date_to_timestamp, OptionChain, YahooOptionsResponse};
455
456 let url = format!(
457 "https://query2.finance.yahoo.com/v7/finance/options/{}",
458 symbol
459 );
460
461 let response = if let Some(exp_date) = date {
462 let timestamp = date_to_timestamp(exp_date)?;
464 let timestamp_str = timestamp.to_string();
465 let params = [("date", timestamp_str.as_str())];
466 self.yahoo_request(&url, Some(¶ms)).await?
467 } else {
468 self.yahoo_request(&url, None).await?
469 };
470
471 let text = response.text().await.map_err(YahooError::NetworkError)?;
472 let yahoo_response: YahooOptionsResponse = serde_json::from_str(&text).map_err(|e| {
473 YahooError::ParseError(format!("Failed to parse options response: {}", e))
474 })?;
475
476 let expiration_date = date.unwrap_or("nearest").to_string();
477 OptionChain::from_yahoo_response(symbol.to_string(), expiration_date, yahoo_response)
478 }
479
480 pub async fn get_option_expirations(
490 &self,
491 symbol: &str,
492 ) -> Result<crate::models::OptionExpirations, YahooError> {
493 use crate::models::options::{OptionExpirations, YahooOptionsResponse};
494
495 let url = format!(
496 "https://query2.finance.yahoo.com/v7/finance/options/{}",
497 symbol
498 );
499
500 let response = self.yahoo_request(&url, None).await?;
501 let text = response.text().await.map_err(YahooError::NetworkError)?;
502
503 let yahoo_response: YahooOptionsResponse = serde_json::from_str(&text).map_err(|e| {
504 YahooError::ParseError(format!("Failed to parse options response: {}", e))
505 })?;
506
507 OptionExpirations::from_yahoo_response(symbol.to_string(), yahoo_response)
508 }
509
510 pub async fn get_calendar(
520 &self,
521 symbol: &str,
522 ) -> Result<crate::models::Calendar, YahooError> {
523 use crate::models::calendar::{Calendar, YahooCalendarResponse};
524
525 let url = format!(
526 "https://query2.finance.yahoo.com/v10/finance/quoteSummary/{}",
527 symbol
528 );
529
530 let params = [
531 ("modules", "calendarEvents"),
532 ("corsDomain", "finance.yahoo.com"),
533 ("formatted", "false"),
534 ];
535
536 let response = self.yahoo_request(&url, Some(¶ms)).await?;
537 let text = response.text().await.map_err(YahooError::NetworkError)?;
538
539 let yahoo_response: YahooCalendarResponse = serde_json::from_str(&text).map_err(|e| {
540 YahooError::ParseError(format!("Failed to parse calendar response: {}", e))
541 })?;
542
543 Calendar::from_yahoo_response(symbol.to_string(), yahoo_response)
544 }
545
546 pub async fn get_sec_filings(
556 &self,
557 symbol: &str,
558 ) -> Result<crate::models::SecFilingsResponse, YahooError> {
559 use crate::models::sec_filings::{SecFilingsResponse, YahooSecFilingsResponse};
560
561 let url = format!(
562 "https://query2.finance.yahoo.com/v10/finance/quoteSummary/{}",
563 symbol
564 );
565
566 let params = [
567 ("modules", "secFilings"),
568 ("corsDomain", "finance.yahoo.com"),
569 ("formatted", "false"),
570 ];
571
572 let response = self.yahoo_request(&url, Some(¶ms)).await?;
573 let text = response.text().await.map_err(YahooError::NetworkError)?;
574
575 let yahoo_response: YahooSecFilingsResponse = serde_json::from_str(&text).map_err(|e| {
576 YahooError::ParseError(format!("Failed to parse SEC filings response: {}", e))
577 })?;
578
579 SecFilingsResponse::from_yahoo_response(symbol.to_string(), yahoo_response)
580 }
581
582 pub async fn get_sustainability(
592 &self,
593 symbol: &str,
594 ) -> Result<crate::models::SustainabilityScores, YahooError> {
595 use crate::models::sustainability::{SustainabilityScores, YahooEsgResponse};
596
597 let url = format!(
598 "https://query2.finance.yahoo.com/v10/finance/quoteSummary/{}",
599 symbol
600 );
601
602 let params = [
603 ("modules", "esgScores"),
604 ("corsDomain", "finance.yahoo.com"),
605 ("formatted", "false"),
606 ];
607
608 let response = self.yahoo_request(&url, Some(¶ms)).await?;
609 let text = response.text().await.map_err(YahooError::NetworkError)?;
610
611 let yahoo_response: YahooEsgResponse = serde_json::from_str(&text).map_err(|e| {
612 YahooError::ParseError(format!("Failed to parse ESG response: {}", e))
613 })?;
614
615 SustainabilityScores::from_yahoo_response(symbol.to_string(), yahoo_response)
616 }
617
618 pub async fn get_industry(
629 &self,
630 industry_key: &str,
631 ) -> Result<crate::models::Industry, YahooError> {
632 use crate::models::industry::{Industry, YahooIndustryResponse};
633
634 let url = format!(
635 "https://query2.finance.yahoo.com/v1/finance/industries/{}",
636 industry_key
637 );
638
639 let response = self.yahoo_request(&url, None).await?;
640 let text = response.text().await.map_err(YahooError::NetworkError)?;
641
642 let yahoo_response: YahooIndustryResponse = serde_json::from_str(&text).map_err(|e| {
643 YahooError::ParseError(format!("Failed to parse industry response: {}", e))
644 })?;
645
646 Industry::from_yahoo_response(yahoo_response)
647 }
648
649 pub async fn get_market_status(
660 &self,
661 market: &str,
662 ) -> Result<crate::models::MarketStatus, YahooError> {
663 use crate::models::market::{MarketStatus, YahooMarketTimeResponse};
664
665 let url = "https://query1.finance.yahoo.com/v6/finance/markettime";
666
667 let params = [
668 ("formatted", "true"),
669 ("key", "finance"),
670 ("lang", "en-US"),
671 ("market", market),
672 ];
673
674 let response = self.yahoo_request(url, Some(¶ms)).await?;
675 let text = response.text().await.map_err(YahooError::NetworkError)?;
676
677 let yahoo_response: YahooMarketTimeResponse = serde_json::from_str(&text).map_err(|e| {
678 YahooError::ParseError(format!("Failed to parse market time response: {}", e))
679 })?;
680
681 MarketStatus::from_yahoo_response(market.to_string(), yahoo_response)
682 }
683
684 pub async fn get_market_summary(
697 &self,
698 market: &str,
699 ) -> Result<crate::models::MarketSummaryResponse, YahooError> {
700 use crate::models::market::{MarketSummaryResponse, YahooMarketSummaryResponse};
701
702 let url = "https://query1.finance.yahoo.com/v6/finance/quote/marketSummary";
703
704 let params = [
705 ("fields", "shortName,regularMarketPrice,regularMarketChange,regularMarketChangePercent"),
706 ("formatted", "false"),
707 ("lang", "en-US"),
708 ("market", market),
709 ];
710
711 let response = self.yahoo_request(url, Some(¶ms)).await?;
712 let text = response.text().await.map_err(YahooError::NetworkError)?;
713
714 let yahoo_response: YahooMarketSummaryResponse = serde_json::from_str(&text).map_err(|e| {
715 YahooError::ParseError(format!("Failed to parse market summary response: {}", e))
716 })?;
717
718 let status = self.get_market_status(market).await.ok();
720
721 MarketSummaryResponse::from_yahoo_response(market.to_string(), yahoo_response, status)
722 }
723
724 pub async fn get_movers(
737 &self,
738 count: crate::models::MoverCount,
739 ) -> Result<(Vec<crate::models::MarketMover>, Vec<crate::models::MarketMover>, Vec<crate::models::MarketMover>), YahooError> {
740 let count_str = count.as_str();
741
742 let actives_url = format!(
744 "https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved?count={}&scrIds=most_actives",
745 count_str
746 );
747 let gainers_url = format!(
748 "https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved?count={}&scrIds=day_gainers",
749 count_str
750 );
751 let losers_url = format!(
752 "https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved?count={}&scrIds=day_losers",
753 count_str
754 );
755
756 let (actives_response, gainers_response, losers_response) = tokio::join!(
758 self.yahoo_request(&actives_url, None),
759 self.yahoo_request(&gainers_url, None),
760 self.yahoo_request(&losers_url, None)
761 );
762
763 let actives = Self::parse_movers_response(actives_response?).await?;
764 let gainers = Self::parse_movers_response(gainers_response?).await?;
765 let losers = Self::parse_movers_response(losers_response?).await?;
766
767 Ok((actives, gainers, losers))
768 }
769
770 async fn parse_movers_response(
771 response: reqwest::Response,
772 ) -> Result<Vec<crate::models::MarketMover>, YahooError> {
773 let text = response.text().await.map_err(YahooError::NetworkError)?;
774 let data: Value = serde_json::from_str(&text).map_err(|e| {
775 YahooError::ParseError(format!("Failed to parse movers response: {}", e))
776 })?;
777
778 let mut movers = Vec::new();
779
780 if let Some(quotes) = data
781 .get("finance")
782 .and_then(|f| f.get("result"))
783 .and_then(|r| r.get(0))
784 .and_then(|r| r.get("quotes"))
785 .and_then(|q| q.as_array())
786 {
787 for quote in quotes {
788 let symbol = quote
789 .get("symbol")
790 .and_then(|s| s.as_str())
791 .unwrap_or("")
792 .to_string();
793
794 if !symbol.is_empty() && !symbol.contains('.')
796 || symbol.ends_with(".OB")
797 || symbol.ends_with(".PK")
798 {
799 let name = quote
800 .get("longName")
801 .or_else(|| quote.get("shortName"))
802 .and_then(|n| n.as_str())
803 .unwrap_or("")
804 .to_string();
805
806 let price = quote
807 .get("regularMarketPrice")
808 .and_then(|p| p.as_f64())
809 .map(|p| format!("{:.2}", p))
810 .unwrap_or_else(|| "0.00".to_string());
811
812 let change = quote
813 .get("regularMarketChange")
814 .and_then(|c| c.as_f64())
815 .map(|c| format!("{:+.2}", c))
816 .unwrap_or_else(|| "0.00".to_string());
817
818 let percent_change = quote
819 .get("regularMarketChangePercent")
820 .and_then(|p| p.as_f64())
821 .map(|p| format!("{:+.2}%", p))
822 .unwrap_or_else(|| "0.00%".to_string());
823
824 movers.push(crate::models::MarketMover {
825 symbol,
826 name,
827 price,
828 change,
829 percent_change,
830 });
831 }
832 }
833 }
834
835 Ok(movers)
836 }
837}