Skip to main content

finance_query/models/discovery/
reference.rs

1//! Provider-routed symbol reference models.
2//!
3//! Returned by the [`Capability::DISCOVERY`](crate::Capability::DISCOVERY)
4//! route via [`Providers::discovery`](crate::Providers::discovery). These are
5//! provider-neutral shapes — unlike [`SearchResults`](super::search::SearchResults),
6//! which mirrors Yahoo's response for the [`crate::finance::search`] shortcut.
7
8use serde::{Deserialize, Serialize};
9
10/// A symbol matched by a search or listing query.
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12#[non_exhaustive]
13pub struct SymbolMatch {
14    /// Ticker symbol, uppercased.
15    pub symbol: String,
16    /// Provider-native identifier, when the provider uses one distinct from
17    /// the ticker — e.g. the CoinGecko coin id (`"bitcoin"`), which is what
18    /// [`Providers::crypto`](crate::Providers::crypto) accepts. `None` for
19    /// providers whose ticker *is* the identifier.
20    pub id: Option<String>,
21    /// Security or company name.
22    pub name: Option<String>,
23    /// Listing exchange, as reported by the provider.
24    pub exchange: Option<String>,
25    /// Asset type (e.g. `"CS"` for common stock, `"ETF"`).
26    pub asset_type: Option<String>,
27    /// Quote currency.
28    pub currency: Option<String>,
29    /// Whether the symbol is currently active/tradable.
30    pub active: Option<bool>,
31    /// Rank within the provider's universe by market cap (1 = largest).
32    pub market_cap_rank: Option<u32>,
33    /// Small logo/icon URL.
34    pub thumbnail: Option<String>,
35    /// Full-size logo URL.
36    pub image: Option<String>,
37    /// Provider-reported IPO date, when available.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub ipo_date: Option<String>,
40    /// Provider-reported delisting date, when available.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub delisted_date: Option<String>,
43}
44
45/// Detailed reference data for a single symbol.
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47#[non_exhaustive]
48pub struct SymbolDetails {
49    /// Ticker symbol.
50    pub symbol: String,
51    /// Company name.
52    pub name: Option<String>,
53    /// Business description.
54    pub description: Option<String>,
55    /// Primary listing exchange.
56    pub exchange: Option<String>,
57    /// Asset type.
58    pub asset_type: Option<String>,
59    /// SEC Central Index Key.
60    pub cik: Option<String>,
61    /// SIC classification code.
62    pub sic_code: Option<String>,
63    /// SIC classification description.
64    pub sic_description: Option<String>,
65    /// Company homepage.
66    pub homepage_url: Option<String>,
67    /// Total employees.
68    pub employees: Option<u64>,
69    /// Market capitalisation.
70    pub market_cap: Option<f64>,
71    /// Date the security was listed (`YYYY-MM-DD`).
72    pub list_date: Option<String>,
73    /// Shares outstanding, weighted across share classes.
74    pub shares_outstanding: Option<f64>,
75    /// Composite security identifier.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub composite_figi: Option<String>,
78    /// Share-class identifier.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub share_class_figi: Option<String>,
81    /// Provider-reported listing status.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub active: Option<bool>,
84    /// Provider-reported delisting timestamp.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub delisted_utc: Option<String>,
87    /// Provider that supplied these details.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub provider_id: Option<crate::Provider>,
90}
91
92/// A tradable exchange.
93#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94#[non_exhaustive]
95pub struct ExchangeInfo {
96    /// Provider-assigned exchange ID.
97    pub id: Option<i64>,
98    /// Exchange name.
99    pub name: Option<String>,
100    /// ISO 10383 Market Identifier Code.
101    pub mic: Option<String>,
102    /// Operating MIC of the parent venue.
103    pub operating_mic: Option<String>,
104    /// Asset class traded (e.g. `"stocks"`, `"options"`).
105    pub asset_class: Option<String>,
106    /// Locale (e.g. `"us"`).
107    pub locale: Option<String>,
108    /// Venue type (e.g. `"exchange"`, `"TRF"`).
109    pub exchange_type: Option<String>,
110    /// Exchange homepage.
111    pub url: Option<String>,
112}
113
114/// A symbol matched by a screener query.
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116#[non_exhaustive]
117pub struct ScreenerMatch {
118    /// Ticker symbol.
119    pub symbol: String,
120    /// Company name.
121    pub name: Option<String>,
122    /// Latest price.
123    pub price: Option<f64>,
124    /// Market capitalisation.
125    pub market_cap: Option<f64>,
126    /// GICS-style sector.
127    pub sector: Option<String>,
128    /// Industry classification.
129    pub industry: Option<String>,
130    /// Beta against the broad market.
131    pub beta: Option<f64>,
132    /// Trading volume.
133    pub volume: Option<f64>,
134    /// Listing exchange.
135    pub exchange: Option<String>,
136    /// Country of domicile.
137    pub country: Option<String>,
138    /// Whether the symbol is an ETF.
139    pub is_etf: Option<bool>,
140    /// Whether the symbol is actively trading.
141    pub is_actively_trading: Option<bool>,
142    /// Whether the symbol is a fund.
143    pub is_fund: Option<bool>,
144}
145
146/// Filters for a provider-routed screener query.
147///
148/// All fields are optional; unset filters are omitted from the request. Build
149/// with [`ScreenerFilters::new`] and the chainable setters.
150#[derive(Debug, Clone, Default, PartialEq)]
151#[non_exhaustive]
152pub struct ScreenerFilters {
153    /// Minimum market capitalisation.
154    pub market_cap_min: Option<f64>,
155    /// Maximum market capitalisation.
156    pub market_cap_max: Option<f64>,
157    /// Minimum price.
158    pub price_min: Option<f64>,
159    /// Maximum price.
160    pub price_max: Option<f64>,
161    /// Minimum trading volume.
162    pub volume_min: Option<f64>,
163    /// Minimum beta.
164    pub beta_min: Option<f64>,
165    /// Maximum beta.
166    pub beta_max: Option<f64>,
167    /// Sector name filter.
168    pub sector: Option<String>,
169    /// Industry name filter.
170    pub industry: Option<String>,
171    /// Exchange filter.
172    pub exchange: Option<String>,
173    /// Country filter.
174    pub country: Option<String>,
175    /// Restrict to actively trading symbols.
176    pub actively_trading: Option<bool>,
177    /// Maximum number of results.
178    pub limit: Option<u32>,
179    /// Restrict to ETFs or exclude them.
180    pub is_etf: Option<bool>,
181    /// Restrict to funds or exclude them.
182    pub is_fund: Option<bool>,
183}
184
185impl ScreenerFilters {
186    /// An empty filter set — matches the provider's default universe.
187    pub fn new() -> Self {
188        Self::default()
189    }
190
191    /// Restrict market capitalisation to `[min, max]`.
192    pub fn market_cap(mut self, min: Option<f64>, max: Option<f64>) -> Self {
193        self.market_cap_min = min;
194        self.market_cap_max = max;
195        self
196    }
197
198    /// Restrict price to `[min, max]`.
199    pub fn price(mut self, min: Option<f64>, max: Option<f64>) -> Self {
200        self.price_min = min;
201        self.price_max = max;
202        self
203    }
204
205    /// Restrict beta to `[min, max]`.
206    pub fn beta(mut self, min: Option<f64>, max: Option<f64>) -> Self {
207        self.beta_min = min;
208        self.beta_max = max;
209        self
210    }
211
212    /// Require at least `min` traded volume.
213    pub fn volume_min(mut self, min: f64) -> Self {
214        self.volume_min = Some(min);
215        self
216    }
217
218    /// Restrict to a sector.
219    pub fn sector(mut self, sector: impl Into<String>) -> Self {
220        self.sector = Some(sector.into());
221        self
222    }
223
224    /// Restrict to an industry.
225    pub fn industry(mut self, industry: impl Into<String>) -> Self {
226        self.industry = Some(industry.into());
227        self
228    }
229
230    /// Restrict to an exchange.
231    pub fn exchange(mut self, exchange: impl Into<String>) -> Self {
232        self.exchange = Some(exchange.into());
233        self
234    }
235
236    /// Restrict to a country of domicile.
237    pub fn country(mut self, country: impl Into<String>) -> Self {
238        self.country = Some(country.into());
239        self
240    }
241
242    /// Restrict to symbols that are actively trading.
243    pub fn actively_trading(mut self, actively_trading: bool) -> Self {
244        self.actively_trading = Some(actively_trading);
245        self
246    }
247
248    /// Cap the number of results returned.
249    pub fn limit(mut self, limit: u32) -> Self {
250        self.limit = Some(limit);
251        self
252    }
253
254    /// Render as provider query-string pairs, omitting unset filters.
255    ///
256    /// Uses Financial Modeling Prep's parameter names — the only provider
257    /// currently routed for `Capability::DISCOVERY` screening.
258    #[cfg(feature = "fmp")]
259    pub(crate) fn to_query(&self) -> Vec<(&'static str, String)> {
260        let mut q: Vec<(&'static str, String)> = Vec::new();
261        let mut num = |k: &'static str, v: Option<f64>| {
262            if let Some(v) = v {
263                q.push((k, v.to_string()));
264            }
265        };
266        num("marketCapMoreThan", self.market_cap_min);
267        num("marketCapLowerThan", self.market_cap_max);
268        num("priceMoreThan", self.price_min);
269        num("priceLowerThan", self.price_max);
270        num("volumeMoreThan", self.volume_min);
271        num("betaMoreThan", self.beta_min);
272        num("betaLowerThan", self.beta_max);
273        for (k, v) in [
274            ("sector", &self.sector),
275            ("industry", &self.industry),
276            ("exchange", &self.exchange),
277            ("country", &self.country),
278        ] {
279            if let Some(v) = v {
280                q.push((k, v.clone()));
281            }
282        }
283        if let Some(v) = self.actively_trading {
284            q.push(("isActivelyTrading", v.to_string()));
285        }
286        if let Some(v) = self.is_etf {
287            q.push(("isEtf", v.to_string()));
288        }
289        if let Some(v) = self.is_fund {
290            q.push(("isFund", v.to_string()));
291        }
292        if let Some(v) = self.limit {
293            q.push(("limit", v.to_string()));
294        }
295        q
296    }
297}
298
299// Gated as a whole: every test here exercises `to_query`, which is fmp-only.
300#[cfg(all(test, feature = "fmp"))]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn empty_filters_render_no_query_params() {
306        assert!(ScreenerFilters::new().to_query().is_empty());
307    }
308
309    #[test]
310    fn set_filters_render_with_provider_parameter_names() {
311        let q = ScreenerFilters::new()
312            .market_cap(Some(1e9), None)
313            .price(None, Some(50.0))
314            .sector("Technology")
315            .actively_trading(true)
316            .limit(25)
317            .to_query();
318
319        assert_eq!(
320            q,
321            vec![
322                ("marketCapMoreThan", "1000000000".to_string()),
323                ("priceLowerThan", "50".to_string()),
324                ("sector", "Technology".to_string()),
325                ("isActivelyTrading", "true".to_string()),
326                ("limit", "25".to_string()),
327            ]
328        );
329    }
330}