Skip to main content

schwab_cli/
market_conditions.rs

1//! Live benchmark quotes for TUI market-conditions panels and CLI summaries.
2
3use std::time::Duration;
4
5use anyhow::Result;
6use chrono::{DateTime, Utc};
7use ratatui::style::{Color, Style};
8use ratatui::text::{Line, Span};
9use schwab_market_data::MarketDataApi;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// Symbol and short label shown in the TUI.
14pub const DEFAULT_BENCHMARKS: &[(&str, &str)] = &[
15    ("SPY", "S&P 500"),
16    ("QQQ", "Nasdaq"),
17    ("IWM", "Russell"),
18    ("DIA", "Dow"),
19    ("$VIX", "VIX"),
20    ("TLT", "Bonds"),
21    ("GLD", "Gold"),
22];
23
24const REFRESH_SECS: u64 = 30;
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct BenchmarkQuote {
28    pub symbol: String,
29    pub label: String,
30    pub last: f64,
31    pub change: Option<f64>,
32    pub change_pct: Option<f64>,
33    pub bid: Option<f64>,
34    pub ask: Option<f64>,
35}
36
37#[derive(Debug, Clone, Default)]
38pub struct MarketConditionsSnapshot {
39    pub quotes: Vec<BenchmarkQuote>,
40    pub fetched_at: Option<DateTime<Utc>>,
41    pub last_error: Option<String>,
42}
43
44impl MarketConditionsSnapshot {
45    pub fn age_secs(&self) -> Option<i64> {
46        self.fetched_at
47            .map(|t| (Utc::now() - t).num_seconds().max(0))
48    }
49}
50
51pub async fn refresh_market_conditions(
52    market: &MarketDataApi,
53    snapshot: &mut MarketConditionsSnapshot,
54) {
55    match fetch_benchmark_quotes(market, DEFAULT_BENCHMARKS).await {
56        Ok(quotes) => {
57            snapshot.quotes = quotes;
58            snapshot.fetched_at = Some(Utc::now());
59            snapshot.last_error = None;
60        }
61        Err(err) => {
62            snapshot.last_error = Some(err.to_string());
63        }
64    }
65}
66
67pub async fn fetch_benchmark_quotes(
68    market: &MarketDataApi,
69    benchmarks: &[(&str, &str)],
70) -> Result<Vec<BenchmarkQuote>> {
71    if benchmarks.is_empty() {
72        return Ok(Vec::new());
73    }
74
75    let symbols: Vec<String> = benchmarks
76        .iter()
77        .map(|(sym, _)| sym.trim().to_uppercase())
78        .collect();
79    let joined = symbols.join(",");
80    let raw = market
81        .quotes()
82        .get_quotes(&joined, Some("quote"), None)
83        .await?;
84
85    let mut out = Vec::new();
86    for (sym, label) in benchmarks {
87        let key = sym.trim().to_uppercase();
88        if let Some(q) = parse_benchmark_quote(&key, label, &raw) {
89            if q.last > 0.0 {
90                out.push(q);
91            }
92        }
93    }
94    if out.is_empty() && !benchmarks.is_empty() {
95        anyhow::bail!("no benchmark quotes returned");
96    }
97    Ok(out)
98}
99
100fn parse_benchmark_quote(symbol: &str, label: &str, raw: &Value) -> Option<BenchmarkQuote> {
101    let entry = extract_symbol_entry(raw, symbol);
102    let quote = entry
103        .get("quote")
104        .cloned()
105        .unwrap_or_else(|| entry.clone());
106    let regular = entry.get("regular").cloned().unwrap_or(Value::Null);
107    let last = quote_f64(&quote, "lastPrice")
108        .or_else(|| quote_f64(&regular, "regularMarketLastPrice"))?;
109    if last <= 0.0 {
110        return None;
111    }
112    Some(BenchmarkQuote {
113        symbol: symbol.to_string(),
114        label: label.to_string(),
115        last,
116        change: quote_f64(&quote, "netChange")
117            .or_else(|| quote_f64(&regular, "regularMarketNetChange")),
118        change_pct: quote_percent_change(&quote, Some(&regular)),
119        bid: quote_f64(&quote, "bidPrice"),
120        ask: quote_f64(&quote, "askPrice"),
121    })
122}
123
124fn extract_symbol_entry(raw: &Value, symbol: &str) -> Value {
125    let sym = symbol.trim().to_uppercase();
126    if let Some(entry) = raw.get(&sym) {
127        return entry.clone();
128    }
129    if let Some(entry) = raw.get(symbol) {
130        return entry.clone();
131    }
132    if let Some(obj) = raw.as_object() {
133        for (k, v) in obj {
134            if k.eq_ignore_ascii_case(&sym) || k == symbol {
135                return v.clone();
136            }
137        }
138    }
139    raw.clone()
140}
141
142fn quote_f64(quote: &Value, field: &str) -> Option<f64> {
143    let v = quote.get(field)?;
144    if let Some(n) = v.as_f64() {
145        return Some(n);
146    }
147    v.as_str().and_then(|s| s.trim().parse().ok())
148}
149
150fn quote_percent_change(quote: &Value, regular: Option<&Value>) -> Option<f64> {
151    quote_f64(quote, "netPercentChangeInDouble")
152        .or_else(|| quote_f64(quote, "netPercentChange"))
153        .or_else(|| quote_f64(quote, "markPercentChange"))
154        .or_else(|| quote_f64(quote, "percentChange"))
155        .or_else(|| regular.and_then(quote_percent_change_regular))
156        .or_else(|| computed_percent_change(quote))
157        .or_else(|| regular.and_then(computed_percent_change_regular))
158}
159
160fn quote_percent_change_regular(regular: &Value) -> Option<f64> {
161    quote_f64(regular, "regularMarketPercentChangeInDouble")
162        .or_else(|| quote_f64(regular, "regularMarketPercentChange"))
163}
164
165fn computed_percent_change_regular(regular: &Value) -> Option<f64> {
166    let net = quote_f64(regular, "regularMarketNetChange")?;
167    let base = quote_f64(regular, "regularMarketPreviousClose")
168        .or_else(|| quote_f64(regular, "regularMarketClose"))
169        .filter(|p| *p > 0.0)?;
170    Some(net / base * 100.0)
171}
172
173fn computed_percent_change(quote: &Value) -> Option<f64> {
174    let net = quote_f64(quote, "netChange")?;
175    let base = quote_f64(quote, "closePrice").filter(|p| *p > 0.0)?;
176    Some(net / base * 100.0)
177}
178
179#[allow(dead_code)]
180pub fn market_conditions_to_json(snapshot: &MarketConditionsSnapshot) -> Value {
181    serde_json::json!({
182        "fetched_at": snapshot.fetched_at,
183        "age_secs": snapshot.age_secs(),
184        "last_error": snapshot.last_error,
185        "benchmarks": snapshot.quotes,
186    })
187}
188
189/// Compact TUI lines: `SPY  $598.12  +0.42%`
190pub fn market_conditions_lines(snapshot: &MarketConditionsSnapshot) -> Vec<Line<'static>> {
191    if snapshot.quotes.is_empty() {
192        let msg = snapshot
193            .last_error
194            .as_deref()
195            .map(|e| format!("(quotes unavailable: {e})"))
196            .unwrap_or_else(|| "(fetching market quotes…)".into());
197        return vec![Line::from(Span::styled(
198            msg,
199            Style::default().fg(Color::DarkGray),
200        ))];
201    }
202
203    let mut lines = Vec::new();
204    let chunks: Vec<_> = snapshot.quotes.chunks(2).collect();
205    for pair in chunks {
206        let mut spans = Vec::new();
207        for (i, q) in pair.iter().enumerate() {
208            if i > 0 {
209                spans.push(Span::raw("  │  "));
210            }
211            spans.extend(benchmark_spans(q));
212        }
213        lines.push(Line::from(spans));
214    }
215
216    if let Some(age) = snapshot.age_secs() {
217        lines.push(Line::from(Span::styled(
218            format!("updated {age}s ago"),
219            Style::default().fg(Color::DarkGray),
220        )));
221    }
222    lines
223}
224
225fn benchmark_spans(q: &BenchmarkQuote) -> Vec<Span<'static>> {
226    let sym = if q.symbol.starts_with('$') {
227        q.symbol.clone()
228    } else {
229        q.symbol.clone()
230    };
231    let price = if q.last >= 1000.0 {
232        format!("${:.0}", q.last)
233    } else if q.last >= 100.0 {
234        format!("${:.1}", q.last)
235    } else {
236        format!("${:.2}", q.last)
237    };
238    let chg = q
239        .change_pct
240        .map(|p| format!("{:+.2}%", p))
241        .unwrap_or_else(|| "—".into());
242    let chg_style = match q.change_pct {
243        Some(p) if p >= 0.25 => Style::default().fg(Color::Green),
244        Some(p) if p <= -0.25 => Style::default().fg(Color::Red),
245        Some(_) => Style::default().fg(Color::Yellow),
246        None => Style::default().fg(Color::DarkGray),
247    };
248    vec![
249        Span::styled(
250            format!("{sym} "),
251            Style::default().fg(Color::Cyan),
252        ),
253        Span::raw(price),
254        Span::raw(" "),
255        Span::styled(chg, chg_style),
256    ]
257}
258
259pub fn spawn_market_conditions_feed(
260    market: std::sync::Arc<MarketDataApi>,
261    snapshot: std::sync::Arc<std::sync::Mutex<MarketConditionsSnapshot>>,
262) -> tokio::task::JoinHandle<()> {
263    tokio::spawn(async move {
264        let mut interval = tokio::time::interval(Duration::from_secs(REFRESH_SECS));
265        interval.tick().await;
266        loop {
267            let mut fresh = MarketConditionsSnapshot::default();
268            refresh_market_conditions(&market, &mut fresh).await;
269            if let Ok(mut guard) = snapshot.lock() {
270                *guard = fresh;
271            }
272            interval.tick().await;
273        }
274    })
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use serde_json::json;
281
282    #[test]
283    fn parses_batch_quote() {
284        let raw = json!({
285            "SPY": {
286                "quote": {
287                    "lastPrice": 598.12,
288                    "netChange": 2.5,
289                    "netPercentChange": 0.42
290                }
291            },
292            "$VIX": {
293                "quote": {
294                    "lastPrice": 14.2,
295                    "netPercentChange": -3.1
296                }
297            }
298        });
299        let quotes = DEFAULT_BENCHMARKS
300            .iter()
301            .filter_map(|(sym, label)| parse_benchmark_quote(sym, label, &raw))
302            .collect::<Vec<_>>();
303        assert_eq!(quotes.len(), 2);
304        assert!((quotes[0].last - 598.12).abs() < 0.01);
305        assert!((quotes[0].change_pct.unwrap() - 0.42).abs() < 0.01);
306    }
307
308    #[test]
309    fn parses_schwab_net_percent_change_field() {
310        let raw = json!({
311            "SPY": {
312                "quote": {
313                    "lastPrice": 741.97,
314                    "netChange": -3.79,
315                    "netPercentChange": -0.50820639
316                }
317            }
318        });
319        let q = parse_benchmark_quote("SPY", "S&P 500", &raw).unwrap();
320        assert!((q.change_pct.unwrap() - (-0.50820639)).abs() < 0.0001);
321    }
322
323    #[test]
324    fn computes_percent_from_net_and_close_when_missing() {
325        let raw = json!({
326            "IWM": {
327                "quote": {
328                    "lastPrice": 295.53,
329                    "netChange": -3.79,
330                    "closePrice": 299.32
331                }
332            }
333        });
334        let q = parse_benchmark_quote("IWM", "Russell", &raw).unwrap();
335        let pct = q.change_pct.unwrap();
336        assert!((pct - (-1.266)).abs() < 0.01);
337    }
338
339    #[test]
340    fn parses_live_schwab_batch_with_all_benchmarks() {
341        let raw = json!({
342            "SPY": {
343                "quote": {
344                    "lastPrice": 742.91,
345                    "netChange": -2.85,
346                    "netPercentChange": -0.38216048
347                }
348            },
349            "QQQ": {
350                "quote": {
351                    "lastPrice": 711.53,
352                    "netChange": -13.64,
353                    "netPercentChange": -1.88093826
354                }
355            },
356            "$VIX": {
357                "quote": {
358                    "lastPrice": 16.67,
359                    "netChange": 0.08,
360                    "netPercentChange": 0.4822182
361                }
362            },
363            "TLT": {
364                "quote": {
365                    "lastPrice": 85.44,
366                    "netChange": -0.12,
367                    "closePrice": 85.56
368                }
369            }
370        });
371        let quotes = DEFAULT_BENCHMARKS
372            .iter()
373            .filter_map(|(sym, label)| parse_benchmark_quote(sym, label, &raw))
374            .collect::<Vec<_>>();
375        assert_eq!(quotes.len(), 4);
376        for q in &quotes {
377            assert!(
378                q.change_pct.is_some(),
379                "{} missing daily %",
380                q.symbol
381            );
382        }
383        let lines = market_conditions_lines(&MarketConditionsSnapshot {
384            quotes,
385            fetched_at: Some(Utc::now()),
386            last_error: None,
387        });
388        let rendered = lines
389            .iter()
390            .map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
391            .collect::<Vec<_>>()
392            .join("\n");
393        assert!(!rendered.contains(" —"), "expected % not em-dash: {rendered}");
394    }
395
396    #[test]
397    fn market_lines_not_empty_when_quotes_present() {
398        let snap = MarketConditionsSnapshot {
399            quotes: vec![BenchmarkQuote {
400                symbol: "SPY".into(),
401                label: "S&P 500".into(),
402                last: 598.0,
403                change: Some(1.0),
404                change_pct: Some(0.2),
405                bid: None,
406                ask: None,
407            }],
408            fetched_at: Some(Utc::now()),
409            last_error: None,
410        };
411        let lines = market_conditions_lines(&snap);
412        assert!(!lines.is_empty());
413    }
414}