use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use crate::error::{FinanceError, Result};
use crate::models::discovery::reference::SymbolMatch;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StockPeersDTO {
pub symbol: Option<String>,
#[serde(rename = "companyName")]
pub company_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct DelistedCompanyDTO {
#[serde(default, deserialize_with = "crate::adapters::fmp::blank_as_none")]
pub symbol: Option<String>,
#[serde(
rename = "companyName",
default,
deserialize_with = "crate::adapters::fmp::blank_as_none"
)]
pub company_name: Option<String>,
#[serde(default, deserialize_with = "crate::adapters::fmp::blank_as_none")]
pub exchange: Option<String>,
#[serde(
rename = "ipoDate",
default,
deserialize_with = "crate::adapters::fmp::blank_as_none"
)]
pub ipo_date: Option<String>,
#[serde(
rename = "delistedDate",
default,
deserialize_with = "crate::adapters::fmp::blank_as_none"
)]
pub delisted_date: Option<String>,
}
fn stock_peers_to_canonical(
peers: Vec<StockPeersDTO>,
limit: usize,
) -> Vec<crate::models::corporate::recommendation::SimilarSymbol> {
let mut symbols: Vec<crate::models::corporate::recommendation::SimilarSymbol> = peers
.into_iter()
.filter_map(|p| p.symbol)
.map(
|s| crate::models::corporate::recommendation::SimilarSymbol {
symbol: s,
score: 0.0,
},
)
.collect();
symbols.truncate(limit);
symbols
}
pub async fn fetch_canonical_similar_symbols(
symbol: &str,
limit: u32,
) -> Result<Vec<crate::models::corporate::recommendation::SimilarSymbol>> {
let peers = stock_peers(symbol).await?;
Ok(stock_peers_to_canonical(peers, limit as usize))
}
pub async fn stock_peers(symbol: &str) -> Result<Vec<StockPeersDTO>> {
let client = crate::adapters::fmp::build_client()?;
client
.get("/stable/stock-peers", &[("symbol", symbol)])
.await
}
pub async fn delisted_companies(limit: Option<u32>) -> Result<Vec<DelistedCompanyDTO>> {
let limit = limit.unwrap_or(MAX_DELISTED_PAGE_SIZE);
check_delisted_page(0, limit)?;
let client = crate::adapters::fmp::build_client()?;
delisted_rows(&client, 0, limit).await
}
#[derive(Default, Serialize, Deserialize)]
struct DelistedCursor {
page: u32,
seen: Vec<u64>,
records: BTreeSet<u64>,
}
fn cursor_fingerprint(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf29ce484222325_u64, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
})
}
const DELISTED_PAGE_LIMITS: crate::adapters::fmp::client::ResponseLimits =
crate::adapters::fmp::client::ResponseLimits::bytes(8 * 1024 * 1024);
const MAX_DELISTED_PAGE_SIZE: u32 = 100;
const MAX_DELISTED_PAGES: u32 = 10_000;
const MAX_DELISTED_CURSOR_BYTES: usize = 16 * 1024 * 1024;
fn check_delisted_page(page: u32, limit: u32) -> Result<()> {
if page >= MAX_DELISTED_PAGES || !(1..=MAX_DELISTED_PAGE_SIZE).contains(&limit) {
return Err(FinanceError::InvalidParameter {
param: "page/limit".into(),
reason: "page must be below 10000 and limit 1..=100".into(),
});
}
Ok(())
}
async fn delisted_rows(
client: &crate::adapters::fmp::client::FmpClient,
page: u32,
limit: u32,
) -> Result<Vec<DelistedCompanyDTO>> {
client
.get_limited(
"/stable/delisted-companies",
&[("page", &page.to_string()), ("limit", &limit.to_string())],
Some(DELISTED_PAGE_LIMITS),
)
.await
}
async fn delisted_page(
client: &crate::adapters::fmp::client::FmpClient,
limit: u32,
cursor: Option<&crate::PageCursor>,
) -> Result<(Vec<DelistedCompanyDTO>, Option<crate::PageCursor>)> {
let invalid = || FinanceError::InvalidParameter {
param: "cursor".into(),
reason: "invalid FMP delisted continuation".into(),
};
let mut state = match cursor {
Some(cursor) if cursor.target().len() <= MAX_DELISTED_CURSOR_BYTES => {
serde_json::from_str::<DelistedCursor>(cursor.target()).map_err(|_| invalid())?
}
Some(_) => return Err(invalid()),
None => DelistedCursor::default(),
};
if state.page as usize != state.seen.len() {
return Err(invalid());
}
check_delisted_page(state.page, limit)?;
let rows = delisted_rows(client, state.page, limit).await?;
if rows.is_empty() {
return Ok((rows, None));
}
let mut ordered: Vec<String> = rows
.iter()
.map(serde_json::to_string)
.collect::<std::result::Result<_, _>>()?;
ordered.sort();
ordered.dedup();
let hash = cursor_fingerprint(ordered.join("\n").as_bytes());
let mut advanced = false;
for row in &ordered {
advanced |= state.records.insert(cursor_fingerprint(row.as_bytes()));
}
if state.seen.contains(&hash) || !advanced {
return Err(FinanceError::ResponseStructureError {
field: "pagination".into(),
context: "FMP repeated a delisted page".into(),
});
}
state.seen.push(hash);
state.page += 1;
let target = serde_json::to_string(&state)?;
if target.len() > MAX_DELISTED_CURSOR_BYTES {
return Err(invalid());
}
Ok((
rows,
Some(crate::PageCursor::continuation(
crate::Provider::Fmp,
target,
)),
))
}
pub(crate) async fn fetch_delisted_stocks_page(
limit: u32,
cursor: Option<&crate::PageCursor>,
) -> Result<crate::ProviderPage<SymbolMatch>> {
let client = crate::adapters::fmp::build_client()?;
let (rows, next) = delisted_page(&client, limit, cursor).await?;
let count = rows.len();
let items = delisted_symbols(rows)?;
Ok(crate::ProviderPage {
items,
next,
provider_id: crate::Provider::Fmp,
request_id: None,
results_count: Some(count),
query_count: None,
reported_symbol: None,
adjusted: None,
})
}
pub(crate) async fn fetch_delisted_stocks_page_at(
page: u32,
limit: u32,
) -> Result<Vec<SymbolMatch>> {
check_delisted_page(page, limit)?;
let client = crate::adapters::fmp::build_client()?;
let rows = delisted_rows(&client, page, limit).await?;
if rows.len() > limit as usize {
return Err(FinanceError::ResponseStructureError {
field: "pagination".into(),
context: "FMP delisted page exceeds its requested limit".into(),
});
}
delisted_symbols(rows)
}
fn delisted_symbols(rows: Vec<DelistedCompanyDTO>) -> Result<Vec<SymbolMatch>> {
Ok(rows
.into_iter()
.filter_map(to_delisted_symbol_match)
.filter(|r| !r.symbol.trim().is_empty())
.collect())
}
fn to_delisted_symbol_match(dto: DelistedCompanyDTO) -> Option<SymbolMatch> {
Some(SymbolMatch {
symbol: dto.symbol?,
id: None,
name: dto.company_name,
exchange: dto.exchange,
asset_type: None,
currency: None,
active: Some(false),
market_cap_rank: None,
thumbnail: None,
image: None,
ipo_date: dto.ipo_date,
delisted_date: dto.delisted_date,
})
}
pub async fn fetch_delisted_listing_status_response() -> Result<Vec<SymbolMatch>> {
Ok(delisted_companies(None)
.await?
.into_iter()
.filter_map(to_delisted_symbol_match)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delisted_rows_without_a_symbol_are_skipped() {
let rows: Vec<DelistedCompanyDTO> = serde_json::from_value(serde_json::json!([
{"symbol": "OLD", "delistedDate": "2003-01-02"},
{"symbol": " ", "delistedDate": "2004-01-02"},
{"delistedDate": "2005-01-02"}
]))
.unwrap();
let items = delisted_symbols(rows).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].symbol, "OLD");
}
#[test]
fn maps_delisted_company_to_symbol_match() {
let dto: DelistedCompanyDTO = serde_json::from_value(serde_json::json!({
"symbol": "XYZ",
"companyName": "XYZ Corp",
"exchange": "NYSE",
"ipoDate": "2001-01-01",
"delistedDate": "2023-06-01"
}))
.unwrap();
let out = to_delisted_symbol_match(dto).unwrap();
assert_eq!(out.symbol, "XYZ");
assert_eq!(out.name.as_deref(), Some("XYZ Corp"));
assert_eq!(out.exchange.as_deref(), Some("NYSE"));
assert_eq!(out.active, Some(false));
assert_eq!(out.ipo_date.as_deref(), Some("2001-01-01"));
assert_eq!(out.delisted_date.as_deref(), Some("2023-06-01"));
}
#[test]
fn drops_delisted_entries_without_a_symbol() {
let dto: DelistedCompanyDTO = serde_json::from_value(serde_json::json!({
"companyName": "No Symbol Inc"
}))
.unwrap();
assert!(to_delisted_symbol_match(dto).is_none());
}
#[tokio::test]
async fn test_fmp_rate_limit_returns_rate_limited_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(429)
.with_body("{}")
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let result = client.get_raw("/api/v3/profile/AAPL", &[]).await;
assert!(matches!(
result,
Err(crate::error::FinanceError::RateLimited { .. })
));
}
#[tokio::test]
async fn test_fmp_401_returns_authentication_failed() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(401)
.with_body("{}")
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let result = client.get_raw("/api/v3/profile/AAPL", &[]).await;
assert!(matches!(
result,
Err(crate::error::FinanceError::AuthenticationFailed { .. })
));
}
#[tokio::test]
async fn test_fmp_body_api_key_error_returns_authentication_failed() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_body(r#"{"Error Message":"Invalid API KEY."}"#)
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let result = client.get_raw("/api/v3/profile/AAPL", &[]).await;
assert!(matches!(
result,
Err(crate::error::FinanceError::AuthenticationFailed { .. })
));
}
#[tokio::test]
async fn test_fmp_500_returns_server_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(500)
.with_body("{}")
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let result = client.get_raw("/api/v3/profile/AAPL", &[]).await;
assert!(matches!(
result,
Err(crate::error::FinanceError::ServerError { .. })
));
}
}