finance_query/endpoints/
lookup.rs

1use super::urls::api;
2/// Lookup endpoint
3///
4/// Type-filtered symbol lookup on Yahoo Finance.
5/// Unlike search, lookup specializes in discovering tickers by type
6/// (equity, ETF, mutual fund, index, future, currency, cryptocurrency).
7use crate::client::YahooClient;
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 lookup results for a query
121///
122/// # Arguments
123///
124/// * `client` - The Yahoo Finance client
125/// * `query` - Search query string
126/// * `options` - Lookup configuration options
127///
128/// # Example
129///
130/// ```ignore
131/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
132/// # let client = finance_query::YahooClient::new(Default::default()).await?;
133/// use finance_query::endpoints::lookup::{fetch, LookupOptions, LookupType};
134/// let options = LookupOptions::new()
135///     .lookup_type(LookupType::Equity)
136///     .count(10)
137///     .include_logo(true);
138/// let results = fetch(&client, "Apple", &options).await?;
139/// # Ok(())
140/// # }
141/// ```
142pub async fn fetch(
143    client: &YahooClient,
144    query: &str,
145    options: &LookupOptions,
146) -> Result<serde_json::Value> {
147    if query.trim().is_empty() {
148        return Err(crate::error::YahooError::InvalidParameter {
149            param: "query".to_string(),
150            reason: "Empty lookup query".to_string(),
151        });
152    }
153
154    info!(
155        "Looking up: {} (type: {}, count: {}, include_logo: {})",
156        query, options.lookup_type, options.count, options.include_logo
157    );
158
159    let count = options.count.to_string();
160    let lookup_type = options.lookup_type.to_string();
161    let fetch_pricing = options.fetch_pricing_data.to_string();
162
163    // Use provided region's lang/code or fall back to client config
164    let lang = options
165        .region
166        .as_ref()
167        .map(|c| c.lang().to_string())
168        .unwrap_or_else(|| client.config().lang.clone());
169    let region = options
170        .region
171        .as_ref()
172        .map(|c| c.region().to_string())
173        .unwrap_or_else(|| client.config().region.clone());
174
175    let params = [
176        ("query", query),
177        ("type", &lookup_type),
178        ("start", "0"),
179        ("count", &count),
180        ("formatted", "false"),
181        ("fetchPricingData", &fetch_pricing),
182        ("lang", &lang),
183        ("region", &region),
184    ];
185
186    let response = client.request_with_params(api::LOOKUP, &params).await?;
187
188    let mut json: serde_json::Value = response.json().await?;
189
190    // If logo is requested, fetch logos for the returned symbols
191    if options.include_logo {
192        json = enrich_with_logos(client, json).await?;
193    }
194
195    Ok(json)
196}
197
198/// Enrich lookup results with logo URLs by fetching from quotes endpoint
199async fn enrich_with_logos(
200    client: &YahooClient,
201    mut json: serde_json::Value,
202) -> Result<serde_json::Value> {
203    // Extract symbols from the response
204    let symbols: Vec<String> = json
205        .get("finance")
206        .and_then(|f| f.get("result"))
207        .and_then(|r| r.as_array())
208        .and_then(|arr| arr.first())
209        .and_then(|first| first.get("documents"))
210        .and_then(|docs| docs.as_array())
211        .map(|docs| {
212            docs.iter()
213                .filter_map(|doc| doc.get("symbol").and_then(|s| s.as_str()))
214                .map(String::from)
215                .collect()
216        })
217        .unwrap_or_default();
218
219    if symbols.is_empty() {
220        return Ok(json);
221    }
222
223    info!("Fetching logos for {} symbols", symbols.len());
224
225    // Fetch logos from quotes endpoint
226    let symbol_refs: Vec<&str> = symbols.iter().map(|s| s.as_str()).collect();
227    let logo_fields = ["logoUrl", "companyLogoUrl"];
228    let logos_json = crate::endpoints::quotes::fetch_with_fields(
229        client,
230        &symbol_refs,
231        Some(&logo_fields),
232        false,
233        true, // include_logo = true to get logo dimensions
234    )
235    .await?;
236
237    // Build a map of symbol -> logo URLs
238    let logo_map: std::collections::HashMap<String, (Option<String>, Option<String>)> = logos_json
239        .get("quoteResponse")
240        .and_then(|qr| qr.get("result"))
241        .and_then(|r| r.as_array())
242        .map(|quotes| {
243            quotes
244                .iter()
245                .filter_map(|q| {
246                    let symbol = q.get("symbol")?.as_str()?.to_string();
247                    let logo_url = q.get("logoUrl").and_then(|u| u.as_str()).map(String::from);
248                    let company_logo_url = q
249                        .get("companyLogoUrl")
250                        .and_then(|u| u.as_str())
251                        .map(String::from);
252                    Some((symbol, (logo_url, company_logo_url)))
253                })
254                .collect()
255        })
256        .unwrap_or_default();
257
258    // Inject logos into the lookup response
259    if let Some(documents) = json
260        .get_mut("finance")
261        .and_then(|f| f.get_mut("result"))
262        .and_then(|r| r.as_array_mut())
263        .and_then(|arr| arr.first_mut())
264        .and_then(|first| first.get_mut("documents"))
265        .and_then(|docs| docs.as_array_mut())
266    {
267        for doc in documents.iter_mut() {
268            if let Some(symbol) = doc.get("symbol").and_then(|s| s.as_str())
269                && let Some((logo_url, company_logo_url)) = logo_map.get(symbol)
270            {
271                if let Some(url) = logo_url {
272                    doc.as_object_mut()
273                        .map(|obj| obj.insert("logoUrl".to_string(), serde_json::json!(url)));
274                }
275                if let Some(url) = company_logo_url {
276                    doc.as_object_mut().map(|obj| {
277                        obj.insert("companyLogoUrl".to_string(), serde_json::json!(url))
278                    });
279                }
280            }
281        }
282    }
283
284    Ok(json)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::client::ClientConfig;
291
292    #[tokio::test]
293    #[ignore] // Requires network access
294    async fn test_fetch_lookup() {
295        let client = YahooClient::new(ClientConfig::default()).await.unwrap();
296        let options = LookupOptions::new().count(5);
297        let result = fetch(&client, "Apple", &options).await;
298        assert!(result.is_ok());
299        let json = result.unwrap();
300        assert!(json.get("finance").is_some());
301    }
302
303    #[tokio::test]
304    #[ignore] // Requires network access
305    async fn test_fetch_lookup_equity() {
306        let client = YahooClient::new(ClientConfig::default()).await.unwrap();
307        let options = LookupOptions::new()
308            .lookup_type(LookupType::Equity)
309            .count(5);
310        let result = fetch(&client, "NVDA", &options).await;
311        assert!(result.is_ok());
312    }
313
314    #[tokio::test]
315    #[ignore] // Requires network access
316    async fn test_fetch_lookup_with_logo() {
317        let client = YahooClient::new(ClientConfig::default()).await.unwrap();
318        let options = LookupOptions::new()
319            .lookup_type(LookupType::Equity)
320            .count(3)
321            .include_logo(true);
322        let result = fetch(&client, "Apple", &options).await;
323        assert!(result.is_ok());
324        // Check that logos were enriched
325        let json = result.unwrap();
326        if let Some(doc) = json
327            .get("finance")
328            .and_then(|f| f.get("result"))
329            .and_then(|r| r.as_array())
330            .and_then(|arr| arr.first())
331            .and_then(|first| first.get("documents"))
332            .and_then(|docs| docs.as_array())
333            .and_then(|docs| docs.first())
334        {
335            // Logo should be present if symbol was found in quotes
336            println!("Document with logo: {:?}", doc);
337        }
338    }
339
340    #[tokio::test]
341    #[ignore = "requires network access - validation tested in common::tests"]
342    async fn test_empty_query() {
343        let client = YahooClient::new(ClientConfig::default()).await.unwrap();
344        let options = LookupOptions::new();
345        let result = fetch(&client, "", &options).await;
346        assert!(result.is_err());
347    }
348}