pub(crate) mod client;
pub(crate) mod futures;
pub(crate) mod models;
use std::time::Duration;
use crate::adapters::singleton::keyless_limiter;
use crate::error::Result;
use client::CftcClient;
const CFTC_RATE_PER_SEC: f64 = 5.0;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
keyless_limiter!(rate = CFTC_RATE_PER_SEC);
fn client() -> Result<CftcClient> {
CftcClient::new(DEFAULT_TIMEOUT, shared_limiter(), client::CFTC_BASE)
}
pub use futures::fetch_commitments_of_traders_response;
#[cfg(test)]
mod tests {
use super::futures::{resolve_contract_code, to_canonical};
use super::*;
use crate::error::FinanceError;
use crate::rate_limiter::RateLimiter;
use std::sync::Arc;
fn test_client(base_url: &str) -> CftcClient {
CftcClient::new(
Duration::from_secs(5),
Arc::new(RateLimiter::new(100.0)),
base_url,
)
.unwrap()
}
fn rows_payload() -> String {
serde_json::json!([
{
"market_and_exchange_names": "GOLD - COMMODITY EXCHANGE INC.",
"cftc_contract_market_code": "088691",
"report_date_as_yyyy_mm_dd": "2026-07-28T00:00:00.000",
"open_interest_all": "384603",
"prod_merc_positions_long": "15367",
"prod_merc_positions_short": "35916",
"swap_positions_long_all": "23661",
"swap__positions_short_all": "215421",
"swap__positions_spread_all": "36432",
"m_money_positions_long_all": "135093",
"m_money_positions_short_all": "15298",
"m_money_positions_spread": "18384",
"other_rept_positions_long": "84529",
"other_rept_positions_short": "22254",
"other_rept_positions_spread": "9753",
"tot_rept_positions_long_all": "323219",
"tot_rept_positions_short": "353458",
"nonrept_positions_long_all": "61384",
"nonrept_positions_short_all": "31145"
},
{
"market_and_exchange_names": "GOLD - COMMODITY EXCHANGE INC.",
"cftc_contract_market_code": "088691",
"report_date_as_yyyy_mm_dd": "2026-07-21T00:00:00.000",
"open_interest_all": "383368",
"prod_merc_positions_long": "15561",
"prod_merc_positions_short": "34882",
"swap_positions_long_all": "24959",
"swap__positions_short_all": "218837",
"swap__positions_spread_all": "39937",
"m_money_positions_long_all": "141487",
"m_money_positions_short_all": "16656",
"m_money_positions_spread": "17872",
"other_rept_positions_long": "83298",
"other_rept_positions_short": "24219",
"other_rept_positions_spread": "14111",
"tot_rept_positions_long_all": "337225",
"tot_rept_positions_short": "366514",
"nonrept_positions_long_all": "46143",
"nonrept_positions_short_all": "16854"
}
])
.to_string()
}
#[tokio::test]
async fn gold_rows_map_to_a_chronological_canonical_series() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(rows_payload())
.create_async()
.await;
let rows = test_client(&server.url())
.commitments_of_traders(&resolve_contract_code("GC=F"))
.await
.unwrap();
let series = to_canonical("GC=F", rows).unwrap();
assert_eq!(series.symbol, "GC=F");
assert_eq!(
series.market_and_exchange_name,
"GOLD - COMMODITY EXCHANGE INC."
);
assert_eq!(series.cftc_contract_market_code, "088691");
assert_eq!(series.observations.len(), 2);
assert_eq!(series.observations[0].report_date, "2026-07-21");
assert_eq!(series.observations[1].report_date, "2026-07-28");
assert_eq!(series.observations[1].open_interest, Some(384603));
assert_eq!(series.observations[1].managed_money_long, Some(135093));
assert_eq!(series.observations[1].managed_money_short, Some(15298));
assert_eq!(series.observations[1].swap_dealer_short, Some(215421));
}
#[tokio::test]
async fn unknown_contract_code_maps_to_symbol_not_found() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body("[]")
.create_async()
.await;
let rows = test_client(&server.url())
.commitments_of_traders("999999")
.await
.unwrap();
let err = to_canonical("999999", rows).unwrap_err();
assert!(
matches!(err, FinanceError::SymbolNotFound { .. }),
"{err:?}"
);
}
#[tokio::test]
async fn a_rejected_query_carries_cftcs_message() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.match_query(mockito::Matcher::Any)
.with_status(400)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"message": "Query coordinator error: query.soql.no-such-column"
})
.to_string(),
)
.create_async()
.await;
let err = test_client(&server.url())
.commitments_of_traders("088691")
.await
.unwrap_err();
match err {
FinanceError::ApiError(msg) => {
assert!(msg.contains("no-such-column"), "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("GET", "/")
.match_query(mockito::Matcher::Any)
.with_status(503)
.create_async()
.await;
let err = test_client(&server.url())
.commitments_of_traders("088691")
.await
.unwrap_err();
assert!(
matches!(err, FinanceError::ExternalApiError { status: 503, .. }),
"{err:?}"
);
}
}