Reduce live search requests for exact symbol lookups

This commit is contained in:
2026-04-04 14:09:10 -04:00
parent 38eeae06dd
commit 1dc7bb3391
2 changed files with 128 additions and 48 deletions

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use crate::terminal::mock_data::load_mock_financial_data; use crate::terminal::mock_data::load_mock_financial_data;
use crate::terminal::yahoo_finance::{ use crate::terminal::yahoo_finance::{
SecurityLookup, SecurityLookupError, SecurityMatch, YahooFinanceLookup, SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch, YahooFinanceLookup,
}; };
use crate::terminal::{ use crate::terminal::{
ChatCommandRequest, ExecuteTerminalCommandRequest, MockFinancialData, PanelPayload, ChatCommandRequest, ExecuteTerminalCommandRequest, MockFinancialData, PanelPayload,
@@ -17,7 +17,10 @@ pub struct TerminalCommandService {
impl Default for TerminalCommandService { impl Default for TerminalCommandService {
fn default() -> Self { 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 { let matches = match self.security_lookup.search(query).await {
Ok(matches) => matches Ok(matches) => matches
.into_iter() .into_iter()
@@ -88,20 +102,7 @@ impl TerminalCommandService {
} }
if let Some(selected_match) = select_exact_symbol_match(query, &matches) { if let Some(selected_match) = select_exact_symbol_match(query, &matches) {
let selected_symbol = selected_match.symbol.clone(); return self.load_exact_symbol_match(selected_match).await;
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}\"."),
},
};
} }
TerminalCommandResponse::Text { 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> { fn select_exact_symbol_match(query: &str, matches: &[SecurityMatch]) -> Option<SecurityMatch> {
@@ -227,6 +261,7 @@ fn help_response() -> TerminalCommandResponse {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::Arc;
use futures::future::BoxFuture; use futures::future::BoxFuture;
@@ -236,11 +271,15 @@ mod tests {
use crate::terminal::yahoo_finance::{ use crate::terminal::yahoo_finance::{
SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch, SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch,
}; };
use crate::terminal::{Company, ExecuteTerminalCommandRequest, PanelPayload, TerminalCommandResponse}; use crate::terminal::{
Company, ExecuteTerminalCommandRequest, PanelPayload, TerminalCommandResponse,
};
struct FakeSecurityLookup { struct FakeSecurityLookup {
search_result: Result<Vec<SecurityMatch>, SecurityLookupError>, search_result: Result<Vec<SecurityMatch>, SecurityLookupError>,
fail_detail: bool, fail_detail: bool,
search_calls: AtomicUsize,
detail_calls: AtomicUsize,
} }
impl FakeSecurityLookup { impl FakeSecurityLookup {
@@ -248,6 +287,8 @@ mod tests {
Self { Self {
search_result: Ok(matches), search_result: Ok(matches),
fail_detail: false, fail_detail: false,
search_calls: AtomicUsize::new(0),
detail_calls: AtomicUsize::new(0),
} }
} }
} }
@@ -257,6 +298,7 @@ mod tests {
&'a self, &'a self,
_query: &'a str, _query: &'a str,
) -> BoxFuture<'a, Result<Vec<SecurityMatch>, SecurityLookupError>> { ) -> BoxFuture<'a, Result<Vec<SecurityMatch>, SecurityLookupError>> {
self.search_calls.fetch_add(1, Ordering::Relaxed);
Box::pin(async move { self.search_result.clone() }) Box::pin(async move { self.search_result.clone() })
} }
@@ -264,6 +306,7 @@ mod tests {
&'a self, &'a self,
security_match: &'a SecurityMatch, security_match: &'a SecurityMatch,
) -> BoxFuture<'a, Result<Company, SecurityLookupError>> { ) -> BoxFuture<'a, Result<Company, SecurityLookupError>> {
self.detail_calls.fetch_add(1, Ordering::Relaxed);
Box::pin(async move { Box::pin(async move {
if self.fail_detail { if self.fail_detail {
return Err(SecurityLookupError::DetailUnavailable { return Err(SecurityLookupError::DetailUnavailable {
@@ -273,7 +316,10 @@ mod tests {
Ok(Company { Ok(Company {
symbol: security_match.symbol.clone(), 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, price: 100.0,
change: 1.0, change: 1.0,
change_percent: 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( TerminalCommandService::with_dependencies(
load_mock_financial_data(), load_mock_financial_data(),
Arc::new(FakeSecurityLookup { Arc::new(FakeSecurityLookup {
search_result, 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] #[test]
fn returns_company_panel_for_exact_search_match() { 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(), symbol: "AAPL".to_string(),
name: Some("Apple Inc.".to_string()), name: Some("Apple Inc.".to_string()),
exchange: Some("NASDAQ".to_string()), exchange: Some("NASDAQ".to_string()),
@@ -322,11 +388,14 @@ mod tests {
} => assert_eq!(data.symbol, "AAPL"), } => assert_eq!(data.symbol, "AAPL"),
other => panic!("expected company panel, got {other:?}"), 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] #[test]
fn returns_text_list_for_name_search() { fn returns_text_list_for_name_search() {
let service = build_service(Ok(vec![ let (service, lookup) = build_service(Ok(vec![
SecurityMatch { SecurityMatch {
symbol: "AAPL".to_string(), symbol: "AAPL".to_string(),
name: Some("Apple Inc.".to_string()), name: Some("Apple Inc.".to_string()),
@@ -352,11 +421,14 @@ mod tests {
} }
other => panic!("expected text response, got {other:?}"), 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] #[test]
fn filters_unsupported_assets() { fn filters_unsupported_assets() {
let service = build_service(Ok(vec![SecurityMatch { let (service, _) = build_service(Ok(vec![SecurityMatch {
symbol: "BTC-USD".to_string(), symbol: "BTC-USD".to_string(),
name: Some("Bitcoin USD".to_string()), name: Some("Bitcoin USD".to_string()),
exchange: None, exchange: None,
@@ -375,7 +447,7 @@ mod tests {
#[test] #[test]
fn prefers_us_exchange_priority_for_exact_matches() { fn prefers_us_exchange_priority_for_exact_matches() {
let service = build_service(Ok(vec![ let (service, _) = build_service(Ok(vec![
SecurityMatch { SecurityMatch {
symbol: "ABC".to_string(), symbol: "ABC".to_string(),
name: Some("ABC Ltd".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 { match response {
TerminalCommandResponse::Panel { TerminalCommandResponse::Panel {
@@ -402,13 +474,13 @@ mod tests {
#[test] #[test]
fn returns_live_search_error_when_provider_search_fails() { 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 { match response {
TerminalCommandResponse::Text { content } => { 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:?}"), other => panic!("expected text response, got {other:?}"),
} }
@@ -416,18 +488,12 @@ mod tests {
#[test] #[test]
fn returns_live_detail_error_when_exact_resolution_fails() { fn returns_live_detail_error_when_exact_resolution_fails() {
let service = TerminalCommandService::with_dependencies( let service = build_failing_service(Ok(vec![SecurityMatch {
load_mock_financial_data(),
Arc::new(FakeSecurityLookup {
search_result: Ok(vec![SecurityMatch {
symbol: "AAPL".to_string(), symbol: "AAPL".to_string(),
name: Some("Apple Inc.".to_string()), name: Some("Apple Inc.".to_string()),
exchange: Some("NASDAQ".to_string()), exchange: Some("NASDAQ".to_string()),
kind: SecurityKind::Equity, kind: SecurityKind::Equity,
}]), }]));
fail_detail: true,
}),
);
let response = execute(&service, "/search AAPL"); 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] #[test]
fn returns_text_for_unknown_command() { fn returns_text_for_unknown_command() {
let service = TerminalCommandService::with_dependencies( let service = TerminalCommandService::with_dependencies(

View File

@@ -115,11 +115,7 @@ impl SecurityLookup for YahooFinanceLookup {
symbol: security_match.symbol.clone(), symbol: security_match.symbol.clone(),
}; };
let quote = self let quote = self.fetch_live_quote(&security_match.symbol).await?;
.fetch_live_quote(&security_match.symbol)
.await
.map_err(|_| detail_error())?;
map_live_quote_to_company(security_match, quote).ok_or_else(detail_error) 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( fn map_live_quote_to_company(
security_match: &SecurityMatch, security_match: &SecurityMatch,
quote: YahooQuoteResult, quote: YahooQuoteResult,
@@ -229,7 +226,7 @@ mod tests {
use super::{map_live_quote_to_company, SecurityKind, SecurityMatch, YahooQuoteResult}; use super::{map_live_quote_to_company, SecurityKind, SecurityMatch, YahooQuoteResult};
#[test] #[test]
fn maps_live_quote_fields_into_company_panel_shape() { fn maps_company_panel_shape_from_single_live_quote_response() {
let security_match = SecurityMatch { let security_match = SecurityMatch {
symbol: "CASEY".to_string(), symbol: "CASEY".to_string(),
name: Some("Fallback Name".to_string()), name: Some("Fallback Name".to_string()),
@@ -253,7 +250,7 @@ mod tests {
fifty_two_week_low: Some(313.89), fifty_two_week_low: Some(313.89),
}, },
) )
.unwrap(); .expect("quote should map into a company");
assert_eq!(company.symbol, "CASEY"); assert_eq!(company.symbol, "CASEY");
assert_eq!(company.name, "Casey's General Stores, Inc."); assert_eq!(company.name, "Casey's General Stores, Inc.");
@@ -293,7 +290,7 @@ mod tests {
fifty_two_week_low: None, fifty_two_week_low: None,
}, },
) )
.unwrap(); .expect("quote should map into a company");
assert_eq!(company.name, "Fallback Name"); assert_eq!(company.name, "Fallback Name");
assert_eq!(company.change, 0.0); assert_eq!(company.change, 0.0);