use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::adapters::fmp::build_client;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ScreenerResultDTO {
pub symbol: Option<String>,
#[serde(rename = "companyName")]
pub company_name: Option<String>,
#[serde(rename = "marketCap")]
pub market_cap: Option<f64>,
pub sector: Option<String>,
pub industry: Option<String>,
pub beta: Option<f64>,
pub price: Option<f64>,
#[serde(rename = "lastAnnualDividend")]
pub last_annual_dividend: Option<f64>,
pub volume: Option<f64>,
pub exchange: Option<String>,
#[serde(rename = "exchangeShortName")]
pub exchange_short_name: Option<String>,
pub country: Option<String>,
#[serde(rename = "isEtf")]
pub is_etf: Option<bool>,
#[serde(rename = "isFund")]
pub is_fund: Option<bool>,
#[serde(rename = "isActivelyTrading")]
pub is_actively_trading: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SearchResultDTO {
pub symbol: Option<String>,
pub name: Option<String>,
pub currency: Option<String>,
#[serde(rename = "exchangeFullName")]
pub exchange_full_name: Option<String>,
pub exchange: Option<String>,
}
pub async fn stock_screener(params: &[(&str, &str)]) -> Result<Vec<ScreenerResultDTO>> {
let client = build_client()?;
client.get("/stable/company-screener", params).await
}
pub async fn symbol_search(
query: &str,
limit: Option<u32>,
exchange: Option<&str>,
) -> Result<Vec<SearchResultDTO>> {
let client = build_client()?;
let limit_str = limit.map(|l| l.to_string());
let mut params: Vec<(&str, &str)> = vec![("query", query)];
if let Some(ref l) = limit_str {
params.push(("limit", l));
}
if let Some(e) = exchange {
params.push(("exchange", e));
}
client.get("/stable/search-symbol", ¶ms).await
}
pub async fn fetch_symbol_search_response(
query: &str,
limit: u32,
) -> Result<Vec<crate::models::discovery::reference::SymbolMatch>> {
use crate::models::discovery::reference::SymbolMatch;
let results = symbol_search(query, Some(limit), None).await?;
Ok(results
.into_iter()
.filter_map(|r| {
Some(SymbolMatch {
symbol: r.symbol?,
id: None,
name: r.name,
exchange: r.exchange.or(r.exchange_full_name),
asset_type: None,
currency: r.currency,
active: None,
market_cap_rank: None,
thumbnail: None,
image: None,
})
})
.collect())
}
pub async fn fetch_screener_response(
filters: &crate::models::discovery::reference::ScreenerFilters,
) -> Result<Vec<crate::models::discovery::reference::ScreenerMatch>> {
use crate::models::discovery::reference::ScreenerMatch;
let owned = filters.to_query();
let params: Vec<(&str, &str)> = owned.iter().map(|(k, v)| (*k, v.as_str())).collect();
let results = stock_screener(¶ms).await?;
Ok(results
.into_iter()
.filter_map(|r| {
Some(ScreenerMatch {
symbol: r.symbol?,
name: r.company_name,
price: r.price,
market_cap: r.market_cap,
sector: r.sector,
industry: r.industry,
beta: r.beta,
volume: r.volume,
exchange: r.exchange_short_name.or(r.exchange),
country: r.country,
is_etf: r.is_etf,
is_actively_trading: r.is_actively_trading,
})
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_symbol_search_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/stable/search-symbol")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("apikey".into(), "test-key".into()),
mockito::Matcher::UrlEncoded("query".into(), "apple".into()),
mockito::Matcher::UrlEncoded("limit".into(), "5".into()),
]))
.with_status(200)
.with_body(
r#"[{
"symbol": "AAPL",
"name": "Apple Inc.",
"currency": "USD",
"exchangeFullName": "NASDAQ Global Select",
"exchange": "NASDAQ"
}]"#,
)
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let result: Vec<SearchResultDTO> = client
.get(
"/stable/search-symbol",
&[("query", "apple"), ("limit", "5")],
)
.await
.unwrap();
let hit = &result[0];
assert_eq!(hit.symbol.as_deref(), Some("AAPL"));
assert_eq!(hit.name.as_deref(), Some("Apple Inc."));
assert_eq!(hit.currency.as_deref(), Some("USD"));
assert_eq!(hit.exchange.as_deref(), Some("NASDAQ"));
assert_eq!(
hit.exchange_full_name.as_deref(),
Some("NASDAQ Global Select")
);
}
#[test]
fn symbol_search_exposes_the_exchange_code() {
let hits: Vec<SearchResultDTO> = serde_json::from_str(
r#"[{"symbol":"AAPL","name":"Apple Inc.","currency":"USD",
"exchangeFullName":"NASDAQ Global Select","exchange":"NASDAQ"}]"#,
)
.unwrap();
let matched: Vec<_> = hits
.into_iter()
.filter_map(|r| {
Some(crate::models::discovery::reference::SymbolMatch {
symbol: r.symbol?,
id: None,
name: r.name,
exchange: r.exchange.or(r.exchange_full_name),
asset_type: None,
currency: r.currency,
active: None,
market_cap_rank: None,
thumbnail: None,
image: None,
})
})
.collect();
assert_eq!(matched[0].exchange.as_deref(), Some("NASDAQ"));
}
}