yfinance-rs 0.9.1

Ergonomic Rust client for Yahoo Finance, supporting historical prices, real-time streaming, options, fundamentals, and more.
Documentation
use url::Url;

use super::YfClient;
use crate::core::YfError;

#[derive(Clone, Copy, Debug)]
pub enum SymbolEndpoint {
    Chart,
    OptionsV7,
    QuoteSummary,
    Timeseries,
}

impl YfClient {
    pub(crate) fn symbol_url(
        &self,
        endpoint: SymbolEndpoint,
        symbol: &str,
    ) -> Result<Url, YfError> {
        let base = match endpoint {
            SymbolEndpoint::Chart => &self.base_chart,
            SymbolEndpoint::OptionsV7 => &self.base_options_v7,
            SymbolEndpoint::QuoteSummary => &self.base_quote_api,
            SymbolEndpoint::Timeseries => &self.base_timeseries,
        };

        append_symbol_path_segment(base, symbol)
    }
}

pub fn normalize_symbol(symbol: &str) -> Result<String, YfError> {
    let symbol = paft::domain::Symbol::new(symbol)
        .map_err(|err| YfError::InvalidParams(format!("invalid symbol {symbol:?}: {err}")))?;
    let symbol = symbol.as_str();

    if matches!(symbol, "." | "..") {
        return Err(YfError::InvalidParams(
            "symbol cannot be a dot path segment".into(),
        ));
    }

    if !symbol.bytes().any(|byte| byte.is_ascii_alphanumeric()) {
        return Err(YfError::InvalidParams(
            "symbol must contain at least one ASCII alphanumeric character".into(),
        ));
    }

    if !symbol.bytes().all(is_yahoo_symbol_byte) {
        return Err(YfError::InvalidParams(format!(
            "symbol {symbol:?} contains characters unsupported by Yahoo Finance"
        )));
    }

    Ok(symbol.to_string())
}

pub fn normalize_symbols<'a>(
    symbols: impl IntoIterator<Item = &'a str>,
) -> Result<Vec<String>, YfError> {
    symbols.into_iter().map(normalize_symbol).collect()
}

const fn is_yahoo_symbol_byte(byte: u8) -> bool {
    byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'=' | b'^')
}

fn append_symbol_path_segment(base: &Url, symbol: &str) -> Result<Url, YfError> {
    let symbol = normalize_symbol(symbol)?;

    let mut url = base.clone();
    url.path_segments_mut()
        .map_err(|()| YfError::InvalidParams("base URL cannot accept path segments".into()))?
        .pop_if_empty()
        .push(&symbol);
    Ok(url)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn symbol_segment_keeps_supported_punctuation_without_retargeting_url() {
        let base = Url::parse("https://query1.finance.yahoo.com/v8/finance/chart/").unwrap();

        let url = append_symbol_path_segment(&base, "^gspc").unwrap();

        assert_eq!(url.scheme(), "https");
        assert_eq!(url.host_str(), Some("query1.finance.yahoo.com"));
        assert_eq!(url.path(), "/v8/finance/chart/^GSPC");
        assert_eq!(url.query(), None);
        assert_eq!(url.fragment(), None);
    }

    #[test]
    fn trailing_slash_base_still_gets_one_symbol_separator() {
        let base =
            Url::parse("https://query1.finance.yahoo.com/v10/finance/quoteSummary/").unwrap();

        let url = append_symbol_path_segment(&base, "BRK.B").unwrap();

        assert_eq!(
            url.as_str(),
            "https://query1.finance.yahoo.com/v10/finance/quoteSummary/BRK.B"
        );
    }

    #[test]
    fn rejects_dot_segments_that_url_would_drop() {
        let base = Url::parse("https://query1.finance.yahoo.com/v8/finance/chart/").unwrap();

        assert!(append_symbol_path_segment(&base, ".").is_err());
        assert!(append_symbol_path_segment(&base, "..").is_err());
    }

    #[test]
    fn rejects_empty_whitespace_and_url_syntax_symbols() {
        let base = Url::parse("https://query1.finance.yahoo.com/v8/finance/chart/").unwrap();

        assert!(append_symbol_path_segment(&base, "").is_err());
        assert!(append_symbol_path_segment(&base, " \t ").is_err());
        assert!(append_symbol_path_segment(&base, "https://evil.example/AAPL?x=1#frag").is_err());
    }

    #[test]
    fn normalize_symbol_trims_and_uppercases_supported_yahoo_symbols() {
        assert_eq!(normalize_symbol(" brk.b ").unwrap(), "BRK.B");
        assert_eq!(normalize_symbol("eurusd=x").unwrap(), "EURUSD=X");
        assert_eq!(normalize_symbol("btc-usd").unwrap(), "BTC-USD");
    }
}