Fix live company panel data mapping
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user