finance_query/adapters/yahoo/discovery/
lookup.rs1use crate::adapters::yahoo::client::YahooClient;
7use crate::adapters::yahoo::endpoints::api;
8use crate::constants::Region;
9use crate::error::Result;
10use serde::{Deserialize, Serialize};
11use std::fmt;
12use tracing::info;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum LookupType {
18 #[default]
20 All,
21 Equity,
23 #[serde(rename = "mutualfund")]
25 MutualFund,
26 #[serde(rename = "etf")]
28 Etf,
29 Index,
31 Future,
33 Currency,
35 Cryptocurrency,
37}
38
39impl fmt::Display for LookupType {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 match self {
42 LookupType::All => write!(f, "all"),
43 LookupType::Equity => write!(f, "equity"),
44 LookupType::MutualFund => write!(f, "mutualfund"),
45 LookupType::Etf => write!(f, "etf"),
46 LookupType::Index => write!(f, "index"),
47 LookupType::Future => write!(f, "future"),
48 LookupType::Currency => write!(f, "currency"),
49 LookupType::Cryptocurrency => write!(f, "cryptocurrency"),
50 }
51 }
52}
53
54#[derive(Debug, Clone)]
56pub struct LookupOptions {
57 pub lookup_type: LookupType,
59 pub count: u32,
61 pub include_logo: bool,
64 pub fetch_pricing_data: bool,
66 pub region: Option<Region>,
68}
69
70impl Default for LookupOptions {
71 fn default() -> Self {
72 Self {
73 lookup_type: LookupType::All,
74 count: 25,
75 include_logo: false,
76 fetch_pricing_data: true,
77 region: None,
78 }
79 }
80}
81
82impl LookupOptions {
83 pub fn new() -> Self {
85 Self::default()
86 }
87
88 pub fn lookup_type(mut self, lookup_type: LookupType) -> Self {
90 self.lookup_type = lookup_type;
91 self
92 }
93
94 pub fn count(mut self, count: u32) -> Self {
96 self.count = count;
97 self
98 }
99
100 pub fn include_logo(mut self, include: bool) -> Self {
103 self.include_logo = include;
104 self
105 }
106
107 pub fn fetch_pricing_data(mut self, fetch: bool) -> Self {
109 self.fetch_pricing_data = fetch;
110 self
111 }
112
113 pub fn region(mut self, region: Region) -> Self {
115 self.region = Some(region);
116 self
117 }
118}
119
120pub(crate) async fn fetch_results(
126 client: &YahooClient,
127 query: &str,
128 options: &LookupOptions,
129) -> Result<crate::models::discovery::lookup::LookupResults> {
130 if query.trim().is_empty() {
131 return Err(crate::error::FinanceError::InvalidParameter {
132 param: "query".to_string(),
133 reason: "Empty lookup query".to_string(),
134 });
135 }
136
137 info!(
138 "Looking up: {} (type: {}, count: {}, include_logo: {})",
139 query, options.lookup_type, options.count, options.include_logo
140 );
141
142 let count = options.count.to_string();
143 let lookup_type = options.lookup_type.to_string();
144 let fetch_pricing = options.fetch_pricing_data.to_string();
145
146 let lang = options
148 .region
149 .as_ref()
150 .map(|c| c.lang().to_string())
151 .unwrap_or_else(|| client.config().lang.clone());
152 let region = options
153 .region
154 .as_ref()
155 .map(|c| c.region().to_string())
156 .unwrap_or_else(|| client.config().region.clone());
157
158 let params = [
159 ("query", query),
160 ("type", &lookup_type),
161 ("start", "0"),
162 ("count", &count),
163 ("formatted", "false"),
164 ("fetchPricingData", &fetch_pricing),
165 ("lang", &lang),
166 ("region", ®ion),
167 ];
168
169 let response = client.request_with_params(api::LOOKUP, ¶ms).await?;
170
171 if options.include_logo {
172 let json: serde_json::Value = response.json().await?;
173 let json = enrich_with_logos(client, json).await?;
174 Ok(crate::models::discovery::lookup::LookupResults::from_json(
175 json,
176 )?)
177 } else {
178 Ok(crate::models::discovery::lookup::LookupResults::from_slice(
179 &response.bytes().await?,
180 )?)
181 }
182}
183
184async fn enrich_with_logos(
186 client: &YahooClient,
187 mut json: serde_json::Value,
188) -> Result<serde_json::Value> {
189 let symbols: Vec<String> = json
191 .get("finance")
192 .and_then(|f| f.get("result"))
193 .and_then(|r| r.as_array())
194 .and_then(|arr| arr.first())
195 .and_then(|first| first.get("documents"))
196 .and_then(|docs| docs.as_array())
197 .map(|docs| {
198 docs.iter()
199 .filter_map(|doc| doc.get("symbol").and_then(|s| s.as_str()))
200 .map(String::from)
201 .collect()
202 })
203 .unwrap_or_default();
204
205 if symbols.is_empty() {
206 return Ok(json);
207 }
208
209 info!("Fetching logos for {} symbols", symbols.len());
210
211 let symbol_refs: Vec<&str> = symbols.iter().map(|s| s.as_str()).collect();
213 let logo_fields = ["logoUrl", "companyLogoUrl"];
214 let logos_json = crate::adapters::yahoo::quote::quotes::fetch_with_fields(
215 client,
216 &symbol_refs,
217 Some(&logo_fields),
218 false,
219 true, )
221 .await?;
222
223 let logo_map: std::collections::HashMap<String, (Option<String>, Option<String>)> = logos_json
225 .get("quoteResponse")
226 .and_then(|qr| qr.get("result"))
227 .and_then(|r| r.as_array())
228 .map(|quotes| {
229 quotes
230 .iter()
231 .filter_map(|q| {
232 let symbol = q.get("symbol")?.as_str()?.to_string();
233 let logo_url = q.get("logoUrl").and_then(|u| u.as_str()).map(String::from);
234 let company_logo_url = q
235 .get("companyLogoUrl")
236 .and_then(|u| u.as_str())
237 .map(String::from);
238 Some((symbol, (logo_url, company_logo_url)))
239 })
240 .collect()
241 })
242 .unwrap_or_default();
243
244 if let Some(documents) = json
246 .get_mut("finance")
247 .and_then(|f| f.get_mut("result"))
248 .and_then(|r| r.as_array_mut())
249 .and_then(|arr| arr.first_mut())
250 .and_then(|first| first.get_mut("documents"))
251 .and_then(|docs| docs.as_array_mut())
252 {
253 for doc in documents.iter_mut() {
254 if let Some(symbol) = doc.get("symbol").and_then(|s| s.as_str())
255 && let Some((logo_url, company_logo_url)) = logo_map.get(symbol)
256 {
257 if let Some(url) = logo_url {
258 doc.as_object_mut()
259 .map(|obj| obj.insert("logoUrl".to_string(), serde_json::json!(url)));
260 }
261 if let Some(url) = company_logo_url {
262 doc.as_object_mut().map(|obj| {
263 obj.insert("companyLogoUrl".to_string(), serde_json::json!(url))
264 });
265 }
266 }
267 }
268 }
269
270 Ok(json)
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::adapters::yahoo::client::ClientConfig;
277
278 #[test]
279 fn lookup_results_parse_from_bytes() {
280 let body = br#"{
284 "finance": {
285 "result": [{
286 "documents": [
287 {"symbol": "AAPL", "shortName": "Apple Inc.", "quoteType": "equity"},
288 {"symbol": "APLE"}
289 ],
290 "start": 0,
291 "count": 2
292 }]
293 }
294 }"#;
295
296 let parsed = crate::models::discovery::lookup::LookupResults::from_slice(body).unwrap();
297 assert_eq!(parsed.result_count(), 2);
298 assert_eq!(parsed.quotes().len(), 2);
299 assert_eq!(parsed.quotes()[0].symbol, "AAPL");
300 assert_eq!(parsed.quotes()[1].short_name, None);
301 assert_eq!(parsed.start, Some(0));
302 }
303
304 #[test]
305 fn lookup_results_parse_from_bytes_empty_result() {
306 let parsed = crate::models::discovery::lookup::LookupResults::from_slice(
307 br#"{"finance": {"result": []}}"#,
308 )
309 .unwrap();
310 assert!(parsed.is_empty());
311 assert_eq!(parsed.start, None);
312 assert_eq!(parsed.count, None);
313 }
314
315 #[tokio::test]
316 #[ignore] async fn test_fetch_lookup() {
318 let client = YahooClient::new(ClientConfig::default()).await.unwrap();
319 let options = LookupOptions::new().count(5);
320 let result = fetch_results(&client, "Apple", &options).await;
321 assert!(result.is_ok());
322 assert!(!result.unwrap().quotes().is_empty());
323 }
324
325 #[tokio::test]
326 #[ignore] async fn test_fetch_lookup_equity() {
328 let client = YahooClient::new(ClientConfig::default()).await.unwrap();
329 let options = LookupOptions::new()
330 .lookup_type(LookupType::Equity)
331 .count(5);
332 let result = fetch_results(&client, "NVDA", &options).await;
333 assert!(result.is_ok());
334 }
335
336 #[tokio::test]
337 #[ignore] async fn test_fetch_lookup_with_logo() {
339 let client = YahooClient::new(ClientConfig::default()).await.unwrap();
340 let options = LookupOptions::new()
341 .lookup_type(LookupType::Equity)
342 .count(3)
343 .include_logo(true);
344 let result = fetch_results(&client, "Apple", &options).await;
345 assert!(result.is_ok());
346 let results = result.unwrap();
348 assert!(!results.quotes().is_empty());
349 }
350
351 #[tokio::test]
352 #[ignore = "requires network access - validation tested in common::tests"]
353 async fn test_empty_query() {
354 let client = YahooClient::new(ClientConfig::default()).await.unwrap();
355 let options = LookupOptions::new();
356 let result = fetch_results(&client, "", &options).await;
357 assert!(result.is_err());
358 }
359}