Skip to main content

finance_query/adapters/fmp/
prices.rs

1//! FMP price and historical data endpoints.
2
3use serde::{Deserialize, Serialize};
4
5use crate::adapters::common::encode_path_segment;
6use crate::error::Result;
7
8use super::models::{FmpQuote, HistoricalPriceResponse, IntradayPrice};
9
10// ============================================================================
11// Additional response types
12// ============================================================================
13
14/// Stock price change from FMP.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct StockPriceChange {
18    /// Ticker symbol.
19    pub symbol: Option<String>,
20    /// 1-day price change.
21    #[serde(rename = "1D")]
22    pub one_day: Option<f64>,
23    /// 5-day price change.
24    #[serde(rename = "5D")]
25    pub five_day: Option<f64>,
26    /// 1-month price change.
27    #[serde(rename = "1M")]
28    pub one_month: Option<f64>,
29    /// 3-month price change.
30    #[serde(rename = "3M")]
31    pub three_month: Option<f64>,
32    /// 6-month price change.
33    #[serde(rename = "6M")]
34    pub six_month: Option<f64>,
35    /// Year-to-date price change.
36    pub ytd: Option<f64>,
37    /// 1-year price change.
38    #[serde(rename = "1Y")]
39    pub one_year: Option<f64>,
40    /// 3-year price change.
41    #[serde(rename = "3Y")]
42    pub three_year: Option<f64>,
43    /// 5-year price change.
44    #[serde(rename = "5Y")]
45    pub five_year: Option<f64>,
46    /// 10-year price change.
47    #[serde(rename = "10Y")]
48    pub ten_year: Option<f64>,
49    /// Max price change.
50    pub max: Option<f64>,
51}
52
53/// Optional parameters for historical price queries.
54#[derive(Debug, Clone, Default)]
55pub struct HistoricalPriceParams {
56    /// Start date (YYYY-MM-DD).
57    pub from: Option<String>,
58    /// End date (YYYY-MM-DD).
59    pub to: Option<String>,
60}
61
62// ============================================================================
63// Query functions
64// ============================================================================
65
66/// Fetch real-time quote for a symbol.
67pub async fn quote(symbol: &str) -> Result<Vec<FmpQuote>> {
68    let client = super::build_client()?;
69    client
70        .get(
71            &format!("/api/v3/quote/{}", encode_path_segment(symbol)),
72            &[],
73        )
74        .await
75}
76
77/// Fetch real-time quotes for multiple symbols (comma-separated).
78pub async fn batch_quote(symbols: &[&str]) -> Result<Vec<FmpQuote>> {
79    let client = super::build_client()?;
80    let joined = symbols.join(",");
81    client
82        .get(
83            &format!("/api/v3/quote/{}", encode_path_segment(&joined)),
84            &[],
85        )
86        .await
87}
88
89/// Fetch stock price change percentages for a symbol.
90pub async fn stock_price(symbol: &str) -> Result<Vec<StockPriceChange>> {
91    let client = super::build_client()?;
92    client
93        .get(
94            &format!("/api/v3/stock-price-change/{}", encode_path_segment(symbol)),
95            &[],
96        )
97        .await
98}
99
100/// Fetch historical daily prices for a symbol.
101pub async fn historical_price_daily(
102    symbol: &str,
103    params: Option<HistoricalPriceParams>,
104) -> Result<HistoricalPriceResponse> {
105    let client = super::build_client()?;
106    let p = params.unwrap_or_default();
107    let mut query_params: Vec<(&str, &str)> = Vec::new();
108    if let Some(ref from) = p.from {
109        query_params.push(("from", from));
110    }
111    if let Some(ref to) = p.to {
112        query_params.push(("to", to));
113    }
114    client
115        .get(
116            &format!(
117                "/api/v3/historical-price-full/{}",
118                encode_path_segment(symbol)
119            ),
120            &query_params,
121        )
122        .await
123}
124
125/// Fetch intraday historical prices for a symbol.
126///
127/// Valid intervals: "1min", "5min", "15min", "30min", "1hour", "4hour".
128pub async fn historical_price_intraday(
129    symbol: &str,
130    interval: &str,
131    params: Option<HistoricalPriceParams>,
132) -> Result<Vec<IntradayPrice>> {
133    let client = super::build_client()?;
134    let p = params.unwrap_or_default();
135    let mut query_params: Vec<(&str, &str)> = Vec::new();
136    if let Some(ref from) = p.from {
137        query_params.push(("from", from));
138    }
139    if let Some(ref to) = p.to {
140        query_params.push(("to", to));
141    }
142    client
143        .get(
144            &format!(
145                "/api/v3/historical-chart/{}/{}",
146                encode_path_segment(interval),
147                encode_path_segment(symbol)
148            ),
149            &query_params,
150        )
151        .await
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[tokio::test]
159    async fn test_quote_mock() {
160        let mut server = mockito::Server::new_async().await;
161        let _mock = server
162            .mock("GET", "/api/v3/quote/AAPL")
163            .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
164                "apikey".into(),
165                "test-key".into(),
166            )]))
167            .with_status(200)
168            .with_body(
169                serde_json::json!([{
170                    "symbol": "AAPL",
171                    "name": "Apple Inc.",
172                    "price": 178.72,
173                    "change": 2.15,
174                    "changesPercentage": 1.22,
175                    "dayLow": 176.21,
176                    "dayHigh": 179.63,
177                    "yearLow": 124.17,
178                    "yearHigh": 199.62,
179                    "marketCap": 2794000000000_f64,
180                    "volume": 58405568,
181                    "avgVolume": 54638267,
182                    "open": 177.09,
183                    "previousClose": 176.57,
184                    "eps": 6.42,
185                    "pe": 27.84,
186                    "timestamp": 1701460800,
187                    "exchange": "NASDAQ"
188                }])
189                .to_string(),
190            )
191            .create_async()
192            .await;
193
194        let client = super::super::build_test_client(&server.url()).unwrap();
195        let result: Vec<FmpQuote> = client.get("/api/v3/quote/AAPL", &[]).await.unwrap();
196
197        assert_eq!(result.len(), 1);
198        assert_eq!(result[0].symbol, "AAPL");
199        assert_eq!(result[0].name.as_deref(), Some("Apple Inc."));
200        assert_eq!(result[0].price, Some(178.72));
201        assert_eq!(result[0].pe, Some(27.84));
202    }
203
204    #[tokio::test]
205    async fn test_historical_price_daily_mock() {
206        let mut server = mockito::Server::new_async().await;
207        let _mock = server
208            .mock("GET", "/api/v3/historical-price-full/AAPL")
209            .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
210                "apikey".into(),
211                "test-key".into(),
212            )]))
213            .with_status(200)
214            .with_body(
215                serde_json::json!({
216                    "symbol": "AAPL",
217                    "historical": [
218                        {
219                            "date": "2024-01-02",
220                            "open": 187.15,
221                            "high": 188.44,
222                            "low": 183.89,
223                            "close": 185.64,
224                            "adjClose": 184.96,
225                            "volume": 82488700,
226                            "unadjustedVolume": 82488700,
227                            "change": -1.51,
228                            "changePercent": -0.8068,
229                            "vwap": 185.99,
230                            "label": "January 02, 2024",
231                            "changeOverTime": -0.008068
232                        },
233                        {
234                            "date": "2024-01-03",
235                            "open": 184.22,
236                            "high": 185.88,
237                            "low": 183.43,
238                            "close": 184.25,
239                            "adjClose": 183.57,
240                            "volume": 58414500,
241                            "unadjustedVolume": 58414500,
242                            "change": 0.03,
243                            "changePercent": 0.0163,
244                            "vwap": 184.52,
245                            "label": "January 03, 2024",
246                            "changeOverTime": 0.000163
247                        }
248                    ]
249                })
250                .to_string(),
251            )
252            .create_async()
253            .await;
254
255        let client = super::super::build_test_client(&server.url()).unwrap();
256        let result: HistoricalPriceResponse = client
257            .get("/api/v3/historical-price-full/AAPL", &[])
258            .await
259            .unwrap();
260
261        assert_eq!(result.symbol.as_deref(), Some("AAPL"));
262        assert_eq!(result.historical.len(), 2);
263        assert_eq!(result.historical[0].date.as_deref(), Some("2024-01-02"));
264        assert_eq!(result.historical[0].close, Some(185.64));
265    }
266
267    #[tokio::test]
268    async fn test_intraday_price_mock() {
269        let mut server = mockito::Server::new_async().await;
270        let _mock = server
271            .mock("GET", "/api/v3/historical-chart/5min/AAPL")
272            .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
273                "apikey".into(),
274                "test-key".into(),
275            )]))
276            .with_status(200)
277            .with_body(
278                serde_json::json!([
279                    {
280                        "date": "2024-01-02 09:30:00",
281                        "open": 187.15,
282                        "high": 187.44,
283                        "low": 186.89,
284                        "close": 187.20,
285                        "volume": 1234567
286                    },
287                    {
288                        "date": "2024-01-02 09:35:00",
289                        "open": 187.20,
290                        "high": 187.50,
291                        "low": 187.10,
292                        "close": 187.35,
293                        "volume": 987654
294                    }
295                ])
296                .to_string(),
297            )
298            .create_async()
299            .await;
300
301        let client = super::super::build_test_client(&server.url()).unwrap();
302        let result: Vec<IntradayPrice> = client
303            .get("/api/v3/historical-chart/5min/AAPL", &[])
304            .await
305            .unwrap();
306
307        assert_eq!(result.len(), 2);
308        assert_eq!(result[0].date.as_deref(), Some("2024-01-02 09:30:00"));
309        assert_eq!(result[0].close, Some(187.20));
310    }
311}