use crate::client::error::YahooError;
use crate::client::fetch_client::FetchClient;
use crate::client::yahoo_auth::YahooAuthManager;
use reqwest::cookie::CookieStore;
use serde_json::Value;
use std::sync::Arc;
use tracing::{debug, error, info, warn};
pub struct YahooFinanceClient {
auth_manager: Arc<YahooAuthManager>,
fetch_client: Arc<FetchClient>,
}
impl YahooFinanceClient {
pub fn new(auth_manager: Arc<YahooAuthManager>, fetch_client: Arc<FetchClient>) -> Self {
Self {
auth_manager,
fetch_client,
}
}
pub fn fetch_client(&self) -> Arc<FetchClient> {
Arc::clone(&self.fetch_client)
}
async fn yahoo_request(
&self,
url: &str,
params: Option<&[(&str, &str)]>,
) -> Result<reqwest::Response, YahooError> {
match self.yahoo_request_inner(url, params).await {
Ok(response) => Ok(response),
Err(YahooError::AuthFailed(msg)) => {
warn!(
"Got 401 Unauthorized: {}. Forcing auth refresh and retrying once",
msg
);
self.auth_manager.refresh().await?;
info!("Auth refreshed, retrying request");
self.yahoo_request_inner(url, params).await
}
Err(YahooError::HttpError(code, msg)) if code >= 400 => {
warn!(
"HTTP {} indicating possible auth issue ({}). Switching auth strategy and retrying once.",
code, msg
);
self.auth_manager.switch_strategy_and_refresh().await?;
info!("Strategy switched, retrying request");
self.yahoo_request_inner(url, params).await
}
Err(YahooError::RateLimited) => {
warn!("Received 429 Too Many Requests. Switching auth strategy and retrying once.");
self.auth_manager.switch_strategy_and_refresh().await?;
info!("Strategy switched after 429, retrying request");
self.yahoo_request_inner(url, params).await
}
Err(e) => Err(e),
}
}
async fn yahoo_request_inner(
&self,
url: &str,
params: Option<&[(&str, &str)]>,
) -> Result<reqwest::Response, YahooError> {
debug!("Getting crumb for Yahoo request");
let (cookie_jar, crumb) = self.auth_manager.get_or_refresh().await?;
debug!("Got crumb (length: {}): {}", crumb.len(), &crumb);
if let Ok(url_parsed) = url::Url::parse(url) {
if let Some(cookie_header) = cookie_jar.cookies(&url_parsed) {
if let Ok(cookie_str) = cookie_header.to_str() {
let cookie_count = cookie_str.split(';').count();
debug!(
"Using {} cookies for request to {}",
cookie_count,
url_parsed.host_str().unwrap_or("unknown")
);
debug!("Cookie header length: {} bytes", cookie_str.len());
for cookie in cookie_str.split(';') {
if let Some(name) = cookie.trim().split('=').next() {
debug!(" Cookie: {}", name);
}
}
} else {
warn!("Could not read cookie header");
}
} else {
warn!(
"No cookies found in jar for {}",
url_parsed.host_str().unwrap_or("unknown")
);
}
}
let mut builder = reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(30))
.cookie_provider(cookie_jar.clone())
.redirect(reqwest::redirect::Policy::limited(10));
if let Some(proxy_url) = self.fetch_client.auth_proxy() {
debug!(
"Using proxy for Yahoo API request: {}...",
&proxy_url.chars().take(30).collect::<String>()
);
builder = builder
.proxy(reqwest::Proxy::all(proxy_url).map_err(YahooError::NetworkError)?)
.danger_accept_invalid_certs(true);
}
let client = builder.build().map_err(YahooError::NetworkError)?;
let mut request = client
.get(url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
)
.header("Accept", "application/json")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Referer", "https://finance.yahoo.com/")
.query(&[("crumb", crumb.as_str())]);
if let Some(params) = params {
request = request.query(params);
}
debug!("Sending Yahoo request to: {}", url);
let response = request.send().await.map_err(YahooError::NetworkError)?;
let status = response.status();
debug!("Yahoo API response status: {}", status);
if status == 401 {
error!("Yahoo API returned 401 Unauthorized. Crumb may be invalid or expired.");
if let Ok(body) = response.text().await {
debug!(
"401 response body (first 200 chars): {}",
body.chars().take(200).collect::<String>()
);
}
return Err(YahooError::AuthFailed("Yahoo auth failed".to_string()));
}
if status == 404 {
return Err(YahooError::NotFound("Yahoo symbol not found".to_string()));
}
if status == 429 {
return Err(YahooError::RateLimited);
}
if !status.is_success() {
return Err(YahooError::HttpError(
status.as_u16(),
format!(
"HTTP {}: {}",
status,
response.status().canonical_reason().unwrap_or("Unknown")
),
));
}
Ok(response)
}
async fn json(&self, url: &str, params: Option<&[(&str, &str)]>) -> Result<Value, YahooError> {
debug!("Making JSON request to: {}", url);
if let Some(params) = params {
debug!("Request params: {:?}", params);
}
let response = self.yahoo_request(url, params).await?;
let status = response.status();
info!("Received response with status: {}", status);
let text = response.text().await.map_err(YahooError::NetworkError)?;
debug!("Response text length: {} bytes", text.len());
debug!(
"Response preview (first 500 chars): {}",
&text.chars().take(500).collect::<String>()
);
serde_json::from_str(&text).map_err(|e| {
error!(
"Failed to parse JSON response from {}: {}. Response text: {}",
url,
e,
&text.chars().take(200).collect::<String>()
);
YahooError::ParseError(format!("Failed to parse JSON response from {}: {}", url, e))
})
}
pub async fn get_quote(&self, symbol: &str) -> Result<Value, YahooError> {
let url = format!(
"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{}",
symbol
);
let params = [(
"modules",
"assetProfile,price,summaryDetail,defaultKeyStatistics,calendarEvents,quoteUnadjustedPerformanceOverview",
)];
self.json(&url, Some(¶ms)).await
}
pub async fn get_simple_quotes(&self, symbols: &[&str]) -> Result<Value, YahooError> {
info!("Fetching simple quotes for symbols: {:?}", symbols);
let url = "https://query1.finance.yahoo.com/v7/finance/quote";
let symbols_str = symbols.join(",");
let params = [("symbols", symbols_str.as_str())];
let result = self.json(url, Some(¶ms)).await;
match &result {
Ok(data) => {
info!("Successfully received quote data");
debug!(
"Quote data keys: {:?}",
data.as_object().map(|o| o.keys().collect::<Vec<_>>())
);
}
Err(e) => {
error!("Failed to fetch simple quotes: {}", e);
}
}
result
}
pub async fn get_chart(
&self,
symbol: &str,
interval: &str,
range: &str,
) -> Result<Value, YahooError> {
let url = format!(
"https://query1.finance.yahoo.com/v8/finance/chart/{}",
symbol
);
let params = [("interval", interval), ("range", range)];
self.json(url.as_str(), Some(¶ms)).await
}
pub async fn get_chart_with_periods(
&self,
symbol: &str,
interval: &str,
period1: i64,
period2: i64,
) -> Result<Value, YahooError> {
let url = format!(
"https://query1.finance.yahoo.com/v8/finance/chart/{}",
symbol
);
let params = [
("interval", interval),
("period1", &period1.to_string()),
("period2", &period2.to_string()),
];
self.json(url.as_str(), Some(¶ms)).await
}
pub async fn search(&self, query: &str, hits: usize) -> Result<Value, YahooError> {
let url = "https://query1.finance.yahoo.com/v1/finance/search";
let params = [("q", query), ("quotesCount", &hits.to_string())];
self.json(url, Some(¶ms)).await
}
pub async fn get_similar_quotes(
&self,
symbol: &str,
limit: usize,
) -> Result<Value, YahooError> {
let url = format!(
"https://query2.finance.yahoo.com/v6/finance/recommendationsbysymbol/{}",
symbol
);
let count_str = limit.to_string();
let params = [("count", count_str.as_str())];
self.json(&url, Some(¶ms)).await
}
pub async fn get_fundamentals_timeseries(
&self,
symbol: &str,
period1: i64,
period2: i64,
types: &[&str],
) -> Result<Value, YahooError> {
let url = format!(
"https://query1.finance.yahoo.com/ws/fundamentals-timeseries/v1/finance/timeseries/{}",
symbol
);
let types_str = types.join(",");
let period1_str = period1.to_string();
let period2_str = period2.to_string();
let params = [
("merge", "false"),
("padTimeSeries", "true"),
("period1", period1_str.as_str()),
("period2", period2_str.as_str()),
("type", types_str.as_str()),
("lang", "en-US"),
("region", "US"),
];
self.json(&url, Some(¶ms)).await
}
pub async fn get_quote_summary(
&self,
symbol: &str,
modules: &[&str],
) -> Result<Value, YahooError> {
let url = format!(
"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{}",
symbol
);
let modules_str = modules.join(",");
let params = [
("modules", modules_str.as_str()),
("corsDomain", "finance.yahoo.com"),
("formatted", "false"),
];
self.json(&url, Some(¶ms)).await
}
pub async fn get_quote_type(&self, symbol: &str) -> Result<Value, YahooError> {
let url = format!(
"https://query1.finance.yahoo.com/v1/finance/quoteType/{}",
symbol
);
self.json(&url, None).await
}
pub async fn get_earnings_transcript(
&self,
event_id: &str,
company_id: &str,
) -> Result<Value, YahooError> {
let url = "https://finance.yahoo.com/xhr/transcript";
let params = [
("eventType", "earnings_call"),
("quartrId", company_id),
("eventId", event_id),
("lang", "en-US"),
("region", "US"),
];
self.json(url, Some(¶ms)).await
}
pub async fn make_request(
&self,
url: &str,
params: Option<&[(&str, &str)]>,
) -> Result<reqwest::Response, YahooError> {
self.yahoo_request(url, params).await
}
pub async fn get_actions(
&self,
symbol: &str,
period: &str,
) -> Result<crate::models::ActionsResponse, YahooError> {
use crate::models::actions::{ActionsResponse, YahooEventsResponse};
let url = format!(
"https://query1.finance.yahoo.com/v8/finance/chart/{}",
symbol
);
let params = [
("interval", "1d"),
("range", period),
("events", "div,split,capitalGains"),
];
let response = self.yahoo_request(&url, Some(¶ms)).await?;
let text = response.text().await.map_err(YahooError::NetworkError)?;
let yahoo_response: YahooEventsResponse = serde_json::from_str(&text).map_err(|e| {
YahooError::ParseError(format!("Failed to parse actions response: {}", e))
})?;
ActionsResponse::from_yahoo_response(symbol.to_string(), yahoo_response)
}
pub async fn get_dividends(
&self,
symbol: &str,
period: &str,
) -> Result<Vec<crate::models::Dividend>, YahooError> {
let actions = self.get_actions(symbol, period).await?;
Ok(actions.dividends)
}
pub async fn get_splits(
&self,
symbol: &str,
period: &str,
) -> Result<Vec<crate::models::StockSplit>, YahooError> {
let actions = self.get_actions(symbol, period).await?;
Ok(actions.splits)
}
pub async fn get_capital_gains(
&self,
symbol: &str,
period: &str,
) -> Result<Vec<crate::models::CapitalGain>, YahooError> {
let actions = self.get_actions(symbol, period).await?;
Ok(actions.capital_gains)
}
pub async fn get_option_chain(
&self,
symbol: &str,
date: Option<&str>,
) -> Result<crate::models::OptionChain, YahooError> {
use crate::models::options::{date_to_timestamp, OptionChain, YahooOptionsResponse};
let url = format!(
"https://query2.finance.yahoo.com/v7/finance/options/{}",
symbol
);
let response = if let Some(exp_date) = date {
let timestamp = date_to_timestamp(exp_date)?;
let timestamp_str = timestamp.to_string();
let params = [("date", timestamp_str.as_str())];
self.yahoo_request(&url, Some(¶ms)).await?
} else {
self.yahoo_request(&url, None).await?
};
let text = response.text().await.map_err(YahooError::NetworkError)?;
let yahoo_response: YahooOptionsResponse = serde_json::from_str(&text).map_err(|e| {
YahooError::ParseError(format!("Failed to parse options response: {}", e))
})?;
let expiration_date = date.unwrap_or("nearest").to_string();
OptionChain::from_yahoo_response(symbol.to_string(), expiration_date, yahoo_response)
}
pub async fn get_option_expirations(
&self,
symbol: &str,
) -> Result<crate::models::OptionExpirations, YahooError> {
use crate::models::options::{OptionExpirations, YahooOptionsResponse};
let url = format!(
"https://query2.finance.yahoo.com/v7/finance/options/{}",
symbol
);
let response = self.yahoo_request(&url, None).await?;
let text = response.text().await.map_err(YahooError::NetworkError)?;
let yahoo_response: YahooOptionsResponse = serde_json::from_str(&text).map_err(|e| {
YahooError::ParseError(format!("Failed to parse options response: {}", e))
})?;
OptionExpirations::from_yahoo_response(symbol.to_string(), yahoo_response)
}
pub async fn get_calendar(&self, symbol: &str) -> Result<crate::models::Calendar, YahooError> {
use crate::models::calendar::{Calendar, YahooCalendarResponse};
let url = format!(
"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{}",
symbol
);
let params = [
("modules", "calendarEvents"),
("corsDomain", "finance.yahoo.com"),
("formatted", "false"),
];
let response = self.yahoo_request(&url, Some(¶ms)).await?;
let text = response.text().await.map_err(YahooError::NetworkError)?;
let yahoo_response: YahooCalendarResponse = serde_json::from_str(&text).map_err(|e| {
YahooError::ParseError(format!("Failed to parse calendar response: {}", e))
})?;
Calendar::from_yahoo_response(symbol.to_string(), yahoo_response)
}
pub async fn get_sec_filings(
&self,
symbol: &str,
) -> Result<crate::models::SecFilingsResponse, YahooError> {
use crate::models::sec_filings::{SecFilingsResponse, YahooSecFilingsResponse};
let url = format!(
"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{}",
symbol
);
let params = [
("modules", "secFilings"),
("corsDomain", "finance.yahoo.com"),
("formatted", "false"),
];
let response = self.yahoo_request(&url, Some(¶ms)).await?;
let text = response.text().await.map_err(YahooError::NetworkError)?;
let yahoo_response: YahooSecFilingsResponse = serde_json::from_str(&text).map_err(|e| {
YahooError::ParseError(format!("Failed to parse SEC filings response: {}", e))
})?;
SecFilingsResponse::from_yahoo_response(symbol.to_string(), yahoo_response)
}
pub async fn get_sustainability(
&self,
symbol: &str,
) -> Result<crate::models::SustainabilityScores, YahooError> {
use crate::models::sustainability::{SustainabilityScores, YahooEsgResponse};
let url = format!(
"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{}",
symbol
);
let params = [
("modules", "esgScores"),
("corsDomain", "finance.yahoo.com"),
("formatted", "false"),
];
let response = self.yahoo_request(&url, Some(¶ms)).await?;
let text = response.text().await.map_err(YahooError::NetworkError)?;
let yahoo_response: YahooEsgResponse = serde_json::from_str(&text)
.map_err(|e| YahooError::ParseError(format!("Failed to parse ESG response: {}", e)))?;
SustainabilityScores::from_yahoo_response(symbol.to_string(), yahoo_response)
}
pub async fn get_industry(
&self,
industry_key: &str,
) -> Result<crate::models::Industry, YahooError> {
use crate::models::industry::{Industry, YahooIndustryResponse};
let url = format!(
"https://query2.finance.yahoo.com/v1/finance/industries/{}",
industry_key
);
let response = self.yahoo_request(&url, None).await?;
let text = response.text().await.map_err(YahooError::NetworkError)?;
let yahoo_response: YahooIndustryResponse = serde_json::from_str(&text).map_err(|e| {
YahooError::ParseError(format!("Failed to parse industry response: {}", e))
})?;
Industry::from_yahoo_response(yahoo_response)
}
pub async fn get_market_status(
&self,
market: &str,
) -> Result<crate::models::MarketStatus, YahooError> {
use crate::models::market::{MarketStatus, YahooMarketTimeResponse};
let url = "https://query1.finance.yahoo.com/v6/finance/markettime";
let params = [
("formatted", "true"),
("key", "finance"),
("lang", "en-US"),
("market", market),
];
let response = self.yahoo_request(url, Some(¶ms)).await?;
let text = response.text().await.map_err(YahooError::NetworkError)?;
let yahoo_response: YahooMarketTimeResponse = serde_json::from_str(&text).map_err(|e| {
YahooError::ParseError(format!("Failed to parse market time response: {}", e))
})?;
MarketStatus::from_yahoo_response(market.to_string(), yahoo_response)
}
pub async fn get_market_summary(
&self,
market: &str,
) -> Result<crate::models::MarketSummaryResponse, YahooError> {
use crate::models::market::{MarketSummaryResponse, YahooMarketSummaryResponse};
let url = "https://query1.finance.yahoo.com/v6/finance/quote/marketSummary";
let params = [
(
"fields",
"shortName,regularMarketPrice,regularMarketChange,regularMarketChangePercent",
),
("formatted", "false"),
("lang", "en-US"),
("market", market),
];
let response = self.yahoo_request(url, Some(¶ms)).await?;
let text = response.text().await.map_err(YahooError::NetworkError)?;
let yahoo_response: YahooMarketSummaryResponse =
serde_json::from_str(&text).map_err(|e| {
YahooError::ParseError(format!("Failed to parse market summary response: {}", e))
})?;
let status = self.get_market_status(market).await.ok();
MarketSummaryResponse::from_yahoo_response(market.to_string(), yahoo_response, status)
}
pub async fn get_movers(
&self,
count: crate::models::MoverCount,
) -> Result<
(
Vec<crate::models::MarketMover>,
Vec<crate::models::MarketMover>,
Vec<crate::models::MarketMover>,
),
YahooError,
> {
let count_str = count.as_str();
let actives_url = format!(
"https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved?count={}&scrIds=most_actives",
count_str
);
let gainers_url = format!(
"https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved?count={}&scrIds=day_gainers",
count_str
);
let losers_url = format!(
"https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved?count={}&scrIds=day_losers",
count_str
);
let (actives_response, gainers_response, losers_response) = tokio::join!(
self.yahoo_request(&actives_url, None),
self.yahoo_request(&gainers_url, None),
self.yahoo_request(&losers_url, None)
);
let actives = Self::parse_movers_response(actives_response?).await?;
let gainers = Self::parse_movers_response(gainers_response?).await?;
let losers = Self::parse_movers_response(losers_response?).await?;
Ok((actives, gainers, losers))
}
async fn parse_movers_response(
response: reqwest::Response,
) -> Result<Vec<crate::models::MarketMover>, YahooError> {
let text = response.text().await.map_err(YahooError::NetworkError)?;
let data: Value = serde_json::from_str(&text).map_err(|e| {
YahooError::ParseError(format!("Failed to parse movers response: {}", e))
})?;
let mut movers = Vec::new();
if let Some(quotes) = data
.get("finance")
.and_then(|f| f.get("result"))
.and_then(|r| r.get(0))
.and_then(|r| r.get("quotes"))
.and_then(|q| q.as_array())
{
for quote in quotes {
let symbol = quote
.get("symbol")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
if !symbol.is_empty() && !symbol.contains('.')
|| symbol.ends_with(".OB")
|| symbol.ends_with(".PK")
{
let name = quote
.get("longName")
.or_else(|| quote.get("shortName"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let price = quote
.get("regularMarketPrice")
.and_then(|p| p.as_f64())
.map(|p| format!("{:.2}", p))
.unwrap_or_else(|| "0.00".to_string());
let change = quote
.get("regularMarketChange")
.and_then(|c| c.as_f64())
.map(|c| format!("{:+.2}", c))
.unwrap_or_else(|| "0.00".to_string());
let percent_change = quote
.get("regularMarketChangePercent")
.and_then(|p| p.as_f64())
.map(|p| format!("{:+.2}%", p))
.unwrap_or_else(|| "0.00%".to_string());
movers.push(crate::models::MarketMover {
symbol,
name,
price,
change,
percent_change,
});
}
}
}
Ok(movers)
}
}