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::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 {
symbol: "AAPL".to_string(),
name: Some("Apple Inc.".to_string()),
exchange: Some("NASDAQ".to_string()),
kind: SecurityKind::Equity,
}]),
fail_detail: true,
}),
);
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,
}]));
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(

View File

@@ -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);