1const MONTH_CODES: [(char, &str); 12] = [
10 ('F', "Jan"),
11 ('G', "Feb"),
12 ('H', "Mar"),
13 ('J', "Apr"),
14 ('K', "May"),
15 ('M', "Jun"),
16 ('N', "Jul"),
17 ('Q', "Aug"),
18 ('U', "Sep"),
19 ('V', "Oct"),
20 ('X', "Nov"),
21 ('Z', "Dec"),
22];
23
24#[derive(Debug, Clone)]
25pub struct CommoditySpec {
26 pub root: &'static str,
28 pub exchange: &'static str,
30 pub name: &'static str,
32 pub unit: &'static str,
34}
35
36pub fn lookup_commodity(query: &str) -> Option<CommoditySpec> {
37 let q = query.to_ascii_lowercase();
38 match q.as_str() {
39 "oil" | "crude" | "wti" | "cl" => Some(CommoditySpec {
40 root: "CL",
41 exchange: ".NYM",
42 name: "WTI Crude Oil",
43 unit: "$/bbl",
44 }),
45 "brent" | "bz" => Some(CommoditySpec {
46 root: "BZ",
47 exchange: ".NYM",
48 name: "Brent Crude Oil",
49 unit: "$/bbl",
50 }),
51 "gold" | "gc" => Some(CommoditySpec {
52 root: "GC",
53 exchange: ".CMX",
54 name: "Gold",
55 unit: "$/oz",
56 }),
57 "silver" | "si" => Some(CommoditySpec {
58 root: "SI",
59 exchange: ".CMX",
60 name: "Silver",
61 unit: "$/oz",
62 }),
63 "natgas" | "gas" | "ng" | "natural gas" => Some(CommoditySpec {
64 root: "NG",
65 exchange: ".NYM",
66 name: "Natural Gas",
67 unit: "$/MMBtu",
68 }),
69 "copper" | "hg" => Some(CommoditySpec {
70 root: "HG",
71 exchange: ".CMX",
72 name: "Copper",
73 unit: "$/lb",
74 }),
75 "platinum" | "pl" => Some(CommoditySpec {
76 root: "PL",
77 exchange: ".NYM",
78 name: "Platinum",
79 unit: "$/oz",
80 }),
81 "palladium" | "pa" => Some(CommoditySpec {
82 root: "PA",
83 exchange: ".NYM",
84 name: "Palladium",
85 unit: "$/oz",
86 }),
87 "rbob" | "gasoline" | "rb" => Some(CommoditySpec {
88 root: "RB",
89 exchange: ".NYM",
90 name: "RBOB Gasoline",
91 unit: "$/gal",
92 }),
93 "heating" | "ho" | "heating oil" => Some(CommoditySpec {
94 root: "HO",
95 exchange: ".NYM",
96 name: "Heating Oil",
97 unit: "$/gal",
98 }),
99 _ => None,
100 }
101}
102
103pub fn list_commodities() -> Vec<(&'static str, &'static str, &'static str)> {
104 vec![
105 ("oil / crude / wti", "CL", "WTI Crude Oil"),
106 ("brent", "BZ", "Brent Crude Oil"),
107 ("gold", "GC", "Gold"),
108 ("silver", "SI", "Silver"),
109 ("natgas / gas", "NG", "Natural Gas"),
110 ("copper", "HG", "Copper"),
111 ("platinum", "PL", "Platinum"),
112 ("palladium", "PA", "Palladium"),
113 ("rbob / gasoline", "RB", "RBOB Gasoline"),
114 ("heating / ho", "HO", "Heating Oil"),
115 ]
116}
117
118pub fn generate_futures_tickers(spec: &CommoditySpec, months: usize) -> Vec<(String, String)> {
126 use chrono::Datelike;
127 let now = chrono::Utc::now();
128 let mut year = now.year() as i32;
129 let mut month: u32 = now.month() + 1;
131 if month > 12 {
132 month = 1;
133 year += 1;
134 }
135
136 let valid_months: &[u32] = futures_curve_months(spec.root)
140 .map(|(_exchange, m)| m)
141 .unwrap_or(ALL_MONTHS);
142
143 let mut tickers = Vec::with_capacity(months);
144 for _ in 0..36 {
146 if tickers.len() >= months {
147 break;
148 }
149 if valid_months.contains(&month) {
150 let (code, label) = MONTH_CODES[(month - 1) as usize];
151 let yy = year % 100;
152 let ticker = format!("{}{}{:02}{}", spec.root, code, yy, spec.exchange);
153 let contract_label = format!("{} {}", label, year);
154 tickers.push((ticker, contract_label));
155 }
156 month += 1;
157 if month > 12 {
158 month = 1;
159 year += 1;
160 }
161 }
162 tickers
163}
164
165#[derive(Serialize)]
166struct CurveResponse {
167 commodity: String,
168 unit: String,
169 generated_at: String,
170 front_month_price: Option<f64>,
171 back_month_price: Option<f64>,
172 spread: Option<f64>,
173 spread_pct: Option<f64>,
174 contracts: Vec<ContractPoint>,
175}
176
177#[derive(Serialize)]
178struct ContractPoint {
179 ticker: String,
180 contract: String,
181 price: f64,
182 change_from_front: Option<f64>,
183 change_from_front_pct: Option<f64>,
184}
185
186async fn cmd_finance_curve(args: FinanceCurveArgs) -> Result<()> {
187 if args.list {
188 let commodities = list_commodities();
189 let out = serde_json::json!({
190 "commodities": commodities.iter().map(|(aliases, root, name)| {
191 serde_json::json!({
192 "aliases": aliases,
193 "root_symbol": root,
194 "name": name,
195 })
196 }).collect::<Vec<_>>()
197 });
198 let pretty = serde_json::to_string_pretty(&out).unwrap();
199 if let Some(out_path) = args.out {
200 std::fs::write(&out_path, &pretty)
201 .map_err(|e| anyhow::anyhow!("failed to write {}: {}", out_path.display(), e))?;
202 println!(
203 "{{\"ok\":true,\"path\":{}}}",
204 serde_json::to_string(&out_path.display().to_string())
205 .unwrap_or_else(|_| "\"\"".to_string())
206 );
207 } else {
208 println!("{}", pretty);
209 }
210 return Ok(());
211 }
212
213 let query = args.commodity.as_deref().unwrap_or("oil");
214
215 let queries: Vec<String> = if query == "all" {
217 list_commodities()
218 .iter()
219 .map(|(aliases, _, _)| aliases.split(" / ").next().unwrap_or(aliases).to_string())
220 .collect()
221 } else {
222 vec![query.to_string()]
223 };
224
225 for (idx, q) in queries.iter().enumerate() {
226 let spec = lookup_commodity(q).ok_or_else(|| {
227 let commodities = list_commodities();
228 let names: Vec<&str> = commodities.iter().map(|(a, _, _)| *a).collect();
229 anyhow::anyhow!(
230 "unknown commodity '{}'. Supported: {}, all. Use --list to see all.",
231 q,
232 names.join(", ")
233 )
234 })?;
235
236 let months = args.months.min(24); let futures = generate_futures_tickers(&spec, months);
238
239 let tickers_str: Vec<String> = futures.iter().map(|(t, _)| t.clone()).collect();
240 let all_tickers = tickers_str.join(",");
241
242 let range = eli_core::finance::Span::parse("5d")
243 .map_err(|e| anyhow::anyhow!(e))
244 .context("parse range")?;
245 let granularity = eli_core::finance::Span::parse("1d")
246 .map_err(|e| anyhow::anyhow!(e))
247 .context("parse granularity")?;
248
249 let paths = Paths::discover().context("discover paths")?;
250 paths.ensure_dirs().context("ensure dirs")?;
251
252 let ibkr_exchange = spec.exchange.trim_start_matches('.').replace("NYM", "NYMEX").replace("CMX", "COMEX");
255 let ibkr_tickers: Vec<String> = futures.iter().map(|(yahoo_t, _)| {
256 let root = spec.root;
259 let rest = yahoo_t.strip_prefix(root).unwrap_or(yahoo_t);
260 let month_code = rest.chars().next().unwrap_or('F');
261 let yy: u32 = rest[1..3].parse().unwrap_or(26);
262 let month_num = MONTH_CODES.iter().position(|(c, _)| *c == month_code).map(|i| i + 1).unwrap_or(1);
263 format!("FUT:{}:{}:{}{:02}", root, ibkr_exchange, 2000 + yy, month_num)
264 }).collect();
265
266 let ibkr_req = eli_core::finance::TimeseriesRequest {
268 tickers: ibkr_tickers.clone(),
269 range: range.clone(),
270 granularity: granularity.clone(),
271 as_of: None,
272 provider: eli_core::finance::ProviderKind::Ibkr,
273 max_points_per_ticker: None,
274 ibkr: None,
275 };
276
277 let resp = match eli_core::finance::fetch_timeseries(ibkr_req, &paths.cache_dir).await {
278 Ok(r) if !r.series.is_empty() => {
279 let prices: Vec<f64> = r.series.iter()
282 .filter_map(|s| s.candles.last().map(|c| c.c))
283 .collect();
284 let spread_pct = if let (Some(&first), Some(&last)) = (prices.first(), prices.last()) {
287 if first > 0.0 { ((last - first) / first).abs() * 100.0 } else { 0.0 }
288 } else { 0.0 };
289 let all_same = prices.len() > 1 && spread_pct < 0.1;
290 eprintln!("[curve] IBKR prices: {:?} spread={:.2}% all_same={}", prices, spread_pct, all_same);
291 if all_same {
292 eprintln!("[curve] IBKR returned same price for all months (front-month only), falling back to Yahoo for {}", spec.name);
293 let yahoo_req = eli_core::finance::TimeseriesRequest {
294 tickers: tickers_str.clone(),
295 range,
296 granularity,
297 as_of: None,
298 provider: eli_core::finance::ProviderKind::Yahoo,
299 max_points_per_ticker: None,
300 ibkr: None,
301 };
302 eli_core::finance::fetch_timeseries(yahoo_req, &paths.cache_dir)
303 .await
304 .map_err(|e| anyhow::anyhow!(e))
305 .context("fetch futures timeseries")?
306 } else {
307 eprintln!("[curve] using IBKR for {} ({} series)", spec.name, r.series.len());
308 r
309 }
310 }
311 _ => {
312 eprintln!("[curve] IBKR unavailable, falling back to Yahoo for {}", spec.name);
314 let yahoo_req = eli_core::finance::TimeseriesRequest {
315 tickers: tickers_str.clone(),
316 range,
317 granularity,
318 as_of: None,
319 provider: eli_core::finance::ProviderKind::Yahoo,
320 max_points_per_ticker: None,
321 ibkr: None,
322 };
323 eli_core::finance::fetch_timeseries(yahoo_req, &paths.cache_dir)
324 .await
325 .map_err(|e| anyhow::anyhow!(e))
326 .context("fetch futures timeseries")?
327 }
328 };
329
330 let mut contracts: Vec<ContractPoint> = Vec::new();
334 for (i, (ticker, label)) in futures.iter().enumerate() {
335 let series = resp.series.iter().find(|s| &s.ticker == ticker)
337 .or_else(|| {
338 if i < ibkr_tickers.len() {
339 resp.series.iter().find(|s| s.ticker == ibkr_tickers[i])
340 } else {
341 None
342 }
343 });
344 if let Some(series) = series {
345 if let Some(last_candle) = series.candles.last() {
346 contracts.push(ContractPoint {
347 ticker: ticker.clone(),
348 contract: label.clone(),
349 price: last_candle.c,
350 change_from_front: None,
351 change_from_front_pct: None,
352 });
353 }
354 }
355 }
356
357 if contracts.is_empty() {
358 anyhow::bail!("no futures data returned for {} ({})", spec.name, all_tickers);
359 }
360
361 let front_price = contracts[0].price;
363 for c in contracts.iter_mut() {
364 let diff = c.price - front_price;
365 c.change_from_front = Some(diff);
366 c.change_from_front_pct = Some(diff / front_price * 100.0);
367 }
368
369 let back_price = contracts.last().map(|c| c.price);
370 let spread = back_price.map(|b| b - front_price);
371 let spread_pct = back_price.map(|b| (b - front_price) / front_price * 100.0);
372
373 let response = CurveResponse {
374 commodity: spec.name.to_string(),
375 unit: spec.unit.to_string(),
376 generated_at: chrono::Utc::now()
377 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
378 front_month_price: Some(front_price),
379 back_month_price: back_price,
380 spread,
381 spread_pct,
382 contracts,
383 };
384
385 if let Some(ref out_path) = args.out {
386 let wr = write_json_out_with_meta(
387 out_path.clone(),
388 &response,
389 "finance.curve",
390 &[format!("commodity={}", q)],
391 )?;
392 println!(
393 "{{\"ok\":true,\"path\":{},\"meta_path\":{}}}",
394 serde_json::to_string(&wr.out_path.display().to_string())
395 .unwrap_or_else(|_| "\"\"".to_string()),
396 serde_json::to_string(&wr.meta_path.display().to_string())
397 .unwrap_or_else(|_| "\"\"".to_string()),
398 );
399 } else {
400 let json =
401 serde_json::to_string_pretty(&response).context("serialize curve response")?;
402 println!("{json}");
403 }
404
405 } Ok(())
408}