use crate::error::{FinanceError, Result};
use crate::models::futures::cot::{CommitmentsOfTraders, CotObservation};
use super::models::CotRow;
const CURATED_CODES: &[(&str, &str)] = &[
("GC=F", "088691"), ("SI=F", "084691"), ("PL=F", "076651"), ("HG=F", "085692"), ("CL=F", "067651"), ("NG=F", "03565B"), ("ZC=F", "002602"), ("ZW=F", "001602"), ("ZS=F", "005602"), ];
pub(crate) fn resolve_contract_code(symbol: &str) -> String {
let upper = symbol.trim().to_uppercase();
CURATED_CODES
.iter()
.find(|(root, _)| *root == upper)
.map(|(_, code)| (*code).to_string())
.unwrap_or(upper)
}
fn parse_i64(value: &Option<String>) -> Option<i64> {
value.as_deref().and_then(|v| v.trim().parse::<i64>().ok())
}
fn report_date(row: &CotRow) -> String {
row.report_date_as_yyyy_mm_dd
.as_deref()
.map(|d| d.split('T').next().unwrap_or(d).to_string())
.unwrap_or_default()
}
fn to_observation(row: &CotRow) -> CotObservation {
CotObservation {
report_date: report_date(row),
open_interest: parse_i64(&row.open_interest_all),
producer_merchant_long: parse_i64(&row.prod_merc_positions_long),
producer_merchant_short: parse_i64(&row.prod_merc_positions_short),
swap_dealer_long: parse_i64(&row.swap_positions_long_all),
swap_dealer_short: parse_i64(&row.swap_positions_short_all),
swap_dealer_spread: parse_i64(&row.swap_positions_spread_all),
managed_money_long: parse_i64(&row.m_money_positions_long_all),
managed_money_short: parse_i64(&row.m_money_positions_short_all),
managed_money_spread: parse_i64(&row.m_money_positions_spread),
other_reportable_long: parse_i64(&row.other_rept_positions_long),
other_reportable_short: parse_i64(&row.other_rept_positions_short),
other_reportable_spread: parse_i64(&row.other_rept_positions_spread),
total_reportable_long: parse_i64(&row.tot_rept_positions_long_all),
total_reportable_short: parse_i64(&row.tot_rept_positions_short),
nonreportable_long: parse_i64(&row.nonrept_positions_long_all),
nonreportable_short: parse_i64(&row.nonrept_positions_short_all),
}
}
pub(crate) fn to_canonical(symbol: &str, mut rows: Vec<CotRow>) -> Result<CommitmentsOfTraders> {
if rows.is_empty() {
return Err(FinanceError::SymbolNotFound {
symbol: Some(symbol.to_string()),
context: "CFTC reported no Commitments of Traders rows for this contract code/symbol"
.to_string(),
});
}
rows.reverse();
let market_and_exchange_name = rows
.last()
.and_then(|r| r.market_and_exchange_names.clone())
.unwrap_or_default();
let cftc_contract_market_code = rows
.last()
.and_then(|r| r.cftc_contract_market_code.clone())
.unwrap_or_default();
let observations = rows.iter().map(to_observation).collect();
Ok(CommitmentsOfTraders {
symbol: symbol.to_string(),
market_and_exchange_name,
cftc_contract_market_code,
observations,
})
}
pub async fn fetch_commitments_of_traders_response(symbol: &str) -> Result<CommitmentsOfTraders> {
let code = resolve_contract_code(symbol);
let rows = super::client()?.commitments_of_traders(&code).await?;
to_canonical(symbol, rows)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn curated_root_symbols_resolve_to_their_contract_code() {
assert_eq!(resolve_contract_code("GC=F"), "088691");
assert_eq!(resolve_contract_code("cl=f"), "067651");
assert_eq!(resolve_contract_code(" ng=f "), "03565B");
}
#[test]
fn unrecognised_symbol_passes_through_as_a_literal_contract_code() {
assert_eq!(resolve_contract_code("088691"), "088691");
assert_eq!(resolve_contract_code("06765a"), "06765A");
}
#[test]
fn missing_or_unparseable_figures_are_none_not_zero() {
let row = CotRow {
market_and_exchange_names: None,
cftc_contract_market_code: None,
report_date_as_yyyy_mm_dd: None,
open_interest_all: None,
prod_merc_positions_long: Some("not-a-number".to_string()),
prod_merc_positions_short: None,
swap_positions_long_all: None,
swap_positions_short_all: None,
swap_positions_spread_all: None,
m_money_positions_long_all: None,
m_money_positions_short_all: None,
m_money_positions_spread: None,
other_rept_positions_long: None,
other_rept_positions_short: None,
other_rept_positions_spread: None,
tot_rept_positions_long_all: None,
tot_rept_positions_short: None,
nonrept_positions_long_all: None,
nonrept_positions_short_all: None,
};
let obs = to_observation(&row);
assert_eq!(obs.open_interest, None);
assert_eq!(obs.producer_merchant_long, None);
}
#[test]
fn report_date_trims_the_time_of_day_component() {
let mut row = blank_row();
row.report_date_as_yyyy_mm_dd = Some("2026-07-28T00:00:00.000".to_string());
assert_eq!(report_date(&row), "2026-07-28");
}
#[test]
fn empty_rows_map_to_symbol_not_found() {
let err = to_canonical("NOSUCHCODE", Vec::new()).unwrap_err();
assert!(
matches!(err, FinanceError::SymbolNotFound { .. }),
"{err:?}"
);
}
#[test]
fn rows_are_reversed_into_chronological_order() {
let mut newest = blank_row();
newest.report_date_as_yyyy_mm_dd = Some("2026-07-28T00:00:00.000".to_string());
newest.market_and_exchange_names = Some("GOLD - COMMODITY EXCHANGE INC.".to_string());
newest.cftc_contract_market_code = Some("088691".to_string());
newest.open_interest_all = Some("384603".to_string());
let mut oldest = blank_row();
oldest.report_date_as_yyyy_mm_dd = Some("2026-07-14T00:00:00.000".to_string());
oldest.market_and_exchange_names = Some("GOLD - COMMODITY EXCHANGE INC.".to_string());
oldest.cftc_contract_market_code = Some("088691".to_string());
oldest.open_interest_all = Some("383689".to_string());
let series = to_canonical("GC=F", vec![newest, oldest]).unwrap();
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-14");
assert_eq!(series.observations[1].report_date, "2026-07-28");
}
fn blank_row() -> CotRow {
CotRow {
market_and_exchange_names: None,
cftc_contract_market_code: None,
report_date_as_yyyy_mm_dd: None,
open_interest_all: None,
prod_merc_positions_long: None,
prod_merc_positions_short: None,
swap_positions_long_all: None,
swap_positions_short_all: None,
swap_positions_spread_all: None,
m_money_positions_long_all: None,
m_money_positions_short_all: None,
m_money_positions_spread: None,
other_rept_positions_long: None,
other_rept_positions_short: None,
other_rept_positions_spread: None,
tot_rept_positions_long_all: None,
tot_rept_positions_short: None,
nonrept_positions_long_all: None,
nonrept_positions_short_all: None,
}
}
}