eli_cli/cmd/finance/
movers_extended.rs1const YAHOO_CHART_URL: &str = "https://query2.finance.yahoo.com/v8/finance/chart";
15const EXTENDED_HOURS_BATCH_CHUNK: usize = 8;
16
17#[derive(Debug, Clone, Serialize)]
18pub struct ExtendedHoursQuote {
19 pub ticker: String,
20 pub regular_price: Option<f64>,
22 pub regular_previous_close: Option<f64>,
24 pub session: Option<String>,
26 pub extended_price: Option<f64>,
29 pub extended_change_pct: Option<f64>,
31 pub extended_change_abs: Option<f64>,
33 pub timestamp_utc: Option<chrono::DateTime<chrono::Utc>>,
35}
36
37impl ExtendedHoursQuote {
38 fn empty(ticker: &str) -> Self {
39 Self {
40 ticker: ticker.to_string(),
41 regular_price: None,
42 regular_previous_close: None,
43 session: None,
44 extended_price: None,
45 extended_change_pct: None,
46 extended_change_abs: None,
47 timestamp_utc: None,
48 }
49 }
50}
51
52pub async fn fetch_extended_hours_quote(ticker: &str) -> Result<ExtendedHoursQuote> {
54 let client = reqwest::Client::builder()
55 .user_agent("Mozilla/5.0")
56 .timeout(std::time::Duration::from_secs(8))
57 .build()
58 .context("build extended-hours client")?;
59 fetch_extended_hours_quote_with_client(&client, ticker).await
60}
61
62async fn fetch_extended_hours_quote_with_client(
63 client: &reqwest::Client,
64 ticker: &str,
65) -> Result<ExtendedHoursQuote> {
66 let trimmed = ticker.trim();
67 if trimmed.is_empty() {
68 anyhow::bail!("empty ticker for extended-hours fetch");
69 }
70 let url = format!("{}/{}", YAHOO_CHART_URL, trimmed);
71 let resp = client
72 .get(&url)
73 .query(&[
74 ("interval", "1m"),
75 ("range", "1d"),
76 ("includePrePost", "true"),
77 ])
78 .send()
79 .await
80 .with_context(|| format!("fetch extended-hours for {trimmed}"))?;
81 if !resp.status().is_success() {
82 anyhow::bail!(
83 "yahoo chart/v8 returned HTTP {} for {}",
84 resp.status(),
85 trimmed
86 );
87 }
88 let body: serde_json::Value = resp
89 .json()
90 .await
91 .with_context(|| format!("parse extended-hours for {trimmed}"))?;
92 Ok(parse_extended_hours_payload(trimmed, &body))
93}
94
95pub async fn fetch_extended_hours_quotes_batch(tickers: &[String]) -> Vec<ExtendedHoursQuote> {
99 if tickers.is_empty() {
100 return Vec::new();
101 }
102 let client = match reqwest::Client::builder()
103 .user_agent("Mozilla/5.0")
104 .timeout(std::time::Duration::from_secs(8))
105 .build()
106 {
107 Ok(c) => c,
108 Err(_) => {
109 return tickers
110 .iter()
111 .map(|t| ExtendedHoursQuote::empty(t))
112 .collect();
113 }
114 };
115
116 let mut out: Vec<ExtendedHoursQuote> = Vec::with_capacity(tickers.len());
117 for chunk in tickers.chunks(EXTENDED_HOURS_BATCH_CHUNK) {
118 let futures_iter = chunk.iter().map(|ticker| {
119 let client = client.clone();
120 let ticker = ticker.clone();
121 async move {
122 match fetch_extended_hours_quote_with_client(&client, &ticker).await {
123 Ok(quote) => quote,
124 Err(_) => ExtendedHoursQuote::empty(&ticker),
125 }
126 }
127 });
128 let chunk_results: Vec<ExtendedHoursQuote> =
129 futures::future::join_all(futures_iter).await;
130 out.extend(chunk_results);
131 }
132 out
133}
134
135fn parse_extended_hours_payload(ticker: &str, body: &serde_json::Value) -> ExtendedHoursQuote {
136 let mut quote = ExtendedHoursQuote::empty(ticker);
137 let result = body
138 .get("chart")
139 .and_then(|c| c.get("result"))
140 .and_then(|r| r.as_array())
141 .and_then(|a| a.first());
142 let Some(result) = result else {
143 return quote;
144 };
145 let meta = result.get("meta");
146
147 quote.regular_price = meta
148 .and_then(|m| m.get("regularMarketPrice"))
149 .and_then(|v| v.as_f64());
150 quote.regular_previous_close = meta
151 .and_then(|m| m.get("chartPreviousClose"))
152 .or_else(|| meta.and_then(|m| m.get("previousClose")))
153 .and_then(|v| v.as_f64());
154
155 let trading_periods = meta.and_then(|m| m.get("tradingPeriods"));
163 let current_period = meta.and_then(|m| m.get("currentTradingPeriod"));
164 let pre_window = trading_periods
165 .and_then(|t| t.get("pre"))
166 .and_then(parse_nested_window)
167 .or_else(|| current_period.and_then(|t| t.get("pre")).and_then(parse_window));
168 let regular_window = trading_periods
169 .and_then(|t| t.get("regular"))
170 .and_then(parse_nested_window)
171 .or_else(|| {
172 current_period
173 .and_then(|t| t.get("regular"))
174 .and_then(parse_window)
175 });
176 let post_window = trading_periods
177 .and_then(|t| t.get("post"))
178 .and_then(parse_nested_window)
179 .or_else(|| {
180 current_period
181 .and_then(|t| t.get("post"))
182 .and_then(parse_window)
183 });
184
185 let market_state = meta
187 .and_then(|m| m.get("marketState"))
188 .and_then(|v| v.as_str());
189 quote.session = match market_state {
190 Some("PRE") => Some("pre".to_string()),
191 Some("REGULAR") => Some("regular".to_string()),
192 Some("POST" | "POSTPOST") => Some("post".to_string()),
193 Some("CLOSED" | "PREPRE") => Some("closed".to_string()),
194 _ => None,
195 };
196
197 let timestamps = result
200 .get("timestamp")
201 .and_then(|v| v.as_array())
202 .cloned()
203 .unwrap_or_default();
204 let closes = result
205 .get("indicators")
206 .and_then(|i| i.get("quote"))
207 .and_then(|q| q.as_array())
208 .and_then(|a| a.first())
209 .and_then(|q0| q0.get("close"))
210 .and_then(|v| v.as_array())
211 .cloned()
212 .unwrap_or_default();
213
214 let mut found = None;
217 if let Some(post) = post_window {
218 found = find_latest_in_window(×tamps, &closes, post);
219 }
220 if found.is_none() {
221 if let Some(pre) = pre_window {
222 let pre_is_current = match (pre, regular_window) {
227 (_, Some(reg)) => pre.1 >= reg.0.saturating_sub(1) && pre.1 <= reg.1,
228 _ => true,
229 };
230 if pre_is_current {
231 found = find_latest_in_window(×tamps, &closes, pre);
232 }
233 }
234 }
235
236 if let Some((px, ts)) = found {
237 quote.extended_price = Some(px);
238 quote.timestamp_utc = chrono::DateTime::<chrono::Utc>::from_timestamp(ts, 0);
239 if let Some(regular) = quote.regular_price {
240 if regular.is_finite() && regular != 0.0 {
241 quote.extended_change_pct = Some((px / regular - 1.0) * 100.0);
242 quote.extended_change_abs = Some(px - regular);
243 }
244 }
245 }
246
247 quote
248}
249
250fn parse_window(v: &serde_json::Value) -> Option<(i64, i64)> {
251 let start = v.get("start").and_then(|v| v.as_i64())?;
252 let end = v.get("end").and_then(|v| v.as_i64())?;
253 Some((start, end))
254}
255
256fn parse_nested_window(v: &serde_json::Value) -> Option<(i64, i64)> {
260 let inner = v
261 .as_array()
262 .and_then(|outer| outer.first())
263 .and_then(|inner_arr| inner_arr.as_array())
264 .and_then(|sessions| sessions.first())?;
265 parse_window(inner)
266}
267
268fn find_latest_in_window(
271 timestamps: &[serde_json::Value],
272 closes: &[serde_json::Value],
273 window: (i64, i64),
274) -> Option<(f64, i64)> {
275 let n = timestamps.len().min(closes.len());
276 for i in (0..n).rev() {
277 let ts = timestamps[i].as_i64()?;
278 if ts < window.0 || ts > window.1 {
279 continue;
280 }
281 if let Some(px) = closes[i].as_f64() {
282 if px.is_finite() {
283 return Some((px, ts));
284 }
285 }
286 }
287 None
288}
289
290#[cfg(test)]
291mod movers_extended_tests {
292 use super::*;
293
294 #[tokio::test]
295 #[ignore = "hits live Yahoo; run with --ignored"]
296 async fn amd_extended_hours_smoke() {
297 let q = fetch_extended_hours_quote("AMD").await.unwrap();
298 eprintln!("AMD extended-hours quote: {:#?}", q);
299 assert_eq!(q.ticker, "AMD");
300 assert!(q.regular_price.is_some(), "expected regular_price, got {:?}", q);
302 }
303
304 #[tokio::test]
305 #[ignore = "hits live Yahoo; run with --ignored"]
306 async fn batch_smoke() {
307 let tickers = vec!["AMD".to_string(), "NVDA".to_string(), "AAPL".to_string()];
308 let quotes = fetch_extended_hours_quotes_batch(&tickers).await;
309 eprintln!("batch quotes: {:#?}", quotes);
310 assert_eq!(quotes.len(), 3);
311 }
312}