Skip to main content

finance_query/adapters/cftc/futures/
mod.rs

1//! `FUTURES` capability for CFTC — Commitments of Traders positioning.
2//!
3//! CFTC publishes no price quotes, so this adapter only ever serves
4//! [`fetch_commitments_of_traders_response`]; the provider bridge reports
5//! `NotSupported` for a plain futures quote.
6
7use crate::error::{FinanceError, Result};
8use crate::models::futures::cot::{CommitmentsOfTraders, CotObservation};
9
10use super::models::CotRow;
11
12/// Curated root-symbol → CFTC `cftc_contract_market_code` table, covering
13/// the benchmark contract per commodity group in the disaggregated
14/// futures-only report. Keys are the Yahoo-style continuous futures symbols
15/// used elsewhere in this library (e.g. `providers.futures("GC=F")`).
16///
17/// Anything not listed here is treated as a literal CFTC contract code
18/// already — the passthrough form, e.g. `providers.futures("067651")` for
19/// NYMEX WTI directly, or any other code from CFTC's own market list.
20const CURATED_CODES: &[(&str, &str)] = &[
21    ("GC=F", "088691"), // Gold - COMMODITY EXCHANGE INC.
22    ("SI=F", "084691"), // Silver - COMMODITY EXCHANGE INC.
23    ("PL=F", "076651"), // Platinum - NEW YORK MERCANTILE EXCHANGE
24    ("HG=F", "085692"), // Copper- #1 - COMMODITY EXCHANGE INC.
25    ("CL=F", "067651"), // WTI-PHYSICAL - NEW YORK MERCANTILE EXCHANGE
26    ("NG=F", "03565B"), // HENRY HUB - NEW YORK MERCANTILE EXCHANGE
27    ("ZC=F", "002602"), // CORN - CHICAGO BOARD OF TRADE
28    ("ZW=F", "001602"), // WHEAT-SRW - CHICAGO BOARD OF TRADE
29    ("ZS=F", "005602"), // SOYBEANS - CHICAGO BOARD OF TRADE
30];
31
32/// Resolve a futures symbol to a CFTC `cftc_contract_market_code`.
33///
34/// Recognised Yahoo-style continuous futures roots (`"GC=F"`, `"CL=F"`, …)
35/// resolve through [`CURATED_CODES`]; anything else is treated as a literal
36/// contract code, uppercased and trimmed.
37pub(crate) fn resolve_contract_code(symbol: &str) -> String {
38    let upper = symbol.trim().to_uppercase();
39    CURATED_CODES
40        .iter()
41        .find(|(root, _)| *root == upper)
42        .map(|(_, code)| (*code).to_string())
43        .unwrap_or(upper)
44}
45
46/// Parse a Socrata numeric-as-string field into `Option<i64>`. Values arrive
47/// as decimal strings (e.g. `"316244"`); a missing or unparseable field
48/// becomes `None` rather than `0`, so "not reported" is never confused with
49/// "reported as zero".
50fn parse_i64(value: &Option<String>) -> Option<i64> {
51    value.as_deref().and_then(|v| v.trim().parse::<i64>().ok())
52}
53
54/// The report date column arrives as a full timestamp
55/// (`"2026-07-28T00:00:00.000"`); only the date part is documented.
56fn report_date(row: &CotRow) -> String {
57    row.report_date_as_yyyy_mm_dd
58        .as_deref()
59        .map(|d| d.split('T').next().unwrap_or(d).to_string())
60        .unwrap_or_default()
61}
62
63fn to_observation(row: &CotRow) -> CotObservation {
64    CotObservation {
65        report_date: report_date(row),
66        open_interest: parse_i64(&row.open_interest_all),
67        producer_merchant_long: parse_i64(&row.prod_merc_positions_long),
68        producer_merchant_short: parse_i64(&row.prod_merc_positions_short),
69        swap_dealer_long: parse_i64(&row.swap_positions_long_all),
70        swap_dealer_short: parse_i64(&row.swap_positions_short_all),
71        swap_dealer_spread: parse_i64(&row.swap_positions_spread_all),
72        managed_money_long: parse_i64(&row.m_money_positions_long_all),
73        managed_money_short: parse_i64(&row.m_money_positions_short_all),
74        managed_money_spread: parse_i64(&row.m_money_positions_spread),
75        other_reportable_long: parse_i64(&row.other_rept_positions_long),
76        other_reportable_short: parse_i64(&row.other_rept_positions_short),
77        other_reportable_spread: parse_i64(&row.other_rept_positions_spread),
78        total_reportable_long: parse_i64(&row.tot_rept_positions_long_all),
79        total_reportable_short: parse_i64(&row.tot_rept_positions_short),
80        nonreportable_long: parse_i64(&row.nonrept_positions_long_all),
81        nonreportable_short: parse_i64(&row.nonrept_positions_short_all),
82    }
83}
84
85/// Map raw rows (newest-first, the API's own order) onto the canonical
86/// [`CommitmentsOfTraders`], reversed into chronological order to match
87/// every other history-shaped model in the library.
88pub(crate) fn to_canonical(symbol: &str, mut rows: Vec<CotRow>) -> Result<CommitmentsOfTraders> {
89    if rows.is_empty() {
90        return Err(FinanceError::SymbolNotFound {
91            symbol: Some(symbol.to_string()),
92            context: "CFTC reported no Commitments of Traders rows for this contract code/symbol"
93                .to_string(),
94        });
95    }
96    rows.reverse();
97
98    let market_and_exchange_name = rows
99        .last()
100        .and_then(|r| r.market_and_exchange_names.clone())
101        .unwrap_or_default();
102    let cftc_contract_market_code = rows
103        .last()
104        .and_then(|r| r.cftc_contract_market_code.clone())
105        .unwrap_or_default();
106    let observations = rows.iter().map(to_observation).collect();
107
108    Ok(CommitmentsOfTraders {
109        symbol: symbol.to_string(),
110        market_and_exchange_name,
111        cftc_contract_market_code,
112        observations,
113    })
114}
115
116/// Fetch the canonical Commitments of Traders series for a futures symbol.
117pub async fn fetch_commitments_of_traders_response(symbol: &str) -> Result<CommitmentsOfTraders> {
118    let code = resolve_contract_code(symbol);
119    let rows = super::client()?.commitments_of_traders(&code).await?;
120    to_canonical(symbol, rows)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn curated_root_symbols_resolve_to_their_contract_code() {
129        assert_eq!(resolve_contract_code("GC=F"), "088691");
130        assert_eq!(resolve_contract_code("cl=f"), "067651");
131        assert_eq!(resolve_contract_code(" ng=f "), "03565B");
132    }
133
134    #[test]
135    fn unrecognised_symbol_passes_through_as_a_literal_contract_code() {
136        assert_eq!(resolve_contract_code("088691"), "088691");
137        assert_eq!(resolve_contract_code("06765a"), "06765A");
138    }
139
140    #[test]
141    fn missing_or_unparseable_figures_are_none_not_zero() {
142        let row = CotRow {
143            market_and_exchange_names: None,
144            cftc_contract_market_code: None,
145            report_date_as_yyyy_mm_dd: None,
146            open_interest_all: None,
147            prod_merc_positions_long: Some("not-a-number".to_string()),
148            prod_merc_positions_short: None,
149            swap_positions_long_all: None,
150            swap_positions_short_all: None,
151            swap_positions_spread_all: None,
152            m_money_positions_long_all: None,
153            m_money_positions_short_all: None,
154            m_money_positions_spread: None,
155            other_rept_positions_long: None,
156            other_rept_positions_short: None,
157            other_rept_positions_spread: None,
158            tot_rept_positions_long_all: None,
159            tot_rept_positions_short: None,
160            nonrept_positions_long_all: None,
161            nonrept_positions_short_all: None,
162        };
163        let obs = to_observation(&row);
164        assert_eq!(obs.open_interest, None);
165        assert_eq!(obs.producer_merchant_long, None);
166    }
167
168    #[test]
169    fn report_date_trims_the_time_of_day_component() {
170        let mut row = blank_row();
171        row.report_date_as_yyyy_mm_dd = Some("2026-07-28T00:00:00.000".to_string());
172        assert_eq!(report_date(&row), "2026-07-28");
173    }
174
175    #[test]
176    fn empty_rows_map_to_symbol_not_found() {
177        let err = to_canonical("NOSUCHCODE", Vec::new()).unwrap_err();
178        assert!(
179            matches!(err, FinanceError::SymbolNotFound { .. }),
180            "{err:?}"
181        );
182    }
183
184    #[test]
185    fn rows_are_reversed_into_chronological_order() {
186        let mut newest = blank_row();
187        newest.report_date_as_yyyy_mm_dd = Some("2026-07-28T00:00:00.000".to_string());
188        newest.market_and_exchange_names = Some("GOLD - COMMODITY EXCHANGE INC.".to_string());
189        newest.cftc_contract_market_code = Some("088691".to_string());
190        newest.open_interest_all = Some("384603".to_string());
191
192        let mut oldest = blank_row();
193        oldest.report_date_as_yyyy_mm_dd = Some("2026-07-14T00:00:00.000".to_string());
194        oldest.market_and_exchange_names = Some("GOLD - COMMODITY EXCHANGE INC.".to_string());
195        oldest.cftc_contract_market_code = Some("088691".to_string());
196        oldest.open_interest_all = Some("383689".to_string());
197
198        // API order: newest first.
199        let series = to_canonical("GC=F", vec![newest, oldest]).unwrap();
200        assert_eq!(
201            series.market_and_exchange_name,
202            "GOLD - COMMODITY EXCHANGE INC."
203        );
204        assert_eq!(series.cftc_contract_market_code, "088691");
205        assert_eq!(series.observations.len(), 2);
206        assert_eq!(series.observations[0].report_date, "2026-07-14");
207        assert_eq!(series.observations[1].report_date, "2026-07-28");
208    }
209
210    fn blank_row() -> CotRow {
211        CotRow {
212            market_and_exchange_names: None,
213            cftc_contract_market_code: None,
214            report_date_as_yyyy_mm_dd: None,
215            open_interest_all: None,
216            prod_merc_positions_long: None,
217            prod_merc_positions_short: None,
218            swap_positions_long_all: None,
219            swap_positions_short_all: None,
220            swap_positions_spread_all: None,
221            m_money_positions_long_all: None,
222            m_money_positions_short_all: None,
223            m_money_positions_spread: None,
224            other_rept_positions_long: None,
225            other_rept_positions_short: None,
226            other_rept_positions_spread: None,
227            tot_rept_positions_long_all: None,
228            tot_rept_positions_short: None,
229            nonrept_positions_long_all: None,
230            nonrept_positions_short_all: None,
231        }
232    }
233}