Skip to main content

finance_query/adapters/polygon/forex/
quotes.rs

1//! Forex quote endpoints: last quote, historical quotes, currency conversion.
2
3use crate::adapters::common::encode_path_segment;
4use crate::error::{FinanceError, Result};
5use serde::{Deserialize, Serialize};
6
7use super::super::build_client;
8use super::super::models::*;
9
10// ============================================================================
11// Response types
12// ============================================================================
13
14/// Last forex quote data.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct ForexLastQuote {
18    /// Bid price.
19    pub bid: Option<f64>,
20    /// Ask price.
21    pub ask: Option<f64>,
22    /// Exchange ID.
23    pub exchange: Option<i32>,
24    /// Unix millisecond timestamp.
25    pub timestamp: Option<i64>,
26}
27
28/// Response for the last forex quote endpoint.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[non_exhaustive]
31pub struct ForexQuoteResponse {
32    /// Response status.
33    pub status: Option<String>,
34    /// Request ID.
35    pub request_id: Option<String>,
36    /// The last quote.
37    pub last: Option<ForexLastQuote>,
38}
39
40/// Currency conversion last price data.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[non_exhaustive]
43pub struct ConversionLast {
44    /// Bid price.
45    pub bid: Option<f64>,
46    /// Ask price.
47    pub ask: Option<f64>,
48    /// Exchange ID.
49    pub exchange: Option<i32>,
50    /// Unix millisecond timestamp.
51    pub timestamp: Option<i64>,
52}
53
54/// Response for the currency conversion endpoint.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[non_exhaustive]
57pub struct CurrencyConversion {
58    /// Response status.
59    pub status: Option<String>,
60    /// The converted amount.
61    pub converted: Option<f64>,
62    /// From currency code.
63    pub from: Option<String>,
64    /// To currency code.
65    pub to: Option<String>,
66    /// Initial amount before conversion.
67    #[serde(rename = "initialAmount")]
68    pub initial_amount: Option<f64>,
69    /// Last quote used for conversion.
70    pub last: Option<ConversionLast>,
71}
72
73// ============================================================================
74// Public API functions
75// ============================================================================
76
77/// Fetch the last quote for a forex currency pair.
78///
79/// # Arguments
80///
81/// * `from` - Base currency code (e.g., `"EUR"`)
82/// * `to` - Quote currency code (e.g., `"USD"`)
83pub async fn forex_last_quote(from: &str, to: &str) -> Result<ForexQuoteResponse> {
84    let client = build_client()?;
85    let path = format!(
86        "/v1/last_quote/currencies/{}/{}",
87        encode_path_segment(from),
88        encode_path_segment(to)
89    );
90    let json = client.get_raw(&path, &[]).await?;
91    serde_json::from_value(json).map_err(|e| FinanceError::ResponseStructureError {
92        field: "forex_last_quote".to_string(),
93        context: format!("Failed to parse forex last quote response: {e}"),
94    })
95}
96
97/// Fetch historical quotes for a forex ticker.
98///
99/// # Arguments
100///
101/// * `ticker` - Forex ticker symbol with `C:` prefix (e.g., `"C:EURUSD"`)
102/// * `params` - Optional query params: `timestamp`, `order`, `limit`, `sort`
103pub async fn forex_quotes(
104    ticker: &str,
105    params: &[(&str, &str)],
106) -> Result<PaginatedResponse<Quote>> {
107    let client = build_client()?;
108    let path = format!("/v3/quotes/{}", encode_path_segment(ticker));
109    client.get(&path, params).await
110}
111
112/// Convert a currency amount from one currency to another.
113///
114/// # Arguments
115///
116/// * `from` - Base currency code (e.g., `"EUR"`)
117/// * `to` - Quote currency code (e.g., `"USD"`)
118/// * `amount` - Amount to convert
119pub async fn currency_conversion(from: &str, to: &str, amount: f64) -> Result<CurrencyConversion> {
120    let client = build_client()?;
121    let path = format!(
122        "/v1/conversion/{}/{}",
123        encode_path_segment(from),
124        encode_path_segment(to)
125    );
126    let amount_str = amount.to_string();
127    let params = [("amount", amount_str.as_str())];
128    let json = client.get_raw(&path, &params).await?;
129    serde_json::from_value(json).map_err(|e| FinanceError::ResponseStructureError {
130        field: "currency_conversion".to_string(),
131        context: format!("Failed to parse currency conversion response: {e}"),
132    })
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[tokio::test]
140    async fn test_forex_last_quote_mock() {
141        let mut server = mockito::Server::new_async().await;
142        let _mock = server
143            .mock("GET", "/v1/last_quote/currencies/EUR/USD")
144            .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
145                "apiKey".into(),
146                "test-key".into(),
147            )]))
148            .with_status(200)
149            .with_header("content-type", "application/json")
150            .with_body(
151                serde_json::json!({
152                    "status": "OK",
153                    "request_id": "abc123",
154                    "last": {
155                        "bid": 1.1050,
156                        "ask": 1.1052,
157                        "exchange": 48,
158                        "timestamp": 1705363200000_i64
159                    }
160                })
161                .to_string(),
162            )
163            .create_async()
164            .await;
165
166        let client = super::super::super::build_test_client(&server.url()).unwrap();
167        let json = client
168            .get_raw("/v1/last_quote/currencies/EUR/USD", &[])
169            .await
170            .unwrap();
171
172        let resp: ForexQuoteResponse = serde_json::from_value(json).unwrap();
173        assert_eq!(resp.status.as_deref(), Some("OK"));
174        let last = resp.last.unwrap();
175        assert!((last.bid.unwrap() - 1.1050).abs() < 0.0001);
176        assert!((last.ask.unwrap() - 1.1052).abs() < 0.0001);
177        assert_eq!(last.exchange.unwrap(), 48);
178    }
179
180    #[tokio::test]
181    async fn test_forex_quotes_mock() {
182        let mut server = mockito::Server::new_async().await;
183        let _mock = server
184            .mock("GET", "/v3/quotes/C:EURUSD")
185            .match_query(mockito::Matcher::AllOf(vec![
186                mockito::Matcher::UrlEncoded("apiKey".into(), "test-key".into()),
187            ]))
188            .with_status(200)
189            .with_header("content-type", "application/json")
190            .with_body(
191                serde_json::json!({
192                    "request_id": "abc123",
193                    "status": "OK",
194                    "results": [
195                        { "ask_price": 1.1052, "bid_price": 1.1050, "ask_size": 1000.0, "bid_size": 1500.0, "sip_timestamp": 1705363200000000000_i64 },
196                        { "ask_price": 1.1053, "bid_price": 1.1051, "ask_size": 800.0, "bid_size": 1200.0, "sip_timestamp": 1705363200100000000_i64 }
197                    ]
198                })
199                .to_string(),
200            )
201            .create_async()
202            .await;
203
204        let client = super::super::super::build_test_client(&server.url()).unwrap();
205        let resp: PaginatedResponse<Quote> = client.get("/v3/quotes/C:EURUSD", &[]).await.unwrap();
206        let quotes = resp.results.unwrap();
207        assert_eq!(quotes.len(), 2);
208        assert!((quotes[0].ask_price.unwrap() - 1.1052).abs() < 0.0001);
209        assert!((quotes[0].bid_price.unwrap() - 1.1050).abs() < 0.0001);
210    }
211
212    #[tokio::test]
213    async fn test_currency_conversion_mock() {
214        let mut server = mockito::Server::new_async().await;
215        let _mock = server
216            .mock("GET", "/v1/conversion/EUR/USD")
217            .match_query(mockito::Matcher::AllOf(vec![
218                mockito::Matcher::UrlEncoded("apiKey".into(), "test-key".into()),
219                mockito::Matcher::UrlEncoded("amount".into(), "100".into()),
220            ]))
221            .with_status(200)
222            .with_header("content-type", "application/json")
223            .with_body(
224                serde_json::json!({
225                    "status": "OK",
226                    "converted": 110.50,
227                    "from": "EUR",
228                    "to": "USD",
229                    "initialAmount": 100.0,
230                    "last": {
231                        "bid": 1.1050,
232                        "ask": 1.1052,
233                        "exchange": 48,
234                        "timestamp": 1705363200000_i64
235                    }
236                })
237                .to_string(),
238            )
239            .create_async()
240            .await;
241
242        let client = super::super::super::build_test_client(&server.url()).unwrap();
243        let json = client
244            .get_raw("/v1/conversion/EUR/USD", &[("amount", "100")])
245            .await
246            .unwrap();
247
248        let resp: CurrencyConversion = serde_json::from_value(json).unwrap();
249        assert_eq!(resp.status.as_deref(), Some("OK"));
250        assert!((resp.converted.unwrap() - 110.50).abs() < 0.01);
251        assert_eq!(resp.from.as_deref(), Some("EUR"));
252        assert_eq!(resp.to.as_deref(), Some("USD"));
253        assert!((resp.initial_amount.unwrap() - 100.0).abs() < 0.01);
254        let last = resp.last.unwrap();
255        assert!((last.bid.unwrap() - 1.1050).abs() < 0.0001);
256    }
257}