diff --git a/MosaicIQ/src-tauri/src/terminal/yahoo_finance.rs b/MosaicIQ/src-tauri/src/terminal/yahoo_finance.rs index ff05e44..a3b3dc1 100644 --- a/MosaicIQ/src-tauri/src/terminal/yahoo_finance.rs +++ b/MosaicIQ/src-tauri/src/terminal/yahoo_finance.rs @@ -1,6 +1,7 @@ use futures::future::BoxFuture; -use yfinance_rs::core::conversions::money_to_f64; -use yfinance_rs::{CacheMode, SearchBuilder, Ticker, YfClient}; +use reqwest::Client; +use serde::Deserialize; +use yfinance_rs::{CacheMode, SearchBuilder, YfClient}; use crate::terminal::Company; @@ -55,12 +56,14 @@ pub(crate) trait SecurityLookup: Send + Sync { pub(crate) struct YahooFinanceLookup { client: YfClient, + http_client: Client, } impl Default for YahooFinanceLookup { fn default() -> Self { Self { client: YfClient::default(), + http_client: Client::new(), } } } @@ -108,53 +111,193 @@ impl SecurityLookup for YahooFinanceLookup { security_match: &'a SecurityMatch, ) -> BoxFuture<'a, Result> { Box::pin(async move { - let ticker = Ticker::new(&self.client, security_match.symbol.clone()); let detail_error = || SecurityLookupError::DetailUnavailable { symbol: security_match.symbol.clone(), }; - let (fast_info, info) = futures::try_join!(ticker.fast_info(), ticker.info()) + let quote = self + .fetch_live_quote(&security_match.symbol) + .await .map_err(|_| detail_error())?; - let name = info - .name - .clone() - .or_else(|| security_match.name.clone()) - .or_else(|| fast_info.name.clone()) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(detail_error)?; - - let price = fast_info - .last - .as_ref() - .map(money_to_f64) - .ok_or_else(detail_error)?; - - let previous_close = fast_info - .previous_close - .as_ref() - .map(money_to_f64) - .unwrap_or(0.0); - let change = price - previous_close; - let change_percent = if previous_close > 0.0 { - (change / previous_close) * 100.0 - } else { - 0.0 - }; - - Ok(Company { - symbol: security_match.symbol.clone(), - name, - price, - change, - change_percent, - market_cap: info.market_cap.as_ref().map(money_to_f64).unwrap_or(0.0), - volume: info.volume.or(fast_info.volume).unwrap_or(0), - pe: info.pe_ttm, - eps: info.eps_ttm.as_ref().map(money_to_f64), - high52_week: info.fifty_two_week_high.as_ref().map(money_to_f64), - low52_week: info.fifty_two_week_low.as_ref().map(money_to_f64), - }) + map_live_quote_to_company(security_match, quote).ok_or_else(detail_error) }) } } + +impl YahooFinanceLookup { + async fn fetch_live_quote( + &self, + symbol: &str, + ) -> Result { + let response = self + .http_client + .get("https://query1.finance.yahoo.com/v7/finance/quote") + .query(&[("symbols", symbol)]) + .send() + .await + .map_err(|_| SecurityLookupError::DetailUnavailable { + symbol: symbol.to_string(), + })? + .error_for_status() + .map_err(|_| SecurityLookupError::DetailUnavailable { + symbol: symbol.to_string(), + })?; + + let envelope = response.json::().await.map_err(|_| { + SecurityLookupError::DetailUnavailable { + symbol: symbol.to_string(), + } + })?; + + envelope + .quote_response + .result + .into_iter() + .find(|quote| quote.symbol.eq_ignore_ascii_case(symbol)) + .ok_or_else(|| SecurityLookupError::DetailUnavailable { + symbol: symbol.to_string(), + }) + } +} + +fn map_live_quote_to_company( + security_match: &SecurityMatch, + quote: YahooQuoteResult, +) -> Option { + let price = quote.regular_market_price?; + let previous_close = quote.regular_market_previous_close.unwrap_or(price); + let change = price - previous_close; + let change_percent = if previous_close > 0.0 { + (change / previous_close) * 100.0 + } else { + 0.0 + }; + + let name = quote + .long_name + .or(quote.short_name) + .or_else(|| security_match.name.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| security_match.symbol.clone()); + + Some(Company { + symbol: security_match.symbol.clone(), + name, + price, + change, + change_percent, + market_cap: quote.market_cap.unwrap_or(0.0), + volume: quote.regular_market_volume.unwrap_or(0), + pe: quote.trailing_pe, + eps: quote.eps_trailing_twelve_months, + high52_week: quote.fifty_two_week_high, + low52_week: quote.fifty_two_week_low, + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct YahooQuoteEnvelope { + quote_response: YahooQuoteResponse, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct YahooQuoteResponse { + result: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct YahooQuoteResult { + symbol: String, + short_name: Option, + long_name: Option, + regular_market_price: Option, + regular_market_previous_close: Option, + regular_market_volume: Option, + market_cap: Option, + trailing_pe: Option, + eps_trailing_twelve_months: Option, + fifty_two_week_high: Option, + fifty_two_week_low: Option, +} + +#[cfg(test)] +mod tests { + use super::{map_live_quote_to_company, SecurityKind, SecurityMatch, YahooQuoteResult}; + + #[test] + fn maps_live_quote_fields_into_company_panel_shape() { + let security_match = SecurityMatch { + symbol: "CASEY".to_string(), + name: Some("Fallback Name".to_string()), + exchange: Some("NASDAQ".to_string()), + kind: SecurityKind::Equity, + }; + + let company = map_live_quote_to_company( + &security_match, + YahooQuoteResult { + symbol: "CASEY".to_string(), + short_name: Some("Casey's".to_string()), + long_name: Some("Casey's General Stores, Inc.".to_string()), + regular_market_price: Some(743.42), + regular_market_previous_close: Some(737.16), + regular_market_volume: Some(363_594), + market_cap: Some(27_650_000_000.0), + trailing_pe: Some(33.4), + eps_trailing_twelve_months: Some(22.28), + fifty_two_week_high: Some(751.24), + fifty_two_week_low: Some(313.89), + }, + ) + .unwrap(); + + assert_eq!(company.symbol, "CASEY"); + assert_eq!(company.name, "Casey's General Stores, Inc."); + assert_eq!(company.price, 743.42); + assert!((company.change - 6.26).abs() < 0.0001); + assert!((company.change_percent - 0.849205).abs() < 0.0001); + assert_eq!(company.market_cap, 27_650_000_000.0); + assert_eq!(company.volume, 363_594); + assert_eq!(company.pe, Some(33.4)); + assert_eq!(company.eps, Some(22.28)); + assert_eq!(company.high52_week, Some(751.24)); + assert_eq!(company.low52_week, Some(313.89)); + } + + #[test] + fn falls_back_to_security_match_name_when_quote_name_is_missing() { + let security_match = SecurityMatch { + symbol: "ABC".to_string(), + name: Some("Fallback Name".to_string()), + exchange: Some("NYSE".to_string()), + kind: SecurityKind::Equity, + }; + + let company = map_live_quote_to_company( + &security_match, + YahooQuoteResult { + symbol: "ABC".to_string(), + short_name: None, + long_name: None, + regular_market_price: Some(100.0), + regular_market_previous_close: None, + regular_market_volume: None, + market_cap: None, + trailing_pe: None, + eps_trailing_twelve_months: None, + fifty_two_week_high: None, + fifty_two_week_low: None, + }, + ) + .unwrap(); + + assert_eq!(company.name, "Fallback Name"); + assert_eq!(company.change, 0.0); + assert_eq!(company.change_percent, 0.0); + assert_eq!(company.market_cap, 0.0); + } +} diff --git a/MosaicIQ/src/components/Panels/CompanyPanel.tsx b/MosaicIQ/src/components/Panels/CompanyPanel.tsx index 583c954..eadded2 100644 --- a/MosaicIQ/src/components/Panels/CompanyPanel.tsx +++ b/MosaicIQ/src/components/Panels/CompanyPanel.tsx @@ -55,7 +55,7 @@ export const CompanyPanel: React.FC = ({ company }) => { {/* P/E Ratio */} - {company.pe && ( + {company.pe !== undefined && (
P/E Ratio
{company.pe.toFixed(1)}
@@ -63,7 +63,7 @@ export const CompanyPanel: React.FC = ({ company }) => { )} {/* EPS */} - {company.eps && ( + {company.eps !== undefined && (
EPS
${company.eps.toFixed(2)}
@@ -71,7 +71,7 @@ export const CompanyPanel: React.FC = ({ company }) => { )} {/* 52W High */} - {company.high52Week && ( + {company.high52Week !== undefined && (
52W High
${company.high52Week.toFixed(2)}
@@ -79,7 +79,7 @@ export const CompanyPanel: React.FC = ({ company }) => { )} {/* 52W Low */} - {company.low52Week && ( + {company.low52Week !== undefined && (
52W Low
${company.low52Week.toFixed(2)}