Skip to main content

schwab_cli/
market_info.rs

1//! Agent-oriented market snapshot: quote + fundamentals + price context + research hints.
2
3use std::collections::HashMap;
4
5use anyhow::{Context, Result};
6use schwab_market_data::MarketDataApi;
7use serde_json::{json, Value};
8
9#[derive(Debug, Clone)]
10pub struct InfoOptions {
11    pub include_history: bool,
12    pub history_period_type: String,
13    pub history_period: u32,
14    pub history_frequency_type: String,
15}
16
17impl Default for InfoOptions {
18    fn default() -> Self {
19        Self {
20            include_history: true,
21            history_period_type: "month".to_string(),
22            history_period: 1,
23            history_frequency_type: "daily".to_string(),
24        }
25    }
26}
27
28pub async fn build_symbol_info(
29    api: &MarketDataApi,
30    symbol: &str,
31    options: &InfoOptions,
32) -> Result<Value> {
33    let symbol = symbol.trim().to_uppercase();
34
35    let quote_raw = api
36        .quotes()
37        .get_quote(&symbol, Some("all"), None)
38        .await
39        .with_context(|| format!("Failed to fetch quote for {symbol}"))?;
40
41    let instrument_raw = api
42        .instruments()
43        .search(&symbol, "fundamental")
44        .await
45        .with_context(|| format!("Failed to fetch instrument fundamentals for {symbol}"))?;
46
47    let quote_entry = extract_quote_entry(&quote_raw, &symbol);
48    let instrument_entry = extract_instrument_entry(&instrument_raw, &symbol);
49
50    let asset_main = quote_entry
51        .get("assetMainType")
52        .or_else(|| instrument_entry.get("assetType"))
53        .and_then(|v| v.as_str())
54        .unwrap_or("UNKNOWN")
55        .to_string();
56
57    let asset_sub = quote_entry
58        .get("assetSubType")
59        .and_then(|v| v.as_str())
60        .map(str::to_string);
61
62    let reference = quote_entry.get("reference").cloned().unwrap_or(json!({}));
63    let quote = quote_entry.get("quote").cloned().unwrap_or(json!({}));
64    let regular = quote_entry.get("regular").cloned().unwrap_or(json!({}));
65    let quote_fundamental = quote_entry.get("fundamental").cloned().unwrap_or(json!({}));
66    let instrument_fundamental = instrument_entry
67        .get("fundamental")
68        .cloned()
69        .unwrap_or(json!({}));
70
71    let identity = json!({
72        "symbol": symbol,
73        "description": reference.get("description")
74            .or(instrument_entry.get("description")),
75        "cusip": reference.get("cusip").or(instrument_entry.get("cusip")),
76        "exchange": reference.get("exchangeName")
77            .or(reference.get("exchange"))
78            .or(instrument_entry.get("exchange")),
79        "assetMainType": asset_main,
80        "assetSubType": asset_sub,
81        "optionable": reference.get("optionable"),
82        "shortable": reference.get("isShortable"),
83    });
84
85    let fundamentals = merge_objects(quote_fundamental, instrument_fundamental);
86
87    let mut price_context = json!(null);
88    if options.include_history {
89        let history = api
90            .price_history()
91            .get(
92                &symbol,
93                Some(options.history_period_type.as_str()),
94                Some(options.history_period),
95                Some(options.history_frequency_type.as_str()),
96                None,
97                None,
98                None,
99                None,
100                Some(true),
101            )
102            .await
103            .with_context(|| format!("Failed to fetch price history for {symbol}"))?;
104        price_context = summarize_history(&history, options);
105    }
106
107    let research_hints = build_research_hints(&symbol, &identity, &fundamentals, &price_context);
108
109    Ok(json!({
110        "symbol": symbol,
111        "identity": identity,
112        "quote": quote,
113        "regularSession": regular,
114        "fundamentals": fundamentals,
115        "priceContext": price_context,
116        "researchHints": research_hints,
117        "sources": {
118            "quote": "GET /{symbol}/quotes?fields=all",
119            "fundamentals": "GET /instruments?projection=fundamental",
120            "priceHistory": if options.include_history {
121                Value::String(format!(
122                    "GET /pricehistory?periodType={}&period={}&frequencyType={}",
123                    options.history_period_type, options.history_period, options.history_frequency_type
124                ))
125            } else {
126                Value::Null
127            }
128        }
129    }))
130}
131
132pub async fn build_info_dossier(
133    api: &MarketDataApi,
134    symbols: &[String],
135    options: InfoOptions,
136) -> Result<Value> {
137    let mut entries = Vec::new();
138    for symbol in symbols {
139        entries.push(build_symbol_info(api, symbol, &options).await?);
140    }
141    Ok(json!({
142        "count": entries.len(),
143        "symbols": entries,
144        "agentNote": "Use Schwab data for live prices, dividends, and ratios. Use researchHints.recommendedWebQueries for qualitative context (holdings, sector, news, alternatives) before trade plans."
145    }))
146}
147
148fn extract_quote_entry(raw: &Value, symbol: &str) -> Value {
149    if let Some(entry) = raw.get(symbol) {
150        return entry.clone();
151    }
152    if let Some(obj) = raw.as_object() {
153        if obj.len() == 1 {
154            return obj.values().next().cloned().unwrap_or(json!({}));
155        }
156    }
157    json!({})
158}
159
160fn extract_instrument_entry(raw: &Value, symbol: &str) -> Value {
161    let Some(list) = raw.get("instruments").and_then(|v| v.as_array()) else {
162        return json!({});
163    };
164    list.iter()
165        .find(|i| {
166            i.get("symbol")
167                .and_then(|s| s.as_str())
168                .is_some_and(|s| s.eq_ignore_ascii_case(symbol))
169        })
170        .cloned()
171        .unwrap_or_else(|| list.first().cloned().unwrap_or(json!({})))
172}
173
174fn merge_objects(a: Value, b: Value) -> Value {
175    let mut out = HashMap::<String, Value>::new();
176    for obj in [a, b] {
177        if let Some(map) = obj.as_object() {
178            for (k, v) in map {
179                if !v.is_null() {
180                    out.insert(k.clone(), v.clone());
181                }
182            }
183        }
184    }
185    serde_json::to_value(out).unwrap_or(json!({}))
186}
187
188fn summarize_history(history: &Value, options: &InfoOptions) -> Value {
189    let candles = history
190        .get("candles")
191        .and_then(|c| c.as_array())
192        .cloned()
193        .unwrap_or_default();
194
195    if candles.is_empty() {
196        return json!({
197            "empty": true,
198            "periodType": options.history_period_type,
199            "period": options.history_period,
200            "frequencyType": options.history_frequency_type,
201        });
202    }
203
204    let first = &candles[0];
205    let last = candles.last().unwrap();
206    let first_close = first.get("close").and_then(|v| v.as_f64()).unwrap_or(0.0);
207    let last_close = last.get("close").and_then(|v| v.as_f64()).unwrap_or(0.0);
208    let change = last_close - first_close;
209    let change_pct = if first_close.abs() > f64::EPSILON {
210        (change / first_close) * 100.0
211    } else {
212        0.0
213    };
214
215    let (high, low) = candles.iter().fold((f64::MIN, f64::MAX), |(hi, lo), c| {
216        let h = c.get("high").and_then(|v| v.as_f64()).unwrap_or(hi);
217        let l = c.get("low").and_then(|v| v.as_f64()).unwrap_or(lo);
218        (hi.max(h), lo.min(l))
219    });
220
221    let avg_volume = candles
222        .iter()
223        .filter_map(|c| c.get("volume").and_then(|v| v.as_f64()))
224        .sum::<f64>()
225        / candles.len() as f64;
226
227    let previous_close = history.get("previousClose");
228    let recent = candles
229        .iter()
230        .rev()
231        .take(5)
232        .map(|c| {
233            json!({
234                "datetime": c.get("datetime"),
235                "close": c.get("close"),
236                "volume": c.get("volume"),
237            })
238        })
239        .collect::<Vec<_>>();
240
241    json!({
242        "empty": false,
243        "periodType": options.history_period_type,
244        "period": options.history_period,
245        "frequencyType": options.history_frequency_type,
246        "candleCount": candles.len(),
247        "firstClose": first_close,
248        "lastClose": last_close,
249        "change": change,
250        "changePercent": change_pct,
251        "rangeHigh": high,
252        "rangeLow": low,
253        "averageVolume": avg_volume,
254        "previousClose": previous_close,
255        "recentCandles": recent,
256    })
257}
258
259fn build_research_hints(
260    symbol: &str,
261    identity: &Value,
262    fundamentals: &Value,
263    price_context: &Value,
264) -> Value {
265    let description = identity
266        .get("description")
267        .and_then(|v| v.as_str())
268        .unwrap_or(symbol);
269    let asset_main = identity
270        .get("assetMainType")
271        .and_then(|v| v.as_str())
272        .unwrap_or("UNKNOWN");
273    let asset_sub = identity.get("assetSubType").and_then(|v| v.as_str());
274
275    let asset_class = classify_asset(asset_main, asset_sub);
276    let div_yield = fundamentals
277        .get("dividendYield")
278        .or_else(|| fundamentals.get("divYield"))
279        .and_then(|v| v.as_f64());
280
281    let mut recommended_queries = vec![
282        format!("{description} ({symbol}) latest news and outlook"),
283        format!("{symbol} key risks and recent performance"),
284    ];
285
286    let data_gaps = vec![
287        "Schwab API does not provide business narrative, management, or SEC filing summaries"
288            .to_string(),
289        "Schwab API does not provide ETF/mutual fund full holdings or expense ratio".to_string(),
290        "Schwab API does not provide analyst ratings or price targets".to_string(),
291    ];
292
293    let mut focus_areas = Vec::new();
294
295    match asset_class.as_str() {
296        "etf" | "mutual_fund" => {
297            recommended_queries.push(format!(
298                "{symbol} ETF holdings top positions expense ratio issuer fact sheet"
299            ));
300            recommended_queries.push(format!(
301                "{symbol} alternatives comparison dividend yield duration credit risk"
302            ));
303            focus_areas.extend([
304                "Holdings composition and sector/country weights".to_string(),
305                "Expense ratio and tracking difference vs benchmark".to_string(),
306                "Distribution policy and tax treatment".to_string(),
307                "Comparable ETFs/funds for substitution in rebalance plans".to_string(),
308            ]);
309            if div_yield.is_some() {
310                focus_areas
311                    .push("Verify distribution sustainability vs underlying yield".to_string());
312            }
313        }
314        "equity" => {
315            recommended_queries.push(format!(
316                "{symbol} earnings revenue growth competitors moat SEC 10-K summary"
317            ));
318            recommended_queries.push(format!(
319                "{symbol} analyst consensus price target institutional ownership"
320            ));
321            focus_areas.extend([
322                "Business model, competitive position, and recent earnings".to_string(),
323                "Valuation vs sector (P/E, growth, margins from Schwab fundamentals)".to_string(),
324                "Catalysts and risks for the planned holding period".to_string(),
325            ]);
326        }
327        _ => {
328            recommended_queries.push(format!(
329                "{symbol} {asset_main} instrument structure and risks"
330            ));
331            focus_areas.push("Confirm instrument type and settlement before trading".to_string());
332        }
333    }
334
335    if !price_context.is_null()
336        && price_context.get("empty").and_then(|v| v.as_bool()) == Some(false)
337    {
338        let change_pct = price_context
339            .get("changePercent")
340            .and_then(|v| v.as_f64())
341            .unwrap_or(0.0);
342        if change_pct.abs() > 5.0 {
343            focus_areas.push(format!(
344                "Recent {change_pct:.1}% move over lookback — verify news-driven volatility"
345            ));
346        }
347    }
348
349    json!({
350        "assetClass": asset_class,
351        "schwabCovers": [
352            "Live quote, bid/ask, volume, session prices",
353            "Dividend schedule and yield (when available)",
354            "Key ratios for equities (P/E, margins, market cap, beta)",
355            "Recent OHLCV price context"
356        ],
357        "schwabDoesNotCover": data_gaps,
358        "recommendedWebQueries": recommended_queries,
359        "planBuildFocus": focus_areas,
360        "suggestedWorkflow": [
361            format!("schwab market info {symbol} --json"),
362            "Web research using recommendedWebQueries",
363            "schwab portfolio summary --json",
364            "schwab plan prompt --json → draft YAML plan → schwab plan validate → schwab plan run --dry-run"
365        ]
366    })
367}
368
369fn classify_asset(asset_main: &str, asset_sub: Option<&str>) -> String {
370    match asset_sub {
371        Some("ETF") => "etf".to_string(),
372        Some(sub) if sub.contains("FUND") || sub.eq_ignore_ascii_case("MUTUAL_FUND") => {
373            "mutual_fund".to_string()
374        }
375        _ => match asset_main {
376            "EQUITY" => "equity".to_string(),
377            "MUTUAL_FUND" => "mutual_fund".to_string(),
378            "INDEX" => "index".to_string(),
379            "OPTION" => "option".to_string(),
380            "FUTURE" => "future".to_string(),
381            "FOREX" => "forex".to_string(),
382            other => other.to_lowercase(),
383        },
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn summarizes_candle_range() {
393        let history = json!({
394            "candles": [
395                {"close": 100.0, "high": 101.0, "low": 99.0, "volume": 1000, "datetime": 1},
396                {"close": 102.0, "high": 103.0, "low": 100.5, "volume": 2000, "datetime": 2}
397            ],
398            "previousClose": 99.5
399        });
400        let summary = summarize_history(&history, &InfoOptions::default());
401        assert_eq!(summary["candleCount"], 2);
402        assert_eq!(summary["firstClose"], 100.0);
403        assert_eq!(summary["lastClose"], 102.0);
404        assert_eq!(summary["changePercent"], 2.0);
405        assert_eq!(summary["rangeHigh"], 103.0);
406        assert_eq!(summary["rangeLow"], 99.0);
407    }
408
409    #[test]
410    fn classifies_etf() {
411        assert_eq!(classify_asset("EQUITY", Some("ETF")), "etf");
412        assert_eq!(classify_asset("EQUITY", None), "equity");
413    }
414
415    #[test]
416    fn merges_fundamentals_prefers_non_null() {
417        let a = json!({"divYield": 3.5, "peRatio": null});
418        let b = json!({"peRatio": 20.0, "marketCap": 100.0});
419        let merged = merge_objects(a, b);
420        assert_eq!(merged["divYield"], 3.5);
421        assert_eq!(merged["peRatio"], 20.0);
422        assert_eq!(merged["marketCap"], 100.0);
423    }
424}