use std::sync::Arc;
use std::time::Duration;
use reqwest::{Client, StatusCode};
use tracing::{debug, warn};
use futures::stream::{StreamExt, TryStreamExt};
use super::models::{FiscalError, FiscalMeta, FiscalResponse, FiscalRow};
use crate::adapters::common::{keyless_http_client, status_error};
use crate::error::{FinanceError, Result};
use crate::rate_limiter::RateLimiter;
pub(super) const FISCALDATA_BASE: &str =
"https://api.fiscaldata.treasury.gov/services/api/fiscal_service";
const API: &str = "US Treasury FiscalData";
const PAGE_SIZE: u32 = 10_000;
const MAX_CONCURRENT_PAGES: usize = 4;
const MAX_PAGES: u32 = 5;
pub(super) const DATE_FIELD: &str = "record_date";
#[derive(Debug, Clone)]
pub(super) struct SeriesQuery<'a> {
pub dataset: &'a str,
pub value_field: &'a str,
pub filter: Option<&'a str>,
}
pub(super) struct FiscalDataClient {
http: Client,
limiter: Arc<RateLimiter>,
base_url: String,
}
impl FiscalDataClient {
pub(super) fn new(
timeout: Duration,
limiter: Arc<RateLimiter>,
base_url: impl Into<String>,
) -> Result<Self> {
Ok(Self {
http: keyless_http_client(timeout)?,
limiter,
base_url: base_url.into(),
})
}
pub(super) async fn series(
&self,
query: &SeriesQuery<'_>,
) -> Result<(Vec<FiscalRow>, FiscalMeta)> {
let fields = format!("{DATE_FIELD},{}", query.value_field);
let mut first = self.page(query, &fields, 1).await?;
let reported_pages = first.meta.total_pages.unwrap_or(1);
let total_pages = reported_pages.min(MAX_PAGES);
if reported_pages > MAX_PAGES {
warn!(
"FiscalData series {}/{} has {reported_pages} pages; truncated at {MAX_PAGES}",
query.dataset, query.value_field
);
}
let mut rows = std::mem::take(&mut first.data);
if !rows.is_empty() && total_pages > 1 {
let rest: Vec<FiscalResponse> = futures::stream::iter(2..=total_pages)
.map(|page| self.page(query, &fields, page))
.buffered(MAX_CONCURRENT_PAGES)
.try_collect()
.await?;
rows.extend(rest.into_iter().flat_map(|page| page.data));
}
if rows.is_empty() {
return Err(FinanceError::SymbolNotFound {
symbol: Some(format!("{}/{}", query.dataset, query.value_field)),
context: "FiscalData returned no rows for this dataset/filter combination"
.to_string(),
});
}
Ok((rows, first.meta))
}
async fn page(
&self,
query: &SeriesQuery<'_>,
fields: &str,
page: u32,
) -> Result<FiscalResponse> {
self.limiter.acquire().await;
let url = format!("{}/{}", self.base_url, query.dataset);
let page_str = page.to_string();
let size_str = PAGE_SIZE.to_string();
let mut params: Vec<(&str, &str)> = vec![
("format", "json"),
("fields", fields),
("sort", DATE_FIELD),
("page[size]", &size_str),
("page[number]", &page_str),
];
if let Some(filter) = query.filter {
params.push(("filter", filter));
}
debug!("FiscalData request: {url} page {page}");
let resp = self.http.get(&url).query(¶ms).send().await?;
let status = resp.status();
let bytes = resp.bytes().await?;
if !status.is_success() {
return Err(Self::map_error(status, &bytes, query));
}
serde_json::from_slice(&bytes).map_err(|e| FinanceError::ResponseStructureError {
field: "fiscaldata.response".to_string(),
context: format!("unrecognised FiscalData envelope: {e}"),
})
}
fn map_error(status: StatusCode, body: &[u8], query: &SeriesQuery<'_>) -> FinanceError {
if status == StatusCode::TOO_MANY_REQUESTS {
return status_error(API, status);
}
if let Ok(err) = serde_json::from_slice::<FiscalError>(body)
&& let Some(detail) = err.describe()
{
return FinanceError::MacroDataError {
provider: API.to_string(),
context: format!("{}/{}: {detail}", query.dataset, query.value_field),
};
}
if status == StatusCode::NOT_FOUND {
return FinanceError::SymbolNotFound {
symbol: Some(query.dataset.to_string()),
context: "no such FiscalData dataset".to_string(),
};
}
status_error(API, status)
}
}