fmp_rs/endpoints/
stock_directory.rs

1//! Stock directory and screener endpoints
2
3use crate::client::FmpClient;
4use crate::error::Result;
5use crate::models::company::{StockScreenerResult, TradableSymbol};
6use serde::Serialize;
7
8/// Stock directory API endpoints
9pub struct StockDirectory {
10    client: FmpClient,
11}
12
13/// Stock screener criteria
14#[derive(Debug, Clone, Default, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct ScreenerCriteria {
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub market_cap_more_than: Option<i64>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub market_cap_lower_than: Option<i64>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub price_more_than: Option<f64>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub price_lower_than: Option<f64>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub beta_more_than: Option<f64>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub beta_lower_than: Option<f64>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub volume_more_than: Option<i64>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub volume_lower_than: Option<i64>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub dividend_more_than: Option<f64>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub dividend_lower_than: Option<f64>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub is_etf: Option<bool>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub is_actively_trading: Option<bool>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub sector: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub industry: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub country: Option<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub exchange: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub limit: Option<u32>,
51}
52
53impl StockDirectory {
54    pub(crate) fn new(client: FmpClient) -> Self {
55        Self { client }
56    }
57
58    /// Screen stocks based on various criteria
59    ///
60    /// # Arguments
61    /// * `criteria` - Screening criteria to filter stocks
62    ///
63    /// # Example
64    /// ```no_run
65    /// # use fmp_rs::{FmpClient, endpoints::stock_directory::ScreenerCriteria};
66    /// # #[tokio::main]
67    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
68    /// let client = FmpClient::new()?;
69    /// let mut criteria = ScreenerCriteria::default();
70    /// criteria.market_cap_more_than = Some(10_000_000_000); // $10B+
71    /// criteria.beta_lower_than = Some(1.5);
72    /// criteria.is_etf = Some(false);
73    /// criteria.is_actively_trading = Some(true);
74    /// criteria.limit = Some(100);
75    ///
76    /// let results = client.stock_directory().screen_stocks(&criteria).await?;
77    /// for stock in results.iter().take(10) {
78    ///     println!("{}: {} - ${:.2}B market cap",
79    ///         stock.symbol,
80    ///         stock.company_name,
81    ///         stock.market_cap.unwrap_or(0.0) / 1_000_000_000.0);
82    /// }
83    /// # Ok(())
84    /// # }
85    /// ```
86    pub async fn screen_stocks(
87        &self,
88        criteria: &ScreenerCriteria,
89    ) -> Result<Vec<StockScreenerResult>> {
90        self.client
91            .get_with_query("v3/stock-screener", criteria)
92            .await
93    }
94
95    /// Get list of all available traded symbols
96    ///
97    /// # Example
98    /// ```no_run
99    /// # use fmp_rs::FmpClient;
100    /// # #[tokio::main]
101    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
102    /// let client = FmpClient::new()?;
103    /// let symbols = client.stock_directory().get_tradable_symbols().await?;
104    /// println!("Total tradable symbols: {}", symbols.len());
105    /// for symbol in symbols.iter().take(10) {
106    ///     println!("{}: {}", symbol.symbol, symbol.name);
107    /// }
108    /// # Ok(())
109    /// # }
110    /// ```
111    pub async fn get_tradable_symbols(&self) -> Result<Vec<TradableSymbol>> {
112        self.client
113            .get_with_query("v3/available-traded/list", &())
114            .await
115    }
116
117    /// Get symbols by exchange
118    ///
119    /// # Arguments
120    /// * `exchange` - Exchange code (e.g., "NASDAQ", "NYSE")
121    ///
122    /// # Example
123    /// ```no_run
124    /// # use fmp_rs::FmpClient;
125    /// # #[tokio::main]
126    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
127    /// let client = FmpClient::new()?;
128    /// let symbols = client.stock_directory().get_symbols_by_exchange("NASDAQ").await?;
129    /// println!("NASDAQ symbols: {}", symbols.len());
130    /// # Ok(())
131    /// # }
132    /// ```
133    pub async fn get_symbols_by_exchange(&self, exchange: &str) -> Result<Vec<TradableSymbol>> {
134        self.client
135            .get_with_query(&format!("v3/symbol/{}", exchange), &())
136            .await
137    }
138
139    /// Get list of all ETFs
140    ///
141    /// # Example
142    /// ```no_run
143    /// # use fmp_rs::FmpClient;
144    /// # #[tokio::main]
145    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
146    /// let client = FmpClient::new()?;
147    /// let etfs = client.stock_directory().get_etf_list().await?;
148    /// println!("Total ETFs: {}", etfs.len());
149    /// for etf in etfs.iter().take(10) {
150    ///     println!("{}: {}", etf.symbol, etf.name);
151    /// }
152    /// # Ok(())
153    /// # }
154    /// ```
155    pub async fn get_etf_list(&self) -> Result<Vec<TradableSymbol>> {
156        self.client.get_with_query("v3/etf/list", &()).await
157    }
158
159    /// Check if symbol is available/valid
160    ///
161    /// # Arguments
162    /// * `symbol` - Stock symbol to check
163    ///
164    /// # Example
165    /// ```no_run
166    /// # use fmp_rs::FmpClient;
167    /// # #[tokio::main]
168    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
169    /// let client = FmpClient::new()?;
170    /// let symbols = client.stock_directory().is_symbol_available("AAPL").await?;
171    /// if !symbols.is_empty() {
172    ///     println!("AAPL is available: {}", symbols[0].name);
173    /// }
174    /// # Ok(())
175    /// # }
176    /// ```
177    pub async fn is_symbol_available(&self, symbol: &str) -> Result<Vec<TradableSymbol>> {
178        self.client
179            .get_with_query(&format!("v3/symbol/available-{}", symbol), &())
180            .await
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn test_new() {
190        let client = FmpClient::builder().api_key("test_key").build().unwrap();
191        let _ = StockDirectory::new(client);
192    }
193
194    #[test]
195    fn test_screener_criteria_default() {
196        let criteria = ScreenerCriteria::default();
197        assert!(criteria.market_cap_more_than.is_none());
198        assert!(criteria.is_etf.is_none());
199    }
200
201    #[test]
202    fn test_screener_criteria_builder() {
203        let mut criteria = ScreenerCriteria::default();
204        criteria.market_cap_more_than = Some(1_000_000_000);
205        criteria.beta_lower_than = Some(1.5);
206        criteria.is_actively_trading = Some(true);
207
208        assert_eq!(criteria.market_cap_more_than, Some(1_000_000_000));
209        assert_eq!(criteria.beta_lower_than, Some(1.5));
210        assert_eq!(criteria.is_actively_trading, Some(true));
211    }
212
213    // Golden path tests
214    #[tokio::test]
215    #[ignore = "requires FMP API key"]
216    async fn test_screen_stocks() {
217        let client = FmpClient::new().unwrap();
218        let mut criteria = ScreenerCriteria::default();
219        criteria.market_cap_more_than = Some(10_000_000_000);
220        criteria.is_etf = Some(false);
221        criteria.limit = Some(10);
222
223        let result = client.stock_directory().screen_stocks(&criteria).await;
224        assert!(result.is_ok());
225        let stocks = result.unwrap();
226        assert!(!stocks.is_empty());
227        assert!(stocks.len() <= 10);
228    }
229
230    #[tokio::test]
231    #[ignore = "requires FMP API key"]
232    async fn test_get_tradable_symbols() {
233        let client = FmpClient::new().unwrap();
234        let result = client.stock_directory().get_tradable_symbols().await;
235        assert!(result.is_ok());
236        let symbols = result.unwrap();
237        assert!(!symbols.is_empty());
238    }
239
240    #[tokio::test]
241    #[ignore = "requires FMP API key"]
242    async fn test_get_symbols_by_exchange() {
243        let client = FmpClient::new().unwrap();
244        let result = client
245            .stock_directory()
246            .get_symbols_by_exchange("NASDAQ")
247            .await;
248        assert!(result.is_ok());
249        let symbols = result.unwrap();
250        assert!(!symbols.is_empty());
251    }
252
253    #[tokio::test]
254    #[ignore = "requires FMP API key"]
255    async fn test_get_etf_list() {
256        let client = FmpClient::new().unwrap();
257        let result = client.stock_directory().get_etf_list().await;
258        assert!(result.is_ok());
259        let etfs = result.unwrap();
260        assert!(!etfs.is_empty());
261    }
262
263    #[tokio::test]
264    #[ignore = "requires FMP API key"]
265    async fn test_is_symbol_available() {
266        let client = FmpClient::new().unwrap();
267        let result = client.stock_directory().is_symbol_available("AAPL").await;
268        assert!(result.is_ok());
269        let symbols = result.unwrap();
270        assert!(!symbols.is_empty());
271        assert_eq!(symbols[0].symbol, "AAPL");
272    }
273
274    // Edge case tests
275    #[tokio::test]
276    #[ignore = "requires FMP API key"]
277    async fn test_screen_stocks_empty_criteria() {
278        let client = FmpClient::new().unwrap();
279        let criteria = ScreenerCriteria::default();
280        let result = client.stock_directory().screen_stocks(&criteria).await;
281        assert!(result.is_ok());
282    }
283
284    #[tokio::test]
285    #[ignore = "requires FMP API key"]
286    async fn test_screen_stocks_restrictive_criteria() {
287        let client = FmpClient::new().unwrap();
288        let mut criteria = ScreenerCriteria::default();
289        criteria.market_cap_more_than = Some(1_000_000_000_000); // $1T+
290        criteria.price_more_than = Some(500.0);
291        criteria.limit = Some(5);
292
293        let result = client.stock_directory().screen_stocks(&criteria).await;
294        assert!(result.is_ok());
295        // May return empty if criteria is too restrictive
296    }
297
298    #[tokio::test]
299    #[ignore = "requires FMP API key"]
300    async fn test_is_symbol_available_invalid() {
301        let client = FmpClient::new().unwrap();
302        let result = client
303            .stock_directory()
304            .is_symbol_available("INVALID_XYZ123")
305            .await;
306        if let Ok(symbols) = result {
307            assert!(symbols.is_empty());
308        }
309    }
310
311    #[tokio::test]
312    #[ignore = "requires FMP API key"]
313    async fn test_get_symbols_by_exchange_invalid() {
314        let client = FmpClient::new().unwrap();
315        let result = client
316            .stock_directory()
317            .get_symbols_by_exchange("INVALID_EXCHANGE")
318            .await;
319        // Should either error or return empty list
320        if let Ok(symbols) = result {
321            assert!(symbols.is_empty());
322        }
323    }
324
325    // Error handling tests
326    #[tokio::test]
327    async fn test_invalid_api_key() {
328        let client = FmpClient::builder()
329            .api_key("invalid_key_12345")
330            .build()
331            .unwrap();
332        let result = client.stock_directory().get_tradable_symbols().await;
333        assert!(result.is_err());
334    }
335}