finance_query_core/client/
yahoo_client.rs

1//! Yahoo Finance API client.
2//!
3//! This module provides the main client for interacting with Yahoo Finance APIs.
4
5use 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
13/// Main client for Yahoo Finance API requests.
14///
15/// This client handles authenticated requests to Yahoo Finance,
16/// automatically managing crumb tokens and retrying on auth failures.
17pub struct YahooFinanceClient {
18    auth_manager: Arc<YahooAuthManager>,
19    fetch_client: Arc<FetchClient>,
20}
21
22impl YahooFinanceClient {
23    /// Create a new YahooFinanceClient.
24    ///
25    /// # Arguments
26    /// * `auth_manager` - Shared authentication manager
27    /// * `fetch_client` - Shared HTTP client
28    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        // First attempt with cached crumb
41        match self.yahoo_request_inner(url, params).await {
42            Ok(response) => Ok(response),
43            Err(YahooError::AuthFailed(msg)) => {
44                // Got 401, force refresh and retry once
45                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        // Log cookies for debugging
68        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                    // Log individual cookie names (not values for security)
80                    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        // Create a new client with the cookie jar from auth manager
97        // IMPORTANT: Also use the proxy for API requests
98        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        // Add proxy configuration if available
104        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            // Log response body for debugging
140            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    /// Get detailed quote data for a symbol.
199    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(&params)).await
209    }
210
211    /// Get simple quote data for multiple symbols.
212    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(&params)).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    /// Get chart data for a symbol.
234    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(&params)).await
246    }
247
248    /// Get chart data using period1 and period2 (Unix timestamps) instead of range.
249    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(&params)).await
266    }
267
268    /// Search for symbols.
269    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(&params)).await
273    }
274
275    /// Get similar/recommended quotes for a symbol.
276    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(&params)).await
284    }
285
286
287    /// Get fundamentals timeseries data.
288    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(&params)).await
312    }
313
314    /// Get quote summary with specified modules.
315    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(&params)).await
331    }
332
333    /// Get quote type information.
334    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    /// Get earnings transcript.
343    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(&params)).await
357    }
358
359    /// Make a Yahoo Finance API request and return the raw response.
360    /// This is useful for endpoints that need custom handling.
361    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    /// Get stock actions (dividends, splits, capital gains) for a symbol
370    ///
371    /// # Arguments
372    /// * `symbol` - The stock symbol
373    /// * `period` - Time period (e.g., "max", "5y", "1y")
374    ///
375    /// # Example
376    /// ```rust,ignore
377    /// let actions = client.get_actions("AAPL", "max").await?;
378    /// println!("Total dividends: {}", actions.total_dividends());
379    /// ```
380    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(&params)).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    /// Get only dividends for a symbol
409    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    /// Get only stock splits for a symbol
419    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    /// Get only capital gains for a symbol
429    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    /// Get option chain for a symbol and expiration date
439    ///
440    /// # Arguments
441    /// * `symbol` - The stock symbol
442    /// * `date` - Optional expiration date (YYYY-MM-DD). If None, returns nearest expiration
443    ///
444    /// # Example
445    /// ```rust,ignore
446    /// let chain = client.get_option_chain("AAPL", Some("2025-12-19")).await?;
447    /// println!("Calls: {}, Puts: {}", chain.calls.len(), chain.puts.len());
448    /// ```
449    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            // Convert date to Unix timestamp
463            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(&params)).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    /// Get all available option expiration dates for a symbol
481    ///
482    /// # Example
483    /// ```rust,ignore
484    /// let expirations = client.get_option_expirations("AAPL").await?;
485    /// for exp in &expirations.expirations {
486    ///     println!("Expiration: {}", exp);
487    /// }
488    /// ```
489    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    /// Get calendar events (earnings dates, dividend dates) for a symbol
511    ///
512    /// # Example
513    /// ```rust,ignore
514    /// let calendar = client.get_calendar("AAPL").await?;
515    /// if let Some(date) = calendar.earnings_date {
516    ///     println!("Next earnings: {}", date);
517    /// }
518    /// ```
519    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(&params)).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    /// Get SEC filings for a symbol
547    ///
548    /// # Example
549    /// ```rust,ignore
550    /// let filings = client.get_sec_filings("AAPL").await?;
551    /// for filing in &filings.filings {
552    ///     println!("{}: {} - {}", filing.date, filing.filing_type, filing.title);
553    /// }
554    /// ```
555    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(&params)).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    /// Get ESG/Sustainability scores for a symbol
583    ///
584    /// # Example
585    /// ```rust,ignore
586    /// let esg = client.get_sustainability("AAPL").await?;
587    /// if let Some(score) = esg.total_esg {
588    ///     println!("ESG Score: {:.1} (Rating: {})", score, esg.rating().unwrap_or("N/A"));
589    /// }
590    /// ```
591    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(&params)).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    /// Get industry data including top performing and growth companies
619    ///
620    /// # Arguments
621    /// * `industry_key` - The industry key (e.g., "technology-hardware")
622    ///
623    /// # Example
624    /// ```rust,ignore
625    /// let industry = client.get_industry("technology-hardware").await?;
626    /// println!("Industry: {} (Sector: {:?})", industry.name, industry.sector_name);
627    /// ```
628    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    /// Get market status (open/closed/pre/post)
650    ///
651    /// # Arguments
652    /// * `market` - Market identifier (e.g., "us_market", "gb_market")
653    ///
654    /// # Example
655    /// ```rust,ignore
656    /// let status = client.get_market_status("us_market").await?;
657    /// println!("Market is {}", if status.is_open() { "open" } else { "closed" });
658    /// ```
659    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(&params)).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    /// Get market summary with major indices
685    ///
686    /// # Arguments
687    /// * `market` - Market identifier (e.g., "us_market")
688    ///
689    /// # Example
690    /// ```rust,ignore
691    /// let summary = client.get_market_summary("us_market").await?;
692    /// for index in &summary.indices {
693    ///     println!("{}: {} ({:+.2}%)", index.short_name, index.price, index.percent_change);
694    /// }
695    /// ```
696    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(&params)).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        // Optionally get market status
719        let status = self.get_market_status(market).await.ok();
720
721        MarketSummaryResponse::from_yahoo_response(market.to_string(), yahoo_response, status)
722    }
723
724    /// Get market movers (most active, gainers, losers)
725    ///
726    /// # Arguments
727    /// * `count` - Number of movers to return (25, 50, or 100)
728    ///
729    /// # Example
730    /// ```rust,ignore
731    /// use finance_query_core::MoverCount;
732    /// 
733    /// let (actives, gainers, losers) = client.get_movers(MoverCount::Fifty).await?;
734    /// println!("Top gainer: {} ({:+})", gainers[0].name, gainers[0].percent_change);
735    /// ```
736    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        // Fetch screener data for each category
743        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        // Fetch all three concurrently
757        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                // Filter for US stocks only (no dots or with US exchange suffixes)
795                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}