Skip to main content

finance_query/adapters/fmp/
news.rs

1//! News endpoints: stock news, FMP articles, press releases, crypto news, forex news.
2
3use serde::{Deserialize, Serialize};
4
5use crate::adapters::common::encode_path_segment;
6use crate::error::Result;
7
8use super::build_client;
9
10// ============================================================================
11// Response types
12// ============================================================================
13
14/// Stock news article.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct StockNews {
18    /// Ticker symbol.
19    pub symbol: Option<String>,
20    /// Published date.
21    #[serde(rename = "publishedDate")]
22    pub published_date: Option<String>,
23    /// Article title.
24    pub title: Option<String>,
25    /// Article image URL.
26    pub image: Option<String>,
27    /// News site name.
28    pub site: Option<String>,
29    /// Article text / summary.
30    pub text: Option<String>,
31    /// Article URL.
32    pub url: Option<String>,
33}
34
35/// FMP article from their own editorial.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[non_exhaustive]
38pub struct FmpArticle {
39    /// Article title.
40    pub title: Option<String>,
41    /// Article date.
42    pub date: Option<String>,
43    /// Article content.
44    pub content: Option<String>,
45    /// Tickers mentioned.
46    pub tickers: Option<String>,
47    /// Article image URL.
48    pub image: Option<String>,
49    /// Article link.
50    pub link: Option<String>,
51    /// Author.
52    pub author: Option<String>,
53    /// Site name.
54    pub site: Option<String>,
55}
56
57/// FMP articles response wrapper (paginated).
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[non_exhaustive]
60pub struct FmpArticlesResponse {
61    /// Content articles.
62    pub content: Option<Vec<FmpArticle>>,
63    /// Page number.
64    pub page: Option<u32>,
65    /// Page size.
66    pub size: Option<u32>,
67}
68
69/// Press release.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[non_exhaustive]
72pub struct PressRelease {
73    /// Ticker symbol.
74    pub symbol: Option<String>,
75    /// Date.
76    pub date: Option<String>,
77    /// Title.
78    pub title: Option<String>,
79    /// Full text.
80    pub text: Option<String>,
81}
82
83// ============================================================================
84// Public API
85// ============================================================================
86
87/// Fetch stock news articles.
88///
89/// * `tickers` - Comma-separated ticker symbols (e.g., `"AAPL,MSFT"`)
90/// * `limit` - Number of results
91pub async fn stock_news(tickers: &str, limit: u32) -> Result<Vec<StockNews>> {
92    let client = build_client()?;
93    let limit_str = limit.to_string();
94    client
95        .get(
96            "/api/v3/stock_news",
97            &[("tickers", tickers), ("limit", &limit_str)],
98        )
99        .await
100}
101
102/// Fetch FMP editorial articles.
103///
104/// * `page` - Page number (0-indexed)
105/// * `size` - Page size
106pub async fn fmp_articles(page: u32, size: u32) -> Result<FmpArticlesResponse> {
107    let client = build_client()?;
108    let page_str = page.to_string();
109    let size_str = size.to_string();
110    client
111        .get(
112            "/api/v3/fmp/articles",
113            &[("page", &*page_str), ("size", &*size_str)],
114        )
115        .await
116}
117
118/// Fetch press releases for a symbol.
119pub async fn press_releases(symbol: &str, limit: u32) -> Result<Vec<PressRelease>> {
120    let client = build_client()?;
121    let path = format!("/api/v3/press-releases/{}", encode_path_segment(symbol));
122    let limit_str = limit.to_string();
123    client.get(&path, &[("limit", &*limit_str)]).await
124}
125
126/// Fetch crypto news.
127pub async fn crypto_news(limit: u32) -> Result<Vec<StockNews>> {
128    let client = build_client()?;
129    let size_str = limit.to_string();
130    client
131        .get("/api/v4/crypto_news", &[("page", "0"), ("size", &size_str)])
132        .await
133}
134
135/// Fetch forex news.
136pub async fn forex_news(limit: u32) -> Result<Vec<StockNews>> {
137    let client = build_client()?;
138    let size_str = limit.to_string();
139    client
140        .get("/api/v4/forex_news", &[("page", "0"), ("size", &size_str)])
141        .await
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[tokio::test]
149    async fn test_stock_news_mock() {
150        let mut server = mockito::Server::new_async().await;
151        let _mock = server
152            .mock("GET", "/api/v3/stock_news")
153            .match_query(mockito::Matcher::AllOf(vec![
154                mockito::Matcher::UrlEncoded("apikey".into(), "test-key".into()),
155                mockito::Matcher::UrlEncoded("tickers".into(), "AAPL".into()),
156                mockito::Matcher::UrlEncoded("limit".into(), "5".into()),
157            ]))
158            .with_status(200)
159            .with_body(
160                serde_json::json!([
161                    {
162                        "symbol": "AAPL",
163                        "publishedDate": "2024-01-15 12:00:00",
164                        "title": "Apple Reports Record Quarter",
165                        "image": "https://example.com/image.jpg",
166                        "site": "Reuters",
167                        "text": "Apple Inc. reported record quarterly earnings...",
168                        "url": "https://example.com/article"
169                    }
170                ])
171                .to_string(),
172            )
173            .create_async()
174            .await;
175
176        let client = super::super::build_test_client(&server.url()).unwrap();
177        let resp: Vec<StockNews> = client
178            .get("/api/v3/stock_news", &[("tickers", "AAPL"), ("limit", "5")])
179            .await
180            .unwrap();
181        assert_eq!(resp.len(), 1);
182        assert_eq!(resp[0].symbol.as_deref(), Some("AAPL"));
183        assert_eq!(resp[0].site.as_deref(), Some("Reuters"));
184    }
185
186    #[tokio::test]
187    async fn test_press_releases_mock() {
188        let mut server = mockito::Server::new_async().await;
189        let _mock = server
190            .mock("GET", "/api/v3/press-releases/AAPL")
191            .match_query(mockito::Matcher::AllOf(vec![
192                mockito::Matcher::UrlEncoded("apikey".into(), "test-key".into()),
193                mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
194            ]))
195            .with_status(200)
196            .with_body(
197                serde_json::json!([
198                    {
199                        "symbol": "AAPL",
200                        "date": "2024-01-15",
201                        "title": "Apple Announces New Product",
202                        "text": "Cupertino, CA -- Apple today announced..."
203                    }
204                ])
205                .to_string(),
206            )
207            .create_async()
208            .await;
209
210        let client = super::super::build_test_client(&server.url()).unwrap();
211        let resp: Vec<PressRelease> = client
212            .get("/api/v3/press-releases/AAPL", &[("limit", "10")])
213            .await
214            .unwrap();
215        assert_eq!(resp.len(), 1);
216        assert_eq!(
217            resp[0].title.as_deref(),
218            Some("Apple Announces New Product")
219        );
220    }
221}