const YAHOO_CHART_URL: &str = "https://query2.finance.yahoo.com/v8/finance/chart";
const EXTENDED_HOURS_BATCH_CHUNK: usize = 8;
#[derive(Debug, Clone, Serialize)]
pub struct ExtendedHoursQuote {
pub ticker: String,
pub regular_price: Option<f64>,
pub regular_previous_close: Option<f64>,
pub session: Option<String>,
pub extended_price: Option<f64>,
pub extended_change_pct: Option<f64>,
pub extended_change_abs: Option<f64>,
pub timestamp_utc: Option<chrono::DateTime<chrono::Utc>>,
}
impl ExtendedHoursQuote {
fn empty(ticker: &str) -> Self {
Self {
ticker: ticker.to_string(),
regular_price: None,
regular_previous_close: None,
session: None,
extended_price: None,
extended_change_pct: None,
extended_change_abs: None,
timestamp_utc: None,
}
}
}
pub async fn fetch_extended_hours_quote(ticker: &str) -> Result<ExtendedHoursQuote> {
let client = reqwest::Client::builder()
.user_agent("Mozilla/5.0")
.timeout(std::time::Duration::from_secs(8))
.build()
.context("build extended-hours client")?;
fetch_extended_hours_quote_with_client(&client, ticker).await
}
async fn fetch_extended_hours_quote_with_client(
client: &reqwest::Client,
ticker: &str,
) -> Result<ExtendedHoursQuote> {
let trimmed = ticker.trim();
if trimmed.is_empty() {
anyhow::bail!("empty ticker for extended-hours fetch");
}
let url = format!("{}/{}", YAHOO_CHART_URL, trimmed);
let resp = client
.get(&url)
.query(&[
("interval", "1m"),
("range", "1d"),
("includePrePost", "true"),
])
.send()
.await
.with_context(|| format!("fetch extended-hours for {trimmed}"))?;
if !resp.status().is_success() {
anyhow::bail!(
"yahoo chart/v8 returned HTTP {} for {}",
resp.status(),
trimmed
);
}
let body: serde_json::Value = resp
.json()
.await
.with_context(|| format!("parse extended-hours for {trimmed}"))?;
Ok(parse_extended_hours_payload(trimmed, &body))
}
pub async fn fetch_extended_hours_quotes_batch(tickers: &[String]) -> Vec<ExtendedHoursQuote> {
if tickers.is_empty() {
return Vec::new();
}
let client = match reqwest::Client::builder()
.user_agent("Mozilla/5.0")
.timeout(std::time::Duration::from_secs(8))
.build()
{
Ok(c) => c,
Err(_) => {
return tickers
.iter()
.map(|t| ExtendedHoursQuote::empty(t))
.collect();
}
};
let mut out: Vec<ExtendedHoursQuote> = Vec::with_capacity(tickers.len());
for chunk in tickers.chunks(EXTENDED_HOURS_BATCH_CHUNK) {
let futures_iter = chunk.iter().map(|ticker| {
let client = client.clone();
let ticker = ticker.clone();
async move {
match fetch_extended_hours_quote_with_client(&client, &ticker).await {
Ok(quote) => quote,
Err(_) => ExtendedHoursQuote::empty(&ticker),
}
}
});
let chunk_results: Vec<ExtendedHoursQuote> =
futures::future::join_all(futures_iter).await;
out.extend(chunk_results);
}
out
}
fn parse_extended_hours_payload(ticker: &str, body: &serde_json::Value) -> ExtendedHoursQuote {
let mut quote = ExtendedHoursQuote::empty(ticker);
let result = body
.get("chart")
.and_then(|c| c.get("result"))
.and_then(|r| r.as_array())
.and_then(|a| a.first());
let Some(result) = result else {
return quote;
};
let meta = result.get("meta");
quote.regular_price = meta
.and_then(|m| m.get("regularMarketPrice"))
.and_then(|v| v.as_f64());
quote.regular_previous_close = meta
.and_then(|m| m.get("chartPreviousClose"))
.or_else(|| meta.and_then(|m| m.get("previousClose")))
.and_then(|v| v.as_f64());
let trading_periods = meta.and_then(|m| m.get("tradingPeriods"));
let current_period = meta.and_then(|m| m.get("currentTradingPeriod"));
let pre_window = trading_periods
.and_then(|t| t.get("pre"))
.and_then(parse_nested_window)
.or_else(|| current_period.and_then(|t| t.get("pre")).and_then(parse_window));
let regular_window = trading_periods
.and_then(|t| t.get("regular"))
.and_then(parse_nested_window)
.or_else(|| {
current_period
.and_then(|t| t.get("regular"))
.and_then(parse_window)
});
let post_window = trading_periods
.and_then(|t| t.get("post"))
.and_then(parse_nested_window)
.or_else(|| {
current_period
.and_then(|t| t.get("post"))
.and_then(parse_window)
});
let market_state = meta
.and_then(|m| m.get("marketState"))
.and_then(|v| v.as_str());
quote.session = match market_state {
Some("PRE") => Some("pre".to_string()),
Some("REGULAR") => Some("regular".to_string()),
Some("POST" | "POSTPOST") => Some("post".to_string()),
Some("CLOSED" | "PREPRE") => Some("closed".to_string()),
_ => None,
};
let timestamps = result
.get("timestamp")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let closes = result
.get("indicators")
.and_then(|i| i.get("quote"))
.and_then(|q| q.as_array())
.and_then(|a| a.first())
.and_then(|q0| q0.get("close"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut found = None;
if let Some(post) = post_window {
found = find_latest_in_window(×tamps, &closes, post);
}
if found.is_none() {
if let Some(pre) = pre_window {
let pre_is_current = match (pre, regular_window) {
(_, Some(reg)) => pre.1 >= reg.0.saturating_sub(1) && pre.1 <= reg.1,
_ => true,
};
if pre_is_current {
found = find_latest_in_window(×tamps, &closes, pre);
}
}
}
if let Some((px, ts)) = found {
quote.extended_price = Some(px);
quote.timestamp_utc = chrono::DateTime::<chrono::Utc>::from_timestamp(ts, 0);
if let Some(regular) = quote.regular_price {
if regular.is_finite() && regular != 0.0 {
quote.extended_change_pct = Some((px / regular - 1.0) * 100.0);
quote.extended_change_abs = Some(px - regular);
}
}
}
quote
}
fn parse_window(v: &serde_json::Value) -> Option<(i64, i64)> {
let start = v.get("start").and_then(|v| v.as_i64())?;
let end = v.get("end").and_then(|v| v.as_i64())?;
Some((start, end))
}
fn parse_nested_window(v: &serde_json::Value) -> Option<(i64, i64)> {
let inner = v
.as_array()
.and_then(|outer| outer.first())
.and_then(|inner_arr| inner_arr.as_array())
.and_then(|sessions| sessions.first())?;
parse_window(inner)
}
fn find_latest_in_window(
timestamps: &[serde_json::Value],
closes: &[serde_json::Value],
window: (i64, i64),
) -> Option<(f64, i64)> {
let n = timestamps.len().min(closes.len());
for i in (0..n).rev() {
let ts = timestamps[i].as_i64()?;
if ts < window.0 || ts > window.1 {
continue;
}
if let Some(px) = closes[i].as_f64() {
if px.is_finite() {
return Some((px, ts));
}
}
}
None
}
#[cfg(test)]
mod movers_extended_tests {
use super::*;
#[tokio::test]
#[ignore = "hits live Yahoo; run with --ignored"]
async fn amd_extended_hours_smoke() {
let q = fetch_extended_hours_quote("AMD").await.unwrap();
eprintln!("AMD extended-hours quote: {:#?}", q);
assert_eq!(q.ticker, "AMD");
assert!(q.regular_price.is_some(), "expected regular_price, got {:?}", q);
}
#[tokio::test]
#[ignore = "hits live Yahoo; run with --ignored"]
async fn batch_smoke() {
let tickers = vec!["AMD".to_string(), "NVDA".to_string(), "AAPL".to_string()];
let quotes = fetch_extended_hours_quotes_batch(&tickers).await;
eprintln!("batch quotes: {:#?}", quotes);
assert_eq!(quotes.len(), 3);
}
}