1use 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
13pub 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("e, "lastPrice")
108 .or_else(|| quote_f64(®ular, "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("e, "netChange")
117 .or_else(|| quote_f64(®ular, "regularMarketNetChange")),
118 change_pct: quote_percent_change("e, Some(®ular)),
119 bid: quote_f64("e, "bidPrice"),
120 ask: quote_f64("e, "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
189pub 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 "es {
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}