Skip to main content

eli_cli/cmd/finance/
movers_extended.rs

1// Extended-hours (pre-market / after-hours) quote fetcher for finance_movers.
2//
3// Yahoo's chart/v8 endpoint with `includePrePost=true` returns the full minute-bar
4// series across the pre/regular/post sessions plus a `currentTradingPeriod` block
5// that gives us the timestamp ranges for each session. No crumb required.
6//
7// We compute the latest extended-hours print by walking the timestamp[] array
8// backwards and finding the most recent non-null close inside the post-market
9// window (or pre-market if it's morning). Change-pct is computed against
10// `regularMarketPrice` — the most recent regular-session close — NOT against
11// previousClose (yesterday's close), because we want the AFTER-hours move, not
12// the cumulative day move.
13
14const 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    /// last regular-session close (Yahoo `meta.regularMarketPrice`)
21    pub regular_price: Option<f64>,
22    /// prior trading day close (Yahoo `meta.chartPreviousClose`)
23    pub regular_previous_close: Option<f64>,
24    /// "pre" | "regular" | "post" | "closed"
25    pub session: Option<String>,
26    /// most recent post-market price (or pre-market if it's morning) — None when
27    /// no extended-hours print has landed yet for the current window
28    pub extended_price: Option<f64>,
29    /// (extended_price / regular_price - 1) * 100
30    pub extended_change_pct: Option<f64>,
31    /// extended_price - regular_price
32    pub extended_change_abs: Option<f64>,
33    /// when the extended-hours print landed
34    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
52/// Fetch a single ticker's extended-hours quote via Yahoo chart/v8.
53pub 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
95/// Fetch many tickers in parallel, chunked at EXTENDED_HOURS_BATCH_CHUNK to avoid
96/// Yahoo throttling. On error, returns an empty quote for that ticker so the
97/// merge in movers.rs is total.
98pub 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    // Yahoo gives us TWO window blocks:
156    //   - currentTradingPeriod: today's upcoming/active sessions
157    //   - tradingPeriods: the sessions the response data actually covers
158    // Between yesterday's post-close and today's pre-open (overnight), the bar
159    // series is yesterday's but currentTradingPeriod has rolled to today —
160    // tradingPeriods is what aligns with the bars. Prefer tradingPeriods, fall
161    // back to currentTradingPeriod when absent.
162    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    // Determine current session from market state + windows.
186    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    // Walk the bar series backwards, find the most recent non-null close in
198    // post (preferred) or pre (fallback).
199    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    // Try post-market first, then pre-market. If we're inside post we want post;
215    // if pre is active and post hasn't happened yet, fall back to pre.
216    let mut found = None;
217    if let Some(post) = post_window {
218        found = find_latest_in_window(&timestamps, &closes, post);
219    }
220    if found.is_none() {
221        if let Some(pre) = pre_window {
222            // Only use pre if we don't have a post print AND there's no
223            // regular_window between pre.end and now (i.e. it's still morning
224            // or pre-market). If the regular session has ended, the pre is
225            // yesterday's pre — skip.
226            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(&timestamps, &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
256/// `meta.tradingPeriods.pre` is shaped as `[[{start, end, ...}]]` (array of
257/// arrays of session objects). For the most common case (one day, one session
258/// per type), peel both wrappers and parse the inner object.
259fn 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
268/// Walk the bar series backwards, return (close, timestamp) for the most recent
269/// non-null close whose timestamp falls inside [window.0, window.1].
270fn 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        // regular_price should always come back from chart/v8 meta
301        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}