eli-cli 0.2.4

Internal CLI library for the `market-search` binary. Use `cargo install market-search`.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
/// Futures term structure (forward curve) via Yahoo Finance.
///
/// Maps commodity names to Yahoo futures ticker patterns, fetches the latest
/// close for each contract month, and outputs the term structure showing
/// contango/backwardation.

/// Yahoo futures month codes: F=Jan, G=Feb, H=Mar, J=Apr, K=May, M=Jun,
/// N=Jul, Q=Aug, U=Sep, V=Oct, X=Nov, Z=Dec
const MONTH_CODES: [(char, &str); 12] = [
    ('F', "Jan"),
    ('G', "Feb"),
    ('H', "Mar"),
    ('J', "Apr"),
    ('K', "May"),
    ('M', "Jun"),
    ('N', "Jul"),
    ('Q', "Aug"),
    ('U', "Sep"),
    ('V', "Oct"),
    ('X', "Nov"),
    ('Z', "Dec"),
];

#[derive(Debug, Clone)]
pub struct CommoditySpec {
    /// Yahoo root symbol (e.g. "CL" for WTI crude)
    pub root: &'static str,
    /// Yahoo exchange suffix (e.g. ".NYM" for NYMEX)
    pub exchange: &'static str,
    /// Human name
    pub name: &'static str,
    /// Unit of measure
    pub unit: &'static str,
}

pub fn lookup_commodity(query: &str) -> Option<CommoditySpec> {
    let q = query.to_ascii_lowercase();
    match q.as_str() {
        "oil" | "crude" | "wti" | "cl" => Some(CommoditySpec {
            root: "CL",
            exchange: ".NYM",
            name: "WTI Crude Oil",
            unit: "$/bbl",
        }),
        "brent" | "bz" => Some(CommoditySpec {
            root: "BZ",
            exchange: ".NYM",
            name: "Brent Crude Oil",
            unit: "$/bbl",
        }),
        "gold" | "gc" => Some(CommoditySpec {
            root: "GC",
            exchange: ".CMX",
            name: "Gold",
            unit: "$/oz",
        }),
        "silver" | "si" => Some(CommoditySpec {
            root: "SI",
            exchange: ".CMX",
            name: "Silver",
            unit: "$/oz",
        }),
        "natgas" | "gas" | "ng" | "natural gas" => Some(CommoditySpec {
            root: "NG",
            exchange: ".NYM",
            name: "Natural Gas",
            unit: "$/MMBtu",
        }),
        "copper" | "hg" => Some(CommoditySpec {
            root: "HG",
            exchange: ".CMX",
            name: "Copper",
            unit: "$/lb",
        }),
        "platinum" | "pl" => Some(CommoditySpec {
            root: "PL",
            exchange: ".NYM",
            name: "Platinum",
            unit: "$/oz",
        }),
        "palladium" | "pa" => Some(CommoditySpec {
            root: "PA",
            exchange: ".NYM",
            name: "Palladium",
            unit: "$/oz",
        }),
        "rbob" | "gasoline" | "rb" => Some(CommoditySpec {
            root: "RB",
            exchange: ".NYM",
            name: "RBOB Gasoline",
            unit: "$/gal",
        }),
        "heating" | "ho" | "heating oil" => Some(CommoditySpec {
            root: "HO",
            exchange: ".NYM",
            name: "Heating Oil",
            unit: "$/gal",
        }),
        _ => None,
    }
}

pub fn list_commodities() -> Vec<(&'static str, &'static str, &'static str)> {
    vec![
        ("oil / crude / wti", "CL", "WTI Crude Oil"),
        ("brent", "BZ", "Brent Crude Oil"),
        ("gold", "GC", "Gold"),
        ("silver", "SI", "Silver"),
        ("natgas / gas", "NG", "Natural Gas"),
        ("copper", "HG", "Copper"),
        ("platinum", "PL", "Platinum"),
        ("palladium", "PA", "Palladium"),
        ("rbob / gasoline", "RB", "RBOB Gasoline"),
        ("heating / ho", "HO", "Heating Oil"),
    ]
}

/// Generate tickers for the next N contract months from today.
///
/// Walks the *valid* contract calendar for the root symbol so e.g. gold
/// (Feb/Apr/Jun/Aug/Oct/Dec) doesn't enumerate Jul/Sep/Nov tickers that don't
/// trade. The single source of truth for which months a root supports is
/// `futures_curve_months()` in `timeseries.rs`; we fall back to every month
/// only for roots not in that table.
pub fn generate_futures_tickers(spec: &CommoditySpec, months: usize) -> Vec<(String, String)> {
    use chrono::Datelike;
    let now = chrono::Utc::now();
    let mut year = now.year() as i32;
    // Start from next month — current month contract is often expired/rolling
    let mut month: u32 = now.month() + 1;
    if month > 12 {
        month = 1;
        year += 1;
    }

    // Pull the valid month set from the shared calendar table in
    // timeseries.rs. All curve.rs commodities are covered there today; the
    // fallback to ALL_MONTHS keeps future additions from silently breaking.
    let valid_months: &[u32] = futures_curve_months(spec.root)
        .map(|(_exchange, m)| m)
        .unwrap_or(ALL_MONTHS);

    let mut tickers = Vec::with_capacity(months);
    // Walk forward up to ~3 years to collect `months` valid contracts.
    for _ in 0..36 {
        if tickers.len() >= months {
            break;
        }
        if valid_months.contains(&month) {
            let (code, label) = MONTH_CODES[(month - 1) as usize];
            let yy = year % 100;
            let ticker = format!("{}{}{:02}{}", spec.root, code, yy, spec.exchange);
            let contract_label = format!("{} {}", label, year);
            tickers.push((ticker, contract_label));
        }
        month += 1;
        if month > 12 {
            month = 1;
            year += 1;
        }
    }
    tickers
}

