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>,
}
#[derive(Debug, Clone)]
pub(super) struct RowQuery<'a> {
pub dataset: &'a str,
pub fields: &'a str,
pub sort: &'a str,
pub filter: Option<&'a str>,
pub page_size: u32,
}
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 (rows, meta) = self
.rows(
&RowQuery {
dataset: query.dataset,
fields: &fields,
sort: DATE_FIELD,
filter: query.filter,
page_size: PAGE_SIZE,
},
MAX_PAGES,
)
.await?;
if meta.total_pages.is_some_and(|pages| pages > MAX_PAGES) {
warn!(
"FiscalData series {}/{} has {} pages; truncated at {MAX_PAGES}",
query.dataset,
query.value_field,
meta.total_pages.unwrap_or_default()
);
}
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, meta))
}
pub(super) async fn rows(
&self,
query: &RowQuery<'_>,
max_pages: u32,
) -> Result<(Vec<FiscalRow>, FiscalMeta)> {
let mut first = self.page(query, 1).await?;
let total_pages = first.meta.total_pages.unwrap_or(1).min(max_pages);
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, page))
.buffered(MAX_CONCURRENT_PAGES)
.try_collect()
.await?;
rows.extend(rest.into_iter().flat_map(|page| page.data));
}
Ok((rows, first.meta))
}
async fn page(&self, query: &RowQuery<'_>, 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 = query.page_size.to_string();
let mut params: Vec<(&str, &str)> = vec![
("format", "json"),
("fields", query.fields),
("sort", query.sort),
("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: &RowQuery<'_>) -> 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.fields),
};
}
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)
}
}