use crate::error::Result;
use crate::adapters::fmp::models::{
FmpQuoteDTO, HistoricalPriceDTO, HistoricalPriceResponseDTO, IntradayPriceDTO,
};
#[derive(Debug, Clone, Default)]
pub struct HistoricalPriceParams {
pub from: Option<String>,
pub to: Option<String>,
}
fn quote_to_canonical(
symbol: &str,
quotes: &[FmpQuoteDTO],
) -> crate::models::quote::QuoteSummaryResponse {
use crate::models::quote::{FormattedValue, Price, QuoteSummaryResponse};
let q = quotes.first();
let price = Price {
regular_market_price: q.and_then(|q| q.price).map(|v| FormattedValue {
raw: Some(v),
fmt: None,
long_fmt: None,
}),
regular_market_change_percent: q.and_then(|q| q.changes_percentage).map(|v| {
FormattedValue {
raw: Some(v),
fmt: None,
long_fmt: None,
}
}),
regular_market_volume: q
.and_then(|q| q.volume.map(|v| v as i64))
.map(|v| FormattedValue {
raw: Some(v),
fmt: None,
long_fmt: None,
}),
regular_market_day_high: q.and_then(|q| q.day_high).map(|v| FormattedValue {
raw: Some(v),
fmt: None,
long_fmt: None,
}),
regular_market_day_low: q.and_then(|q| q.day_low).map(|v| FormattedValue {
raw: Some(v),
fmt: None,
long_fmt: None,
}),
market_cap: q
.and_then(|q| q.market_cap.map(|v| v as i64))
.map(|v| FormattedValue {
raw: Some(v),
fmt: None,
long_fmt: None,
}),
exchange: q.and_then(|q| q.exchange.clone()),
..Default::default()
};
QuoteSummaryResponse {
symbol: symbol.to_string(),
price: Some(price),
..Default::default()
}
}
pub async fn fetch_canonical_quote(
symbol: &str,
) -> Result<crate::models::quote::QuoteSummaryResponse> {
let quotes = quote(symbol).await?;
Ok(quote_to_canonical(symbol, "es))
}
pub async fn fetch_canonical_quotes_batch(
symbols: &[&str],
) -> Result<Vec<(String, crate::models::quote::QuoteSummaryResponse)>> {
let quotes = batch_quote(symbols).await?;
Ok(quotes
.iter()
.map(|q| {
(
q.symbol.clone(),
quote_to_canonical(&q.symbol, std::slice::from_ref(q)),
)
})
.collect())
}
fn historical_to_candles(historical: Vec<HistoricalPriceDTO>) -> Vec<crate::models::chart::Candle> {
let mut candles: Vec<crate::models::chart::Candle> = historical
.into_iter()
.filter_map(|r| {
let ts = chrono::NaiveDate::parse_from_str(r.date.as_deref()?, "%Y-%m-%d")
.ok()?
.and_hms_opt(0, 0, 0)?
.and_utc()
.timestamp();
Some(crate::models::chart::Candle {
timestamp: ts,
open: r.open?,
high: r.high?,
low: r.low?,
close: r.close?,
volume: r.volume.map(|v| v as i64).unwrap_or(0),
adj_close: None,
provider_id: Some(crate::providers::Provider::Fmp),
})
})
.collect();
candles.sort_unstable_by_key(|c| c.timestamp);
candles
}
fn intraday_to_candles(intraday: Vec<IntradayPriceDTO>) -> Vec<crate::models::chart::Candle> {
let mut candles: Vec<crate::models::chart::Candle> = intraday
.into_iter()
.filter_map(|r| {
let ts = chrono::NaiveDateTime::parse_from_str(r.date.as_deref()?, "%Y-%m-%d %H:%M:%S")
.ok()?
.and_utc()
.timestamp();
Some(crate::models::chart::Candle {
timestamp: ts,
open: r.open?,
high: r.high?,
low: r.low?,
close: r.close?,
volume: r.volume.map(|v| v as i64).unwrap_or(0),
adj_close: None,
provider_id: Some(crate::providers::Provider::Fmp),
})
})
.collect();
candles.sort_unstable_by_key(|c| c.timestamp);
candles
}
pub async fn fetch_daily_chart_candles(
symbol: &str,
params: Option<HistoricalPriceParams>,
) -> Result<Vec<crate::models::chart::Candle>> {
let resp = historical_price_daily(symbol, params).await?;
Ok(historical_to_candles(resp.historical))
}
pub async fn fetch_intraday_chart_candles(
symbol: &str,
interval: &str,
params: Option<HistoricalPriceParams>,
) -> Result<Vec<crate::models::chart::Candle>> {
let points = historical_price_intraday(symbol, interval, params).await?;
Ok(intraday_to_candles(points))
}
pub async fn quote(symbol: &str) -> Result<Vec<FmpQuoteDTO>> {
let client = crate::adapters::fmp::build_client()?;
client.get("/stable/quote", &[("symbol", symbol)]).await
}
pub async fn batch_quote(symbols: &[&str]) -> Result<Vec<FmpQuoteDTO>> {
let client = crate::adapters::fmp::build_client()?;
let joined = symbols.join(",");
client
.get("/stable/batch-quote", &[("symbols", &joined)])
.await
}
pub async fn historical_price_daily(
symbol: &str,
params: Option<HistoricalPriceParams>,
) -> Result<HistoricalPriceResponseDTO> {
let client = crate::adapters::fmp::build_client()?;
let p = params.unwrap_or_default();
let mut query_params: Vec<(&str, &str)> = Vec::new();
if let Some(ref from) = p.from {
query_params.push(("from", from));
}
if let Some(ref to) = p.to {
query_params.push(("to", to));
}
query_params.push(("symbol", symbol));
let historical = client
.get("/stable/historical-price-eod/full", &query_params)
.await?;
Ok(HistoricalPriceResponseDTO {
symbol: Some(symbol.to_string()),
historical,
})
}
pub async fn historical_price_intraday(
symbol: &str,
interval: &str,
params: Option<HistoricalPriceParams>,
) -> Result<Vec<IntradayPriceDTO>> {
let client = crate::adapters::fmp::build_client()?;
let p = params.unwrap_or_default();
let mut query_params: Vec<(&str, &str)> = Vec::new();
if let Some(ref from) = p.from {
query_params.push(("from", from));
}
if let Some(ref to) = p.to {
query_params.push(("to", to));
}
query_params.push(("symbol", symbol));
client
.get(
&format!("/stable/historical-chart/{interval}"),
&query_params,
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_quote_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/stable/quote")
.match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
"apikey".into(),
"test-key".into(),
)]))
.with_status(200)
.with_body(
r#"[{
"symbol": "AAPL",
"name": "Apple Inc.",
"price": 178.72,
"changePercentage": 1.22,
"change": 2.15,
"volume": 58405568,
"dayLow": 176.21,
"dayHigh": 179.63,
"yearHigh": 199.62,
"yearLow": 124.17,
"marketCap": 2794000000000,
"priceAvg50": 172.40,
"priceAvg200": 165.03,
"exchange": "NASDAQ",
"open": 177.09,
"previousClose": 176.57,
"timestamp": 1701460800
}]"#,
)
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let result: Vec<FmpQuoteDTO> = client.get("/stable/quote", &[]).await.unwrap();
let quote = &result[0];
assert_eq!(quote.symbol, "AAPL");
assert_eq!(quote.name.as_deref(), Some("Apple Inc."));
assert_eq!(quote.price, Some(178.72));
assert_eq!(quote.changes_percentage, Some(1.22));
assert_eq!(quote.market_cap, Some(2_794_000_000_000.0));
assert_eq!(quote.exchange.as_deref(), Some("NASDAQ"));
let canonical = quote_to_canonical("AAPL", &result);
let price = canonical.price.unwrap();
assert_eq!(price.regular_market_price.and_then(|v| v.raw), Some(178.72));
assert_eq!(
price.market_cap.and_then(|v| v.raw),
Some(2_794_000_000_000)
);
assert_eq!(
price.regular_market_change_percent.and_then(|v| v.raw),
Some(1.22)
);
}
#[tokio::test]
async fn test_historical_price_daily_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/stable/historical-price-eod/full")
.match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
"apikey".into(),
"test-key".into(),
)]))
.with_status(200)
.with_body(
serde_json::json!([
{
"date": "2024-01-02",
"open": 187.15,
"high": 188.44,
"low": 183.89,
"close": 185.64,
"adjClose": 184.96,
"volume": 82488700,
"unadjustedVolume": 82488700,
"change": -1.51,
"changePercent": -0.8068,
"vwap": 185.99,
"label": "January 02, 2024",
"changeOverTime": -0.008068
},
{
"date": "2024-01-03",
"open": 184.22,
"high": 185.88,
"low": 183.43,
"close": 184.25,
"adjClose": 183.57,
"volume": 58414500,
"unadjustedVolume": 58414500,
"change": 0.03,
"changePercent": 0.0163,
"vwap": 184.52,
"label": "January 03, 2024",
"changeOverTime": 0.000163
}
])
.to_string(),
)
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let result: Vec<HistoricalPriceDTO> = client
.get("/stable/historical-price-eod/full", &[])
.await
.unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].date.as_deref(), Some("2024-01-02"));
assert_eq!(result[0].close, Some(185.64));
}
#[tokio::test]
async fn test_intraday_price_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/stable/historical-chart/5min")
.match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
"apikey".into(),
"test-key".into(),
)]))
.with_status(200)
.with_body(
serde_json::json!([
{
"date": "2024-01-02 09:30:00",
"open": 187.15,
"high": 187.44,
"low": 186.89,
"close": 187.20,
"volume": 1234567
},
{
"date": "2024-01-02 09:35:00",
"open": 187.20,
"high": 187.50,
"low": 187.10,
"close": 187.35,
"volume": 987654
}
])
.to_string(),
)
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let result: Vec<IntradayPriceDTO> = client
.get("/stable/historical-chart/5min", &[])
.await
.unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].date.as_deref(), Some("2024-01-02 09:30:00"));
assert_eq!(result[0].close, Some(187.20));
}
#[test]
fn quote_to_canonical_maps_price_fields() {
let quotes: Vec<FmpQuoteDTO> = serde_json::from_value(serde_json::json!([{
"symbol": "AAPL",
"price": 178.72,
"changesPercentage": 1.22,
"dayLow": 176.21,
"dayHigh": 179.63,
"marketCap": 2794000000000_f64,
"volume": 58405568,
"exchange": "NASDAQ"
}]))
.unwrap();
let resp = quote_to_canonical("AAPL", "es);
assert_eq!(resp.symbol, "AAPL");
let price = resp.price.expect("price block present");
assert_eq!(price.regular_market_price.and_then(|v| v.raw), Some(178.72));
assert_eq!(
price.regular_market_change_percent.and_then(|v| v.raw),
Some(1.22)
);
assert_eq!(
price.regular_market_volume.and_then(|v| v.raw),
Some(58405568)
);
assert_eq!(
price.market_cap.and_then(|v| v.raw),
Some(2_794_000_000_000)
);
assert_eq!(price.exchange.as_deref(), Some("NASDAQ"));
}
#[test]
fn quote_to_canonical_empty_yields_no_raw_values() {
let resp = quote_to_canonical("AAPL", &[]);
assert_eq!(resp.symbol, "AAPL");
let price = resp.price.expect("price block present even when empty");
assert!(price.regular_market_price.is_none());
assert!(price.exchange.is_none());
}
#[test]
fn historical_to_candles_parses_dates_and_tags_provider() {
let resp: HistoricalPriceResponseDTO = serde_json::from_value(serde_json::json!({
"symbol": "AAPL",
"historical": [
{"date": "2024-01-02", "open": 187.15, "high": 188.44, "low": 183.89, "close": 185.64, "volume": 82488700},
{"date": "2024-01-03", "open": 184.22, "high": 185.88, "low": 183.43, "close": 184.25, "volume": 58414500}
]
}))
.unwrap();
let candles = historical_to_candles(resp.historical);
assert_eq!(candles.len(), 2);
assert_eq!(candles[0].timestamp, 1_704_153_600);
assert_eq!(candles[0].close, 185.64);
assert_eq!(candles[0].volume, 82_488_700);
assert_eq!(
candles[0].provider_id,
Some(crate::providers::Provider::Fmp)
);
}
#[test]
fn candle_conversion_sorts_fmp_newest_first_payloads_ascending() {
let resp: HistoricalPriceResponseDTO = serde_json::from_value(serde_json::json!({
"symbol": "AAPL",
"historical": [
{"date": "2024-01-04", "open": 1.0, "high": 1.0, "low": 1.0, "close": 3.0, "volume": 3},
{"date": "2024-01-03", "open": 1.0, "high": 1.0, "low": 1.0, "close": 2.0, "volume": 2},
{"date": "2024-01-02", "open": 1.0, "high": 1.0, "low": 1.0, "close": 1.0, "volume": 1}
]
}))
.unwrap();
let candles = historical_to_candles(resp.historical);
assert!(
candles.windows(2).all(|w| w[0].timestamp <= w[1].timestamp),
"daily candles must be ascending"
);
assert_eq!(candles[0].close, 1.0);
let points: Vec<IntradayPriceDTO> = serde_json::from_value(serde_json::json!([
{"date": "2024-01-02 09:35:00", "open": 1.0, "high": 1.0, "low": 1.0, "close": 2.0},
{"date": "2024-01-02 09:30:00", "open": 1.0, "high": 1.0, "low": 1.0, "close": 1.0}
]))
.unwrap();
let candles = intraday_to_candles(points);
assert!(
candles.windows(2).all(|w| w[0].timestamp <= w[1].timestamp),
"intraday candles must be ascending"
);
assert_eq!(candles[0].close, 1.0);
}
#[test]
fn historical_to_candles_skips_rows_missing_required_fields() {
let resp: HistoricalPriceResponseDTO = serde_json::from_value(serde_json::json!({
"symbol": "AAPL",
"historical": [
{"date": "2024-01-02", "open": 187.15, "high": 188.44, "low": 183.89, "close": 185.64, "volume": 82488700},
{"date": "not-a-date", "open": 1.0, "high": 1.0, "low": 1.0, "close": 1.0, "volume": 1},
{"date": "2024-01-04", "open": 1.0, "high": 1.0, "low": 1.0, "volume": 1}
]
}))
.unwrap();
let candles = historical_to_candles(resp.historical);
assert_eq!(candles.len(), 1, "bad-date and missing-close rows dropped");
assert_eq!(candles[0].timestamp, 1_704_153_600);
}
#[test]
fn intraday_to_candles_parses_datetime_and_defaults_missing_volume() {
let points: Vec<IntradayPriceDTO> = serde_json::from_value(serde_json::json!([
{"date": "2024-01-02 09:30:00", "open": 187.15, "high": 187.44, "low": 186.89, "close": 187.20, "volume": 1234567},
{"date": "2024-01-02 09:35:00", "open": 187.20, "high": 187.50, "low": 187.10, "close": 187.35}
]))
.unwrap();
let candles = intraday_to_candles(points);
assert_eq!(candles.len(), 2);
assert_eq!(candles[0].timestamp, 1_704_187_800);
assert_eq!(candles[0].volume, 1_234_567);
assert_eq!(candles[1].volume, 0);
assert_eq!(candles[1].close, 187.35);
}
}