use std::sync::Arc;
use std::time::Duration;
use reqwest::Client;
use super::models::{
NasdaqDividendRow, NasdaqDividendsEnvelope, NasdaqEarningsEnvelope, NasdaqEarningsRow,
NasdaqIpoEnvelope, NasdaqIpoRow, NasdaqSplitRow, NasdaqSplitsEnvelope,
};
use crate::adapters::common::check_status;
use crate::error::{FinanceError, Result};
use crate::rate_limiter::RateLimiter;
pub(super) const NASDAQ_BASE: &str = "https://api.nasdaq.com/api";
const BROWSER_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/120.0 Safari/537.36";
pub(super) struct NasdaqClient {
http: Client,
limiter: Arc<RateLimiter>,
base_url: String,
}
impl NasdaqClient {
pub(super) fn new(
timeout: Duration,
limiter: Arc<RateLimiter>,
base_url: impl Into<String>,
) -> Result<Self> {
let http = Client::builder()
.timeout(timeout)
.user_agent(BROWSER_USER_AGENT)
.build()?;
Ok(Self {
http,
limiter,
base_url: base_url.into(),
})
}
async fn get_json<T: serde::de::DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T> {
self.limiter.acquire().await;
let url = format!("{}/{path}", self.base_url);
let resp = self.http.get(&url).query(query).send().await?;
check_status("Nasdaq", resp.status())?;
let bytes = resp.bytes().await?;
serde_json::from_slice(&bytes).map_err(|e| FinanceError::ResponseStructureError {
field: "nasdaq.response".to_string(),
context: format!("unrecognised Nasdaq envelope: {e}"),
})
}
pub(super) async fn earnings_for_date(&self, date: &str) -> Result<Vec<NasdaqEarningsRow>> {
let envelope: NasdaqEarningsEnvelope = self
.get_json("calendar/earnings", &[("date", date)])
.await?;
Ok(envelope.data.and_then(|d| d.rows).unwrap_or_default())
}
pub(super) async fn dividends_for_date(&self, date: &str) -> Result<Vec<NasdaqDividendRow>> {
let envelope: NasdaqDividendsEnvelope = self
.get_json("calendar/dividends", &[("date", date)])
.await?;
Ok(envelope
.data
.and_then(|d| d.calendar)
.and_then(|c| c.rows)
.unwrap_or_default())
}
pub(super) async fn splits_for_date(&self, date: &str) -> Result<Vec<NasdaqSplitRow>> {
let envelope: NasdaqSplitsEnvelope =
self.get_json("calendar/splits", &[("date", date)]).await?;
Ok(envelope.data.and_then(|d| d.rows).unwrap_or_default())
}
pub(super) async fn ipos_for_month(
&self,
month: &str,
) -> Result<Vec<(&'static str, NasdaqIpoRow)>> {
let envelope: NasdaqIpoEnvelope = self.get_json("ipo/calendar", &[("date", month)]).await?;
let Some(data) = envelope.data else {
return Ok(Vec::new());
};
let mut out = Vec::new();
out.extend(
data.priced
.and_then(|r| r.rows)
.unwrap_or_default()
.into_iter()
.map(|r| ("priced", r)),
);
out.extend(
data.upcoming
.map(|u| u.upcoming_table)
.and_then(|r| r.rows)
.unwrap_or_default()
.into_iter()
.map(|r| ("expected", r)),
);
out.extend(
data.filed
.and_then(|r| r.rows)
.unwrap_or_default()
.into_iter()
.map(|r| ("filed", r)),
);
out.extend(
data.withdrawn
.and_then(|r| r.rows)
.unwrap_or_default()
.into_iter()
.map(|r| ("withdrawn", r)),
);
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rate_limiter::RateLimiter;
fn test_client(base_url: &str) -> NasdaqClient {
NasdaqClient::new(
Duration::from_secs(5),
Arc::new(RateLimiter::new(100.0)),
base_url,
)
.unwrap()
}
#[tokio::test]
async fn earnings_for_date_parses_rows() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/calendar/earnings")
.match_query(mockito::Matcher::UrlEncoded(
"date".into(),
"2026-08-25".into(),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"data": {
"rows": [{
"symbol": "BMO",
"time": "time-pre-market",
"fiscalQuarterEnding": "Jul/2026",
"epsForecast": "$2.72"
}]
}
})
.to_string(),
)
.create_async()
.await;
let rows = test_client(&server.url())
.earnings_for_date("2026-08-25")
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].symbol.as_deref(), Some("BMO"));
assert_eq!(rows[0].eps_forecast.as_deref(), Some("$2.72"));
}
#[tokio::test]
async fn dividends_for_date_reads_the_nested_calendar_key() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/calendar/dividends")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"data": {
"calendar": {
"rows": [{
"symbol": "CDW",
"dividend_Ex_Date": "8/25/2026",
"dividend_Rate": 0.63
}]
}
}
})
.to_string(),
)
.create_async()
.await;
let rows = test_client(&server.url())
.dividends_for_date("2026-08-25")
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].dividend_rate, Some(0.63));
}
#[tokio::test]
async fn splits_for_date_returns_empty_when_no_rows() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/calendar/splits")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::json!({"data": {"rows": null}}).to_string())
.create_async()
.await;
let rows = test_client(&server.url())
.splits_for_date("2026-08-25")
.await
.unwrap();
assert!(rows.is_empty());
}
#[tokio::test]
async fn ipos_for_month_tags_each_row_by_deal_status() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/ipo/calendar")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"data": {
"priced": {"rows": [{"proposedTickerSymbol": "LYNX", "pricedDate": "8/19/2026"}]},
"upcoming": {"upcomingTable": {"rows": [{"proposedTickerSymbol": "PTT", "expectedPriceDate": "9/08/2026"}]}},
"filed": {"rows": []},
"withdrawn": {"rows": []}
}
})
.to_string(),
)
.create_async()
.await;
let rows = test_client(&server.url())
.ipos_for_month("2026-08")
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].0, "priced");
assert_eq!(rows[0].1.proposed_ticker_symbol.as_deref(), Some("LYNX"));
assert_eq!(rows[1].0, "expected");
assert_eq!(rows[1].1.proposed_ticker_symbol.as_deref(), Some("PTT"));
}
#[tokio::test]
async fn non_2xx_status_maps_to_external_api_error() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/calendar/earnings")
.match_query(mockito::Matcher::Any)
.with_status(503)
.create_async()
.await;
let err = test_client(&server.url())
.earnings_for_date("2026-08-25")
.await
.unwrap_err();
assert!(matches!(
err,
FinanceError::ExternalApiError { status: 503, .. }
));
}
}