Reduce live search requests for exact symbol lookups
This commit is contained in:
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use crate::terminal::mock_data::load_mock_financial_data;
|
||||
use crate::terminal::yahoo_finance::{
|
||||
SecurityLookup, SecurityLookupError, SecurityMatch, YahooFinanceLookup,
|
||||
SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch, YahooFinanceLookup,
|
||||
};
|
||||
use crate::terminal::{
|
||||
ChatCommandRequest, ExecuteTerminalCommandRequest, MockFinancialData, PanelPayload,
|
||||
@@ -17,7 +17,10 @@ pub struct TerminalCommandService {
|
||||
|
||||
impl Default for TerminalCommandService {
|
||||
fn default() -> Self {
|
||||
Self::with_dependencies(load_mock_financial_data(), Arc::new(YahooFinanceLookup::default()))
|
||||
Self::with_dependencies(
|
||||
load_mock_financial_data(),
|
||||
Arc::new(YahooFinanceLookup::default()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +67,17 @@ impl TerminalCommandService {
|
||||
};
|
||||
}
|
||||
|
||||
if looks_like_symbol_query(query) {
|
||||
return self
|
||||
.load_exact_symbol_match(SecurityMatch {
|
||||
symbol: query.trim().to_uppercase(),
|
||||
name: None,
|
||||
exchange: None,
|
||||
kind: SecurityKind::Equity,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let matches = match self.security_lookup.search(query).await {
|
||||
Ok(matches) => matches
|
||||
.into_iter()
|
||||
@@ -88,20 +102,7 @@ impl TerminalCommandService {
|
||||
}
|
||||
|
||||
if let Some(selected_match) = select_exact_symbol_match(query, &matches) {
|
||||
let selected_symbol = selected_match.symbol.clone();
|
||||
return match self.security_lookup.load_company(&selected_match).await {
|
||||
Ok(company) => TerminalCommandResponse::Panel {
|
||||
panel: PanelPayload::Company { data: company },
|
||||
},
|
||||
Err(SecurityLookupError::DetailUnavailable { symbol }) => {
|
||||
TerminalCommandResponse::Text {
|
||||
content: format!("Live security data unavailable for \"{symbol}\"."),
|
||||
}
|
||||
}
|
||||
Err(SecurityLookupError::SearchUnavailable) => TerminalCommandResponse::Text {
|
||||
content: format!("Live security data unavailable for \"{selected_symbol}\"."),
|
||||
},
|
||||
};
|
||||
return self.load_exact_symbol_match(selected_match).await;
|
||||
}
|
||||
|
||||
TerminalCommandResponse::Text {
|
||||
@@ -155,6 +156,39 @@ impl TerminalCommandService {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_exact_symbol_match(
|
||||
&self,
|
||||
security_match: SecurityMatch,
|
||||
) -> TerminalCommandResponse {
|
||||
let selected_symbol = security_match.symbol.clone();
|
||||
|
||||
match self.security_lookup.load_company(&security_match).await {
|
||||
Ok(company) => TerminalCommandResponse::Panel {
|
||||
panel: PanelPayload::Company { data: company },
|
||||
},
|
||||
Err(SecurityLookupError::DetailUnavailable { symbol }) => {
|
||||
TerminalCommandResponse::Text {
|
||||
content: format!("Live security data unavailable for \"{symbol}\"."),
|
||||
}
|
||||
}
|
||||
Err(SecurityLookupError::SearchUnavailable) => TerminalCommandResponse::Text {
|
||||
content: format!("Live security data unavailable for \"{selected_symbol}\"."),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_symbol_query(query: &str) -> bool {
|
||||
let trimmed = query.trim();
|
||||
|
||||
!trimmed.is_empty()
|
||||
&& !trimmed.contains(char::is_whitespace)
|
||||
&& trimmed.len() <= 10
|
||||
&& trimmed == trimmed.to_ascii_uppercase()
|
||||
&& trimmed.chars().all(|character| {
|
||||
character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '^' | '=')
|
||||
})
|
||||
}
|
||||
|
||||
fn select_exact_symbol_match(query: &str, matches: &[SecurityMatch]) -> Option<SecurityMatch> {
|
||||
@@ -227,6 +261,7 @@ fn help_response() -> TerminalCommandResponse {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
@@ -236,11 +271,15 @@ mod tests {
|
||||
use crate::terminal::yahoo_finance::{
|
||||
SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch,
|
||||
};
|
||||
use crate::terminal::{Company, ExecuteTerminalCommandRequest, PanelPayload, TerminalCommandResponse};
|
||||
use crate::terminal::{
|
||||
Company, ExecuteTerminalCommandRequest, PanelPayload, TerminalCommandResponse,
|
||||
};
|
||||
|
||||
struct FakeSecurityLookup {
|
||||
search_result: Result<Vec<SecurityMatch>, SecurityLookupError>,
|
||||
fail_detail: bool,
|
||||
search_calls: AtomicUsize,
|
||||
detail_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FakeSecurityLookup {
|
||||
@@ -248,6 +287,8 @@ mod tests {
|
||||
Self {
|
||||
search_result: Ok(matches),
|
||||
fail_detail: false,
|
||||
search_calls: AtomicUsize::new(0),
|
||||
detail_calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,6 +298,7 @@ mod tests {
|
||||
&'a self,
|
||||
_query: &'a str,
|
||||
) -> BoxFuture<'a, Result<Vec<SecurityMatch>, SecurityLookupError>> {
|
||||
self.search_calls.fetch_add(1, Ordering::Relaxed);
|
||||
Box::pin(async move { self.search_result.clone() })
|
||||
}
|
||||
|
||||
@@ -264,6 +306,7 @@ mod tests {
|
||||
&'a self,
|
||||
security_match: &'a SecurityMatch,
|
||||
) -> BoxFuture<'a, Result<Company, SecurityLookupError>> {
|
||||
self.detail_calls.fetch_add(1, Ordering::Relaxed);
|
||||
Box::pin(async move {
|
||||
if self.fail_detail {
|
||||
return Err(SecurityLookupError::DetailUnavailable {
|
||||
@@ -273,7 +316,10 @@ mod tests {
|
||||
|
||||
Ok(Company {
|
||||
symbol: security_match.symbol.clone(),
|
||||
name: security_match.exchange.clone().unwrap_or_else(|| "N/A".to_string()),
|
||||
name: security_match
|
||||
.exchange
|
||||
.clone()
|
||||
.unwrap_or_else(|| "N/A".to_string()),
|
||||
price: 100.0,
|
||||
change: 1.0,
|
||||
change_percent: 1.0,
|
||||
@@ -288,12 +334,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_service(search_result: Result<Vec<SecurityMatch>, SecurityLookupError>) -> TerminalCommandService {
|
||||
fn build_service(
|
||||
search_result: Result<Vec<SecurityMatch>, SecurityLookupError>,
|
||||
) -> (TerminalCommandService, Arc<FakeSecurityLookup>) {
|
||||
let lookup = Arc::new(FakeSecurityLookup {
|
||||
search_result,
|
||||
fail_detail: false,
|
||||
search_calls: AtomicUsize::new(0),
|
||||
detail_calls: AtomicUsize::new(0),
|
||||
});
|
||||
|
||||
(
|
||||
TerminalCommandService::with_dependencies(load_mock_financial_data(), lookup.clone()),
|
||||
lookup,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_failing_service(
|
||||
search_result: Result<Vec<SecurityMatch>, SecurityLookupError>,
|
||||
) -> TerminalCommandService {
|
||||
TerminalCommandService::with_dependencies(
|
||||
load_mock_financial_data(),
|
||||
Arc::new(FakeSecurityLookup {
|
||||
search_result,
|
||||
fail_detail: false,
|
||||
fail_detail: true,
|
||||
search_calls: AtomicUsize::new(0),
|
||||
detail_calls: AtomicUsize::new(0),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -307,7 +373,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn returns_company_panel_for_exact_search_match() {
|
||||
let service = build_service(Ok(vec![SecurityMatch {
|
||||
let (service, lookup) = build_service(Ok(vec![SecurityMatch {
|
||||
symbol: "AAPL".to_string(),
|
||||
name: Some("Apple Inc.".to_string()),
|
||||
exchange: Some("NASDAQ".to_string()),
|
||||
@@ -322,11 +388,14 @@ mod tests {
|
||||
} => assert_eq!(data.symbol, "AAPL"),
|
||||
other => panic!("expected company panel, got {other:?}"),
|
||||
}
|
||||
|
||||
assert_eq!(lookup.search_calls.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(lookup.detail_calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_text_list_for_name_search() {
|
||||
let service = build_service(Ok(vec![
|
||||
let (service, lookup) = build_service(Ok(vec![
|
||||
SecurityMatch {
|
||||
symbol: "AAPL".to_string(),
|
||||
name: Some("Apple Inc.".to_string()),
|
||||
@@ -352,11 +421,14 @@ mod tests {
|
||||
}
|
||||
other => panic!("expected text response, got {other:?}"),
|
||||
}
|
||||
|
||||
assert_eq!(lookup.search_calls.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(lookup.detail_calls.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_unsupported_assets() {
|
||||
let service = build_service(Ok(vec![SecurityMatch {
|
||||
let (service, _) = build_service(Ok(vec![SecurityMatch {
|
||||
symbol: "BTC-USD".to_string(),
|
||||
name: Some("Bitcoin USD".to_string()),
|
||||
exchange: None,
|
||||
@@ -375,7 +447,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn prefers_us_exchange_priority_for_exact_matches() {
|
||||
let service = build_service(Ok(vec![
|
||||
let (service, _) = build_service(Ok(vec![
|
||||
SecurityMatch {
|
||||
symbol: "ABC".to_string(),
|
||||
name: Some("ABC Ltd".to_string()),
|
||||
@@ -390,7 +462,7 @@ mod tests {
|
||||
},
|
||||
]));
|
||||
|
||||
let response = execute(&service, "/search ABC");
|
||||
let response = execute(&service, "/search abc");
|
||||
|
||||
match response {
|
||||
TerminalCommandResponse::Panel {
|
||||
@@ -402,13 +474,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn returns_live_search_error_when_provider_search_fails() {
|
||||
let service = build_service(Err(SecurityLookupError::SearchUnavailable));
|
||||
let (service, _) = build_service(Err(SecurityLookupError::SearchUnavailable));
|
||||
|
||||
let response = execute(&service, "/search AAPL");
|
||||
let response = execute(&service, "/search apple");
|
||||
|
||||
match response {
|
||||
TerminalCommandResponse::Text { content } => {
|
||||
assert_eq!(content, "Live search failed for \"AAPL\".");
|
||||
assert_eq!(content, "Live search failed for \"apple\".");
|
||||
}
|
||||
other => panic!("expected text response, got {other:?}"),
|
||||
}
|
||||
@@ -416,18 +488,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn returns_live_detail_error_when_exact_resolution_fails() {
|
||||
let service = TerminalCommandService::with_dependencies(
|
||||
load_mock_financial_data(),
|
||||
Arc::new(FakeSecurityLookup {
|
||||
search_result: Ok(vec![SecurityMatch {
|
||||
let service = build_failing_service(Ok(vec![SecurityMatch {
|
||||
symbol: "AAPL".to_string(),
|
||||
name: Some("Apple Inc.".to_string()),
|
||||
exchange: Some("NASDAQ".to_string()),
|
||||
kind: SecurityKind::Equity,
|
||||
}]),
|
||||
fail_detail: true,
|
||||
}),
|
||||
);
|
||||
}]));
|
||||
|
||||
let response = execute(&service, "/search AAPL");
|
||||
|
||||
@@ -456,6 +522,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_symbol_search_skips_live_search_lookup() {
|
||||
let (service, lookup) = build_service(Ok(vec![]));
|
||||
|
||||
let response = execute(&service, "/search CASY");
|
||||
|
||||
match response {
|
||||
TerminalCommandResponse::Panel {
|
||||
panel: PanelPayload::Company { data },
|
||||
} => assert_eq!(data.symbol, "CASY"),
|
||||
other => panic!("expected company panel, got {other:?}"),
|
||||
}
|
||||
|
||||
assert_eq!(lookup.search_calls.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(lookup.detail_calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_text_for_unknown_command() {
|
||||
let service = TerminalCommandService::with_dependencies(
|
||||
|
||||
@@ -115,11 +115,7 @@ impl SecurityLookup for YahooFinanceLookup {
|
||||
symbol: security_match.symbol.clone(),
|
||||
};
|
||||
|
||||
let quote = self
|
||||
.fetch_live_quote(&security_match.symbol)
|
||||
.await
|
||||
.map_err(|_| detail_error())?;
|
||||
|
||||
let quote = self.fetch_live_quote(&security_match.symbol).await?;
|
||||
map_live_quote_to_company(security_match, quote).ok_or_else(detail_error)
|
||||
})
|
||||
}
|
||||
@@ -161,6 +157,7 @@ impl YahooFinanceLookup {
|
||||
}
|
||||
}
|
||||
|
||||
// Map the full quote payload directly so an exact-symbol lookup only needs one request.
|
||||
fn map_live_quote_to_company(
|
||||
security_match: &SecurityMatch,
|
||||
quote: YahooQuoteResult,
|
||||
@@ -229,7 +226,7 @@ mod tests {
|
||||
use super::{map_live_quote_to_company, SecurityKind, SecurityMatch, YahooQuoteResult};
|
||||
|
||||
#[test]
|
||||
fn maps_live_quote_fields_into_company_panel_shape() {
|
||||
fn maps_company_panel_shape_from_single_live_quote_response() {
|
||||
let security_match = SecurityMatch {
|
||||
symbol: "CASEY".to_string(),
|
||||
name: Some("Fallback Name".to_string()),
|
||||
@@ -253,7 +250,7 @@ mod tests {
|
||||
fifty_two_week_low: Some(313.89),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
.expect("quote should map into a company");
|
||||
|
||||
assert_eq!(company.symbol, "CASEY");
|
||||
assert_eq!(company.name, "Casey's General Stores, Inc.");
|
||||
@@ -293,7 +290,7 @@ mod tests {
|
||||
fifty_two_week_low: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
.expect("quote should map into a company");
|
||||
|
||||
assert_eq!(company.name, "Fallback Name");
|
||||
assert_eq!(company.change, 0.0);
|
||||
|
||||
Reference in New Issue
Block a user