finance_query/adapters/cftc/futures/
mod.rs1use crate::error::{FinanceError, Result};
8use crate::models::futures::cot::{CommitmentsOfTraders, CotObservation};
9
10use super::models::CotRow;
11
12const CURATED_CODES: &[(&str, &str)] = &[
21 ("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"), ];
31
32pub(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
46fn parse_i64(value: &Option<String>) -> Option<i64> {
51 value.as_deref().and_then(|v| v.trim().parse::<i64>().ok())
52}
53
54fn 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
85pub(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
116pub 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 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}