Fix live company panel data mapping
This commit is contained in:
@@ -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<Company, SecurityLookupError>> {
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export const CompanyPanel: React.FC<CompanyPanelProps> = ({ company }) => {
|
||||
</div>
|
||||
|
||||
{/* P/E Ratio */}
|
||||
{company.pe && (
|
||||
{company.pe !== undefined && (
|
||||
<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-lg font-mono text-[#e0e0e0]">{company.pe.toFixed(1)}</div>
|
||||
@@ -63,7 +63,7 @@ export const CompanyPanel: React.FC<CompanyPanelProps> = ({ company }) => {
|
||||
)}
|
||||
|
||||
{/* EPS */}
|
||||
{company.eps && (
|
||||
{company.eps !== undefined && (
|
||||
<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-lg font-mono text-[#e0e0e0]">${company.eps.toFixed(2)}</div>
|
||||
@@ -71,7 +71,7 @@ export const CompanyPanel: React.FC<CompanyPanelProps> = ({ company }) => {
|
||||
)}
|
||||
|
||||
{/* 52W High */}
|
||||
{company.high52Week && (
|
||||
{company.high52Week !== undefined && (
|
||||
<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-lg font-mono text-[#00d26a]">${company.high52Week.toFixed(2)}</div>
|
||||
@@ -79,7 +79,7 @@ export const CompanyPanel: React.FC<CompanyPanelProps> = ({ company }) => {
|
||||
)}
|
||||
|
||||
{/* 52W Low */}
|
||||
{company.low52Week && (
|
||||
{company.low52Week !== undefined && (
|
||||
<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-lg font-mono text-[#ff4757]">${company.low52Week.toFixed(2)}</div>
|
||||
|
||||
Reference in New Issue
Block a user