Skip to main content

eli_cli/cmd/finance/
curve.rs

1/// Futures term structure (forward curve) via Yahoo Finance.
2///
3/// Maps commodity names to Yahoo futures ticker patterns, fetches the latest
4/// close for each contract month, and outputs the term structure showing
5/// contango/backwardation.
6
7/// Yahoo futures month codes: F=Jan, G=Feb, H=Mar, J=Apr, K=May, M=Jun,
8/// N=Jul, Q=Aug, U=Sep, V=Oct, X=Nov, Z=Dec
9const 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    /// Yahoo root symbol (e.g. "CL" for WTI crude)
27    pub root: &'static str,
28    /// Yahoo exchange suffix (e.g. ".NYM" for NYMEX)
29    pub exchange: &'static str,
30    /// Human name
31    pub name: &'static str,
32    /// Unit of measure
33    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
118/// Generate tickers for the next N contract months from today.
119///
120/// Walks the *valid* contract calendar for the root symbol so e.g. gold
121/// (Feb/Apr/Jun/Aug/Oct/Dec) doesn't enumerate Jul/Sep/Nov tickers that don't
122/// trade. The single source of truth for which months a root supports is
123/// `futures_curve_months()` in `timeseries.rs`; we fall back to every month
124/// only for roots not in that table.
125pub 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    // Start from next month — current month contract is often expired/rolling
130    let mut month: u32 = now.month() + 1;
131    if month > 12 {
132        month = 1;
133        year += 1;
134    }
135
136    // Pull the valid month set from the shared calendar table in
137    // timeseries.rs. All curve.rs commodities are covered there today; the
138    // fallback to ALL_MONTHS keeps future additions from silently breaking.
139    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    // Walk forward up to ~3 years to collect `months` valid contracts.
145    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    // Handle "all" — fetch every commodity's curve
216    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); // cap at 2 years
237    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    // Try IBKR first (better data), fall back to Yahoo.
253    // IBKR tickers: FUT:CL:NYMEX:YYYYMM
254    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        // Parse month/year from Yahoo ticker to build IBKR expiry
257        // Yahoo: CLK26.NYM → month_code=K, yy=26
258        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    // Try IBKR
267    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            // Check that IBKR actually returned different prices per contract,
280            // not just the front month repeated for every expiry
281            let prices: Vec<f64> = r.series.iter()
282                .filter_map(|s| s.candles.last().map(|c| c.c))
283                .collect();
284            // Use percentage difference to detect front-month-only duplication.
285            // Real curves have >1% spread front-to-back. All-same means IBKR failed to resolve months.
286            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            // Fall back to Yahoo
313            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    // Extract latest close for each ticker.
331    // Match by index position: futures[i] corresponds to resp.series in order,
332    // or by ticker name if provider returns them (Yahoo uses yahoo tickers, IBKR uses IBKR tickers).
333    let mut contracts: Vec<ContractPoint> = Vec::new();
334    for (i, (ticker, label)) in futures.iter().enumerate() {
335        // Try exact Yahoo match first, then exact IBKR match
336        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    // Compute changes from front month
362    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    } // end for loop over queries
406
407    Ok(())
408}