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
274        .get("assetSubType")
275        .and_then(|v| v.as_str());
276
277    let asset_class = classify_asset(asset_main, asset_sub);
278    let div_yield = fundamentals
279        .get("dividendYield")
280        .or_else(|| fundamentals.get("divYield"))
281        .and_then(|v| v.as_f64());
282
283    let mut recommended_queries = vec![
284        format!("{description} ({symbol}) latest news and outlook"),
285        format!("{symbol} key risks and recent performance"),
286    ];
287
288    let data_gaps = vec![
289        "Schwab API does not provide business narrative, management, or SEC filing summaries".to_string(),
290        "Schwab API does not provide ETF/mutual fund full holdings or expense ratio".to_string(),
291        "Schwab API does not provide analyst ratings or price targets".to_string(),
292    ];
293
294    let mut focus_areas = Vec::new();
295
296    match asset_class.as_str() {
297        "etf" | "mutual_fund" => {
298            recommended_queries.push(format!(
299                "{symbol} ETF holdings top positions expense ratio issuer fact sheet"
300            ));
301            recommended_queries.push(format!(
302                "{symbol} alternatives comparison dividend yield duration credit risk"
303            ));
304            focus_areas.extend([
305                "Holdings composition and sector/country weights".to_string(),
306                "Expense ratio and tracking difference vs benchmark".to_string(),
307                "Distribution policy and tax treatment".to_string(),
308                "Comparable ETFs/funds for substitution in rebalance plans".to_string(),
309            ]);
310            if div_yield.is_some() {
311                focus_areas.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!("{symbol} {asset_main} instrument structure and risks"));
329            focus_areas.push("Confirm instrument type and settlement before trading".to_string());
330        }
331    }
332
333    if !price_context.is_null()
334        && price_context.get("empty").and_then(|v| v.as_bool()) == Some(false)
335    {
336        let change_pct = price_context
337            .get("changePercent")
338            .and_then(|v| v.as_f64())
339            .unwrap_or(0.0);
340        if change_pct.abs() > 5.0 {
341            focus_areas.push(format!(
342                "Recent {change_pct:.1}% move over lookback — verify news-driven volatility"
343            ));
344        }
345    }
346
347    json!({
348        "assetClass": asset_class,
349        "schwabCovers": [
350            "Live quote, bid/ask, volume, session prices",
351            "Dividend schedule and yield (when available)",
352            "Key ratios for equities (P/E, margins, market cap, beta)",
353            "Recent OHLCV price context"
354        ],
355        "schwabDoesNotCover": data_gaps,
356        "recommendedWebQueries": recommended_queries,
357        "planBuildFocus": focus_areas,
358        "suggestedWorkflow": [
359            format!("schwab market info {symbol} --json"),
360            "Web research using recommendedWebQueries",
361            "schwab portfolio summary --json",
362            "schwab plan prompt --json → draft YAML plan → schwab plan validate → schwab plan run --dry-run"
363        ]
364    })
365}
366
367fn classify_asset(asset_main: &str, asset_sub: Option<&str>) -> String {
368    match asset_sub {
369        Some("ETF") => "etf".to_string(),
370        Some(sub) if sub.contains("FUND") || sub.eq_ignore_ascii_case("MUTUAL_FUND") => {
371            "mutual_fund".to_string()
372        }
373        _ => match asset_main {
374            "EQUITY" => "equity".to_string(),
375            "MUTUAL_FUND" => "mutual_fund".to_string(),
376            "INDEX" => "index".to_string(),
377            "OPTION" => "option".to_string(),
378            "FUTURE" => "future".to_string(),
379            "FOREX" => "forex".to_string(),
380            other => other.to_lowercase(),
381        },
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn summarizes_candle_range() {
391        let history = json!({
392            "candles": [
393                {"close": 100.0, "high": 101.0, "low": 99.0, "volume": 1000, "datetime": 1},
394                {"close": 102.0, "high": 103.0, "low": 100.5, "volume": 2000, "datetime": 2}
395            ],
396            "previousClose": 99.5
397        });
398        let summary = summarize_history(&history, &InfoOptions::default());
399        assert_eq!(summary["candleCount"], 2);
400        assert_eq!(summary["firstClose"], 100.0);
401        assert_eq!(summary["lastClose"], 102.0);
402        assert_eq!(summary["changePercent"], 2.0);
403        assert_eq!(summary["rangeHigh"], 103.0);
404        assert_eq!(summary["rangeLow"], 99.0);
405    }
406
407    #[test]
408    fn classifies_etf() {
409        assert_eq!(classify_asset("EQUITY", Some("ETF")), "etf");
410        assert_eq!(classify_asset("EQUITY", None), "equity");
411    }
412
413    #[test]
414    fn merges_fundamentals_prefers_non_null() {
415        let a = json!({"divYield": 3.5, "peRatio": null});
416        let b = json!({"peRatio": 20.0, "marketCap": 100.0});
417        let merged = merge_objects(a, b);
418        assert_eq!(merged["divYield"], 3.5);
419        assert_eq!(merged["peRatio"], 20.0);
420        assert_eq!(merged["marketCap"], 100.0);
421    }
422}