pub(crate) mod client;
pub(crate) mod fundamentals;
pub(crate) mod models;
use std::time::Duration;
use crate::adapters::singleton::keyless_limiter;
use crate::error::Result;
use client::FinraClient;
const FINRA_RATE_PER_SEC: f64 = 2.0;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
keyless_limiter!(rate = FINRA_RATE_PER_SEC);
fn client() -> Result<FinraClient> {
FinraClient::new(DEFAULT_TIMEOUT, shared_limiter(), client::FINRA_BASE)
}
pub(crate) use fundamentals::fetch_short_volume_response;
#[cfg(test)]
mod tests {
use super::fundamentals::consolidate;
use super::*;
use crate::error::FinanceError;
use crate::rate_limiter::RateLimiter;
use std::sync::Arc;
fn test_client(base_url: &str) -> FinraClient {
FinraClient::new(
Duration::from_secs(5),
Arc::new(RateLimiter::new(100.0)),
base_url,
)
.unwrap()
}
fn rows_payload() -> String {
serde_json::json!([
{ "reportingFacilityCode": "NQTRF", "marketCode": "Q",
"tradeReportDate": "2026-07-29",
"securitiesInformationProcessorSymbolIdentifier": "AAPL",
"shortParQuantity": 8104492.271627_f64,
"shortExemptParQuantity": 43220.0_f64,
"totalParQuantity": 16890321.3741_f64 },
{ "reportingFacilityCode": "NCTRF", "marketCode": "B",
"tradeReportDate": "2026-07-28",
"securitiesInformationProcessorSymbolIdentifier": "AAPL",
"shortParQuantity": 71287.391347_f64,
"shortExemptParQuantity": 2.0_f64,
"totalParQuantity": 139754.385672_f64 },
{ "reportingFacilityCode": "NQTRF", "marketCode": "Q",
"tradeReportDate": "2026-07-28",
"securitiesInformationProcessorSymbolIdentifier": "AAPL",
"shortParQuantity": 8101622.503547_f64,
"shortExemptParQuantity": 41852.0_f64,
"totalParQuantity": 17193767.649164_f64 },
{ "reportingFacilityCode": "NYTRF", "marketCode": "N",
"tradeReportDate": "2026-07-28",
"securitiesInformationProcessorSymbolIdentifier": "AAPL",
"shortParQuantity": 1433075.91414_f64,
"shortExemptParQuantity": 430.0_f64,
"totalParQuantity": 1756412.25786_f64 }
])
.to_string()
}
#[tokio::test]
async fn per_facility_rows_are_summed_into_one_figure_per_date() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/otcMarket/name/regShoDaily")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(rows_payload())
.create_async()
.await;
let rows = test_client(&server.url())
.reg_sho_daily("AAPL", "2026-07-28".into(), "2026-07-29".into())
.await
.unwrap();
let series = consolidate(rows);
assert_eq!(series.len(), 2);
assert_eq!(series[0].date.as_deref(), Some("2026-07-28"));
assert_eq!(series[1].date.as_deref(), Some("2026-07-29"));
let day = &series[0];
let expected_short = 71287.391347 + 8101622.503547 + 1433075.91414;
let expected_total = 139754.385672 + 17193767.649164 + 1756412.25786;
assert!((day.short_volume.unwrap() - expected_short).abs() < 1e-6);
assert!((day.total_volume.unwrap() - expected_total).abs() < 1e-6);
assert!((day.short_exempt_volume.unwrap() - (2.0 + 41852.0 + 430.0)).abs() < 1e-9);
}
#[test]
fn a_field_absent_from_every_facility_stays_none() {
let rows =
serde_json::from_value::<Vec<super::models::RegShoDailyRow>>(serde_json::json!([
{ "tradeReportDate": "2026-07-28", "shortParQuantity": 10.0_f64 },
{ "tradeReportDate": "2026-07-28", "shortParQuantity": 5.0_f64 }
]))
.unwrap();
let series = consolidate(rows);
assert_eq!(series.len(), 1);
assert_eq!(series[0].short_volume, Some(15.0));
assert_eq!(series[0].total_volume, None);
}
#[tokio::test]
async fn no_content_means_an_empty_series_not_an_error() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/otcMarket/name/regShoDaily")
.with_status(204)
.create_async()
.await;
let rows = test_client(&server.url())
.reg_sho_daily("NOSUCHSYM", "2026-01-01".into(), "2026-07-30".into())
.await
.unwrap();
assert!(rows.is_empty());
}
#[tokio::test]
async fn a_rejected_request_carries_finras_message() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/otcMarket/name/regShoDaily")
.with_status(400)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"statusCode": 400,
"message": "The following fields are not available in this dataset: [nope]"
})
.to_string(),
)
.create_async()
.await;
let err = test_client(&server.url())
.reg_sho_daily("AAPL", "2026-01-01".into(), "2026-07-30".into())
.await
.unwrap_err();
match err {
FinanceError::ApiError(msg) => {
assert!(msg.contains("not available in this dataset"), "got {msg}");
}
other => panic!("expected ApiError, got {other:?}"),
}
}
#[tokio::test]
async fn http_error_maps_to_external_api_error() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/otcMarket/name/regShoDaily")
.with_status(503)
.create_async()
.await;
let err = test_client(&server.url())
.reg_sho_daily("AAPL", "2026-01-01".into(), "2026-07-30".into())
.await
.unwrap_err();
assert!(
matches!(err, FinanceError::ExternalApiError { status: 503, .. }),
"{err:?}"
);
}
}