Fix live company panel data mapping

This commit is contained in:
2026-04-04 12:59:37 -04:00
parent d9f950d595
commit 38eeae06dd
2 changed files with 190 additions and 47 deletions

View File

@@ -1,6 +1,7 @@
use futures::future::BoxFuture; use futures::future::BoxFuture;
use yfinance_rs::core::conversions::money_to_f64; use reqwest::Client;
use yfinance_rs::{CacheMode, SearchBuilder, Ticker, YfClient}; use serde::Deserialize;
use yfinance_rs::{CacheMode, SearchBuilder, YfClient};
use crate::terminal::Company; use crate::terminal::Company;
@@ -55,12 +56,14 @@ pub(crate) trait SecurityLookup: Send + Sync {
pub(crate) struct YahooFinanceLookup { pub(crate) struct YahooFinanceLookup {
client: YfClient, client: YfClient,
http_client: Client,
} }
impl Default for YahooFinanceLookup { impl Default for YahooFinanceLookup {
fn default() -> Self { fn default() -> Self {
Self { Self {
client: YfClient::default(), client: YfClient::default(),
http_client: Client::new(),
} }
} }
} }
@@ -108,53 +111,193 @@ impl SecurityLookup for YahooFinanceLookup {
security_match: &'a SecurityMatch, security_match: &'a SecurityMatch,
) -> BoxFuture<'a, Result<Company, SecurityLookupError>> { ) -> BoxFuture<'a, Result<Company, SecurityLookupError>> {
Box::pin(async move { Box::pin(async move {
let ticker = Ticker::new(&self.client, security_match.symbol.clone());
let detail_error = || SecurityLookupError::DetailUnavailable { let detail_error = || SecurityLookupError::DetailUnavailable {
symbol: security_match.symbol.clone(), 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())?; .map_err(|_| detail_error())?;
let name = info map_live_quote_to_company(security_match, quote).ok_or_else(detail_error)
.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),
})
}) })
} }
} }
impl YahooFinanceLookup {
async fn fetch_live_quote(
&self,
symbol: &str,
) -> Result<YahooQuoteResult, SecurityLookupError> {
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::<YahooQuoteEnvelope>().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<Company> {
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<YahooQuoteResult>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct YahooQuoteResult {
symbol: String,
short_name: Option<String>,
long_name: Option<String>,
regular_market_price: Option<f64>,
regular_market_previous_close: Option<f64>,
regular_market_volume: Option<u64>,
market_cap: Option<f64>,
trailing_pe: Option<f64>,
eps_trailing_twelve_months: Option<f64>,
fifty_two_week_high: Option<f64>,
fifty_two_week_low: Option<f64>,
}
#[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);
}
}

View File

@@ -55,7 +55,7 @@ export const CompanyPanel: React.FC<CompanyPanelProps> = ({ company }) => {
</div> </div>
{/* P/E Ratio */} {/* P/E Ratio */}
{company.pe && ( {company.pe !== undefined && (
<div className="bg-[#1a1a1a] rounded p-3"> <div className="bg-[#1a1a1a] rounded p-3">
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">P/E Ratio</div> <div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">P/E Ratio</div>
<div className="text-lg font-mono text-[#e0e0e0]">{company.pe.toFixed(1)}</div> <div className="text-lg font-mono text-[#e0e0e0]">{company.pe.toFixed(1)}</div>
@@ -63,7 +63,7 @@ export const CompanyPanel: React.FC<CompanyPanelProps> = ({ company }) => {
)} )}
{/* EPS */} {/* EPS */}
{company.eps && ( {company.eps !== undefined && (
<div className="bg-[#1a1a1a] rounded p-3"> <div className="bg-[#1a1a1a] rounded p-3">
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">EPS</div> <div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">EPS</div>
<div className="text-lg font-mono text-[#e0e0e0]">${company.eps.toFixed(2)}</div> <div className="text-lg font-mono text-[#e0e0e0]">${company.eps.toFixed(2)}</div>
@@ -71,7 +71,7 @@ export const CompanyPanel: React.FC<CompanyPanelProps> = ({ company }) => {
)} )}
{/* 52W High */} {/* 52W High */}
{company.high52Week && ( {company.high52Week !== undefined && (
<div className="bg-[#1a1a1a] rounded p-3"> <div className="bg-[#1a1a1a] rounded p-3">
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">52W High</div> <div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">52W High</div>
<div className="text-lg font-mono text-[#00d26a]">${company.high52Week.toFixed(2)}</div> <div className="text-lg font-mono text-[#00d26a]">${company.high52Week.toFixed(2)}</div>
@@ -79,7 +79,7 @@ export const CompanyPanel: React.FC<CompanyPanelProps> = ({ company }) => {
)} )}
{/* 52W Low */} {/* 52W Low */}
{company.low52Week && ( {company.low52Week !== undefined && (
<div className="bg-[#1a1a1a] rounded p-3"> <div className="bg-[#1a1a1a] rounded p-3">
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">52W Low</div> <div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">52W Low</div>
<div className="text-lg font-mono text-[#ff4757]">${company.low52Week.toFixed(2)}</div> <div className="text-lg font-mono text-[#ff4757]">${company.low52Week.toFixed(2)}</div>