use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::adapters::fmp::build_client;
use crate::adapters::fmp::models::FmpQuoteDTO;
fn index_quote_to_canonical(
symbol: &str,
quotes: &[FmpQuoteDTO],
) -> crate::models::indices::IndexQuote {
let q = quotes.first();
crate::models::indices::IndexQuote {
symbol: symbol.to_string(),
name: q.and_then(|q| q.name.clone()),
price: q.and_then(|q| q.price),
change: q.and_then(|q| q.change),
change_percent: q.and_then(|q| q.changes_percentage),
timestamp: None,
}
}
pub async fn fetch_canonical_index_quote(
symbol: &str,
) -> Result<crate::models::indices::IndexQuote> {
let quotes = crate::adapters::fmp::quote::quote(symbol).await?;
Ok(index_quote_to_canonical(symbol, "es))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct IndexConstituentDTO {
pub symbol: Option<String>,
pub name: Option<String>,
pub sector: Option<String>,
#[serde(rename = "subSector")]
pub sub_sector: Option<String>,
#[serde(rename = "headQuarter")]
pub head_quarter: Option<String>,
#[serde(rename = "dateFirstAdded")]
pub date_first_added: Option<String>,
pub cik: Option<String>,
pub founded: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct HistoricalConstituentDTO {
pub date: Option<String>,
pub symbol: Option<String>,
#[serde(rename = "addedSecurity")]
pub added_security: Option<String>,
#[serde(rename = "removedTicker")]
pub removed_ticker: Option<String>,
#[serde(rename = "removedSecurity")]
pub removed_security: Option<String>,
pub reason: Option<String>,
}
pub async fn sp500_constituents() -> Result<Vec<IndexConstituentDTO>> {
let client = build_client()?;
client.get("/stable/sp500-constituent", &[]).await
}
pub async fn nasdaq_constituents() -> Result<Vec<IndexConstituentDTO>> {
let client = build_client()?;
client.get("/stable/nasdaq-constituent", &[]).await
}
pub async fn dow_constituents() -> Result<Vec<IndexConstituentDTO>> {
let client = build_client()?;
client.get("/stable/dowjones-constituent", &[]).await
}
pub async fn historical_sp500() -> Result<Vec<HistoricalConstituentDTO>> {
let client = build_client()?;
client
.get("/stable/historical-sp500-constituent", &[])
.await
}
fn constituent_to_canonical(c: IndexConstituentDTO) -> crate::models::indices::IndexConstituent {
crate::models::indices::IndexConstituent {
symbol: c.symbol.unwrap_or_default(),
name: c.name,
sector: c.sector,
sub_sector: c.sub_sector,
headquarters: c.head_quarter,
date_first_added: c.date_first_added,
cik: c.cik,
founded: c.founded,
}
}
pub async fn fetch_index_constituents_response(
index: crate::models::indices::MajorIndex,
) -> Result<Vec<crate::models::indices::IndexConstituent>> {
use crate::models::indices::MajorIndex;
let dtos = match index {
MajorIndex::Sp500 => sp500_constituents().await?,
MajorIndex::Nasdaq100 => nasdaq_constituents().await?,
MajorIndex::DowJones => dow_constituents().await?,
};
Ok(dtos.into_iter().map(constituent_to_canonical).collect())
}
pub async fn fetch_index_constituent_changes_response(
index: crate::models::indices::MajorIndex,
) -> Result<Vec<crate::models::indices::IndexConstituentChange>> {
use crate::models::indices::MajorIndex;
let dtos = match index {
MajorIndex::Sp500 => historical_sp500().await?,
other => {
return Err(crate::error::FinanceError::InvalidParameter {
param: "index".into(),
reason: format!(
"FMP provides historical constituent changes for the S&P 500 only, not {other}"
),
});
}
};
Ok(dtos
.into_iter()
.map(|c| crate::models::indices::IndexConstituentChange {
date: c.date,
symbol: c.symbol,
added_security: c.added_security,
removed_ticker: c.removed_ticker,
removed_security: c.removed_security,
reason: c.reason,
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constituent_maps_all_fields() {
let dto: IndexConstituentDTO = serde_json::from_value(serde_json::json!({
"symbol": "AAPL",
"name": "Apple Inc.",
"sector": "Information Technology",
"subSector": "Technology Hardware",
"headQuarter": "Cupertino, CA",
"dateFirstAdded": "1982-11-30",
"cik": "0000320193",
"founded": "1976"
}))
.unwrap();
let c = constituent_to_canonical(dto);
assert_eq!(c.symbol, "AAPL");
assert_eq!(c.sub_sector.as_deref(), Some("Technology Hardware"));
assert_eq!(c.headquarters.as_deref(), Some("Cupertino, CA"));
assert_eq!(c.date_first_added.as_deref(), Some("1982-11-30"));
assert_eq!(c.cik.as_deref(), Some("0000320193"));
assert_eq!(c.founded.as_deref(), Some("1976"));
}
#[tokio::test]
async fn test_sp500_constituents_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/stable/sp500-constituent")
.match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
"apikey".into(),
"test-key".into(),
)]))
.with_status(200)
.with_body(
serde_json::json!([
{
"symbol": "AAPL",
"name": "Apple Inc.",
"sector": "Information Technology",
"subSector": "Technology Hardware",
"headQuarter": "Cupertino, CA",
"dateFirstAdded": "1982-11-30",
"cik": "0000320193",
"founded": "1976"
}
])
.to_string(),
)
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let result: Vec<IndexConstituentDTO> =
client.get("/stable/sp500-constituent", &[]).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].symbol.as_deref(), Some("AAPL"));
assert_eq!(result[0].sector.as_deref(), Some("Information Technology"));
}
#[tokio::test]
async fn test_index_quote_to_canonical_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(
serde_json::json!([{
"symbol": "^GSPC",
"name": "S&P 500",
"price": 4790.61,
"change": 20.12,
"changesPercentage": 0.42
}])
.to_string(),
)
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let quotes: Vec<FmpQuoteDTO> = client.get("/stable/quote", &[]).await.unwrap();
let quote = index_quote_to_canonical("^GSPC", "es);
assert_eq!(quote.symbol, "^GSPC");
assert_eq!(quote.name.as_deref(), Some("S&P 500"));
assert_eq!(quote.price, Some(4790.61));
assert_eq!(quote.change, Some(20.12));
assert_eq!(quote.change_percent, Some(0.42));
}
#[test]
fn index_quote_to_canonical_empty_yields_no_values() {
let quote = index_quote_to_canonical("^GSPC", &[]);
assert_eq!(quote.symbol, "^GSPC");
assert!(quote.name.is_none());
assert!(quote.price.is_none());
}
}