#[derive(Serialize)]
struct CurveResponse {
    commodity: String,
    unit: String,
    generated_at: String,
    front_month_price: Option<f64>,
    back_month_price: Option<f64>,
    spread: Option<f64>,
    spread_pct: Option<f64>,
    contracts: Vec<ContractPoint>,
}

#[derive(Serialize)]
struct ContractPoint {
    ticker: String,
    contract: String,
    price: f64,
    change_from_front: Option<f64>,
    change_from_front_pct: Option<f64>,
}

async fn cmd_finance_curve(args: FinanceCurveArgs) -> Result<()> {
    if args.list {
        let commodities = list_commodities();
        let out = serde_json::json!({
            "commodities": commodities.iter().map(|(aliases, root, name)| {
                serde_json::json!({
                    "aliases": aliases,
                    "root_symbol": root,
                    "name": name,
                })
            }).collect::<Vec<_>>()
        });
        let pretty = serde_json::to_string_pretty(&out).unwrap();
        if let Some(out_path) = args.out {
            std::fs::write(&out_path, &pretty)
                .map_err(|e| anyhow::anyhow!("failed to write {}: {}", out_path.display(), e))?;
            println!(
                "{{\"ok\":true,\"path\":{}}}",
                serde_json::to_string(&out_path.display().to_string())
                    .unwrap_or_else(|_| "\"\"".to_string())
            );
        } else {
            println!("{}", pretty);
        }
        return Ok(());
    }

    let query = args.commodity.as_deref().unwrap_or("oil");

    // Handle "all" — fetch every commodity's curve
    let queries: Vec<String> = if query == "all" {
        list_commodities()
            .iter()
            .map(|(aliases, _, _)| aliases.split(" / ").next().unwrap_or(aliases).to_string())
            .collect()
    } else {
        vec![query.to_string()]
    };

    for (idx, q) in queries.iter().enumerate() {
    let spec = lookup_commodity(q).ok_or_else(|| {
        let commodities = list_commodities();
        let names: Vec<&str> = commodities.iter().map(|(a, _, _)| *a).collect();
        anyhow::anyhow!(
            "unknown commodity '{}'. Supported: {}, all. Use --list to see all.",
            q,
            names.join(", ")
        )
    })?;

    let months = args.months.min(24); // cap at 2 years
    let futures = generate_futures_tickers(&spec, months);

    let tickers_str: Vec<String> = futures.iter().map(|(t, _)| t.clone()).collect();
    let all_tickers = tickers_str.join(",");

    let range = eli_core::finance::Span::parse("5d")
        .map_err(|e| anyhow::anyhow!(e))
        .context("parse range")?;
    let granularity = eli_core::finance::Span::parse("1d")
        .map_err(|e| anyhow::anyhow!(e))
        .context("parse granularity")?;

    let paths = Paths::discover().context("discover paths")?;
    paths.ensure_dirs().context("ensure dirs")?;

    // Try IBKR first (better data), fall back to Yahoo.
    // IBKR tickers: FUT:CL:NYMEX:YYYYMM
    let ibkr_exchange = spec.exchange.trim_start_matches('.').replace("NYM", "NYMEX").replace("CMX", "COMEX");
    let ibkr_tickers: Vec<String> = futures.iter().map(|(yahoo_t, _)| {
        // Parse month/year from Yahoo ticker to build IBKR expiry
        // Yahoo: CLK26.NYM → month_code=K, yy=26
        let root = spec.root;
        let rest = yahoo_t.strip_prefix(root).unwrap_or(yahoo_t);
        let month_code = rest.chars().next().unwrap_or('F');
        let yy: u32 = rest[1..3].parse().unwrap_or(26);
        let month_num = MONTH_CODES.iter().position(|(c, _)| *c == month_code).map(|i| i + 1).unwrap_or(1);
        format!("FUT:{}:{}:{}{:02}", root, ibkr_exchange, 2000 + yy, month_num)
    }).collect();

    // Try IBKR
    let ibkr_req = eli_core::finance::TimeseriesRequest {
        tickers: ibkr_tickers.clone(),
        range: range.clone(),
        granularity: granularity.clone(),
        as_of: None,
        provider: eli_core::finance::ProviderKind::Ibkr,
        max_points_per_ticker: None,
        ibkr: None,
    };

    let resp = match eli_core::finance::fetch_timeseries(ibkr_req, &paths.cache_dir).await {
        Ok(r) if !r.series.is_empty() => {
            // Check that IBKR actually returned different prices per contract,
            // not just the front month repeated for every expiry
            let prices: Vec<f64> = r.series.iter()
                .filter_map(|s| s.candles.last().map(|c| c.c))
                .collect();
            // Use percentage difference to detect front-month-only duplication.
            // Real curves have >1% spread front-to-back. All-same means IBKR failed to resolve months.
            let spread_pct = if let (Some(&first), Some(&last)) = (prices.first(), prices.last()) {
                if first > 0.0 { ((last - first) / first).abs() * 100.0 } else { 0.0 }
            } else { 0.0 };
            let all_same = prices.len() > 1 && spread_pct < 0.1;
            eprintln!("[curve] IBKR prices: {:?} spread={:.2}% all_same={}", prices, spread_pct, all_same);
            if all_same {
                eprintln!("[curve] IBKR returned same price for all months (front-month only), falling back to Yahoo for {}", spec.name);
                let yahoo_req = eli_core::finance::TimeseriesRequest {
                    tickers: tickers_str.clone(),
                    range,
                    granularity,
                    as_of: None,
                    provider: eli_core::finance::ProviderKind::Yahoo,
                    max_points_per_ticker: None,
                    ibkr: None,
                };
                eli_core::finance::fetch_timeseries(yahoo_req, &paths.cache_dir)
                    .await
                    .map_err(|e| anyhow::anyhow!(e))
                    .context("fetch futures timeseries")?
            } else {
                eprintln!("[curve] using IBKR for {} ({} series)", spec.name, r.series.len());
                r
            }
        }
        _ => {
            // Fall back to Yahoo
            eprintln!("[curve] IBKR unavailable, falling back to Yahoo for {}", spec.name);
            let yahoo_req = eli_core::finance::TimeseriesRequest {
                tickers: tickers_str.clone(),
                range,
                granularity,
                as_of: None,
                provider: eli_core::finance::ProviderKind::Yahoo,
                max_points_per_ticker: None,
                ibkr: None,
            };
            eli_core::finance::fetch_timeseries(yahoo_req, &paths.cache_dir)
                .await
                .map_err(|e| anyhow::anyhow!(e))
                .context("fetch futures timeseries")?
        }
    };

    // Extract latest close for each ticker.
    // Match by index position: futures[i] corresponds to resp.series in order,
    // or by ticker name if provider returns them (Yahoo uses yahoo tickers, IBKR uses IBKR tickers).
    let mut contracts: Vec<ContractPoint> = Vec::new();
    for (i, (ticker, label)) in futures.iter().enumerate() {
        // Try exact Yahoo match first, then exact IBKR match
        let series = resp.series.iter().find(|s| &s.ticker == ticker)
            .or_else(|| {
                if i < ibkr_tickers.len() {
                    resp.series.iter().find(|s| s.ticker == ibkr_tickers[i])
                } else {
                    None
                }
            });
        if let Some(series) = series {
            if let Some(last_candle) = series.candles.last() {
                contracts.push(ContractPoint {
                    ticker: ticker.clone(),
                    contract: label.clone(),
                    price: last_candle.c,
                    change_from_front: None,
                    change_from_front_pct: None,
                });
            }
        }
    }

    if contracts.is_empty() {
        anyhow::bail!("no futures data returned for {} ({})", spec.name, all_tickers);
    }

    // Compute changes from front month
    let front_price = contracts[0].price;
    for c in contracts.iter_mut() {
        let diff = c.price - front_price;
        c.change_from_front = Some(diff);
        c.change_from_front_pct = Some(diff / front_price * 100.0);
    }

    let back_price = contracts.last().map(|c| c.price);
    let spread = back_price.map(|b| b - front_price);
    let spread_pct = back_price.map(|b| (b - front_price) / front_price * 100.0);

    let response = CurveResponse {
        commodity: spec.name.to_string(),
        unit: spec.unit.to_string(),
        generated_at: chrono::Utc::now()
            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
        front_month_price: Some(front_price),
        back_month_price: back_price,
        spread,
        spread_pct,
        contracts,
    };

    if let Some(ref out_path) = args.out {
        let wr = write_json_out_with_meta(
            out_path.clone(),
            &response,
            "finance.curve",
            &[format!("commodity={}", q)],
        )?;
        println!(
            "{{\"ok\":true,\"path\":{},\"meta_path\":{}}}",
            serde_json::to_string(&wr.out_path.display().to_string())
                .unwrap_or_else(|_| "\"\"".to_string()),
            serde_json::to_string(&wr.meta_path.display().to_string())
                .unwrap_or_else(|_| "\"\"".to_string()),
        );
    } else {
        let json =
            serde_json::to_string_pretty(&response).context("serialize curve response")?;
        println!("{json}");
    }

    } // end for loop over queries

    Ok(())
}