Skip to main content

finance_query/adapters/yahoo/discovery/
lookup.rs

1/// Lookup endpoint
2///
3/// Type-filtered symbol lookup on Yahoo Finance.
4/// Unlike search, lookup specializes in discovering tickers by type
5/// (equity, ETF, mutual fund, index, future, currency, cryptocurrency).
6use 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/// Asset types available for lookup
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum LookupType {
18    /// All asset types
19    #[default]
20    All,
21    /// Stocks/equities
22    Equity,
23    /// Mutual funds
24    #[serde(rename = "mutualfund")]
25    MutualFund,
26    /// Exchange-traded funds
27    #[serde(rename = "etf")]
28    Etf,
29    /// Market indices
30    Index,
31    /// Futures contracts
32    Future,
33    /// Fiat currencies
34    Currency,
35    /// Cryptocurrencies
36    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/// Lookup configuration options
55#[derive(Debug, Clone)]
56pub struct LookupOptions {
57    /// Asset type to search for (default: All)
58    pub lookup_type: LookupType,
59    /// Maximum number of results (default: 25)
60    pub count: u32,
61    /// Include logo URLs by fetching from quotes endpoint (default: false)
62    /// Note: This requires an additional API call for symbols returned
63    pub include_logo: bool,
64    /// Include pricing data (default: true)
65    pub fetch_pricing_data: bool,
66    /// Region for language/region settings. If None, uses client default.
67    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    /// Create new lookup options with defaults
84    pub fn new() -> Self {
85        Self::default()
86    }
87
88    /// Set the asset type to look up
89    pub fn lookup_type(mut self, lookup_type: LookupType) -> Self {
90        self.lookup_type = lookup_type;
91        self
92    }
93
94    /// Set maximum number of results
95    pub fn count(mut self, count: u32) -> Self {
96        self.count = count;
97        self
98    }
99
100    /// Enable or disable logo URL fetching
101    /// Note: When enabled, an additional API call is made to fetch logos
102    pub fn include_logo(mut self, include: bool) -> Self {
103        self.include_logo = include;
104        self
105    }
106
107    /// Enable or disable pricing data
108    pub fn fetch_pricing_data(mut self, fetch: bool) -> Self {
109        self.fetch_pricing_data = fetch;
110        self
111    }
112
113    /// Set region for language/localization settings
114    pub fn region(mut self, region: Region) -> Self {
115        self.region = Some(region);
116        self
117    }
118}
119
120/// Fetch and parse lookup results in a single pass over the body.
121///
122/// Logo enrichment mutates the parsed tree, so that path goes through `Value`
123/// and reshapes from it; the no-logo path deserializes straight from bytes.
124/// Neither path serializes back to bytes.
125pub(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    // Use provided region's lang/code or fall back to client config
147    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", &region),
167    ];
168
169    let response = client.request_with_params(api::LOOKUP, &params).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
184/// Enrich lookup results with logo URLs by fetching from quotes endpoint
185async fn enrich_with_logos(
186    client: &YahooClient,
187    mut json: serde_json::Value,
188) -> Result<serde_json::Value> {
189    // Extract symbols from the response
190    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    // Fetch logos from quotes endpoint
212    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, // include_logo = true to get logo dimensions
220    )
221    .await?;
222
223    // Build a map of symbol -> logo URLs
224    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    // Inject logos into the lookup response
245    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        // Realistic minimal lookup payload in Yahoo's actual wire shape
281        // (finance.result[0].documents), including a partial document that
282        // only carries `symbol` to prove optional fields tolerate absence.
283        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] // Requires network access
317    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] // Requires network access
327    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] // Requires network access
338    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        // The enrichment path reshapes from `Value`; prove it still yields quotes.
347        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}