use crate::adapters::common::encode_path_segment;
use crate::error::{FinanceError, Result};
use serde::{Deserialize, Serialize};
use super::super::build_client;
use super::super::models::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ForexLastQuote {
pub bid: Option<f64>,
pub ask: Option<f64>,
pub exchange: Option<i32>,
pub timestamp: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ForexQuoteResponse {
pub status: Option<String>,
pub request_id: Option<String>,
pub last: Option<ForexLastQuote>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ConversionLast {
pub bid: Option<f64>,
pub ask: Option<f64>,
pub exchange: Option<i32>,
pub timestamp: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CurrencyConversion {
pub status: Option<String>,
pub converted: Option<f64>,
pub from: Option<String>,
pub to: Option<String>,
#[serde(rename = "initialAmount")]
pub initial_amount: Option<f64>,
pub last: Option<ConversionLast>,
}
pub async fn forex_last_quote(from: &str, to: &str) -> Result<ForexQuoteResponse> {
let client = build_client()?;
let path = format!(
"/v1/last_quote/currencies/{}/{}",
encode_path_segment(from),
encode_path_segment(to)
);
let json = client.get_raw(&path, &[]).await?;
serde_json::from_value(json).map_err(|e| FinanceError::ResponseStructureError {
field: "forex_last_quote".to_string(),
context: format!("Failed to parse forex last quote response: {e}"),
})
}
pub async fn forex_quotes(
ticker: &str,
params: &[(&str, &str)],
) -> Result<PaginatedResponse<Quote>> {
let client = build_client()?;
let path = format!("/v3/quotes/{}", encode_path_segment(ticker));
client.get(&path, params).await
}
pub async fn currency_conversion(from: &str, to: &str, amount: f64) -> Result<CurrencyConversion> {
let client = build_client()?;
let path = format!(
"/v1/conversion/{}/{}",
encode_path_segment(from),
encode_path_segment(to)
);
let amount_str = amount.to_string();
let params = [("amount", amount_str.as_str())];
let json = client.get_raw(&path, ¶ms).await?;
serde_json::from_value(json).map_err(|e| FinanceError::ResponseStructureError {
field: "currency_conversion".to_string(),
context: format!("Failed to parse currency conversion response: {e}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_forex_last_quote_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/v1/last_quote/currencies/EUR/USD")
.match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
"apiKey".into(),
"test-key".into(),
)]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"status": "OK",
"request_id": "abc123",
"last": {
"bid": 1.1050,
"ask": 1.1052,
"exchange": 48,
"timestamp": 1705363200000_i64
}
})
.to_string(),
)
.create_async()
.await;
let client = super::super::super::build_test_client(&server.url()).unwrap();
let json = client
.get_raw("/v1/last_quote/currencies/EUR/USD", &[])
.await
.unwrap();
let resp: ForexQuoteResponse = serde_json::from_value(json).unwrap();
assert_eq!(resp.status.as_deref(), Some("OK"));
let last = resp.last.unwrap();
assert!((last.bid.unwrap() - 1.1050).abs() < 0.0001);
assert!((last.ask.unwrap() - 1.1052).abs() < 0.0001);
assert_eq!(last.exchange.unwrap(), 48);
}
#[tokio::test]
async fn test_forex_quotes_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/v3/quotes/C:EURUSD")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("apiKey".into(), "test-key".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"request_id": "abc123",
"status": "OK",
"results": [
{ "ask_price": 1.1052, "bid_price": 1.1050, "ask_size": 1000.0, "bid_size": 1500.0, "sip_timestamp": 1705363200000000000_i64 },
{ "ask_price": 1.1053, "bid_price": 1.1051, "ask_size": 800.0, "bid_size": 1200.0, "sip_timestamp": 1705363200100000000_i64 }
]
})
.to_string(),
)
.create_async()
.await;
let client = super::super::super::build_test_client(&server.url()).unwrap();
let resp: PaginatedResponse<Quote> = client.get("/v3/quotes/C:EURUSD", &[]).await.unwrap();
let quotes = resp.results.unwrap();
assert_eq!(quotes.len(), 2);
assert!((quotes[0].ask_price.unwrap() - 1.1052).abs() < 0.0001);
assert!((quotes[0].bid_price.unwrap() - 1.1050).abs() < 0.0001);
}
#[tokio::test]
async fn test_currency_conversion_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/v1/conversion/EUR/USD")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("apiKey".into(), "test-key".into()),
mockito::Matcher::UrlEncoded("amount".into(), "100".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"status": "OK",
"converted": 110.50,
"from": "EUR",
"to": "USD",
"initialAmount": 100.0,
"last": {
"bid": 1.1050,
"ask": 1.1052,
"exchange": 48,
"timestamp": 1705363200000_i64
}
})
.to_string(),
)
.create_async()
.await;
let client = super::super::super::build_test_client(&server.url()).unwrap();
let json = client
.get_raw("/v1/conversion/EUR/USD", &[("amount", "100")])
.await
.unwrap();
let resp: CurrencyConversion = serde_json::from_value(json).unwrap();
assert_eq!(resp.status.as_deref(), Some("OK"));
assert!((resp.converted.unwrap() - 110.50).abs() < 0.01);
assert_eq!(resp.from.as_deref(), Some("EUR"));
assert_eq!(resp.to.as_deref(), Some("USD"));
assert!((resp.initial_amount.unwrap() - 100.0).abs() < 0.01);
let last = resp.last.unwrap();
assert!((last.bid.unwrap() - 1.1050).abs() < 0.0001);
}
}