1use 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("e_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}